Merge key-22 59feb513 into release-0.3.22 (the one chain key: scene/live-dag.js 2.0.5 legend, the app's chain card renders it, Your blocks first)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-07 18:01:11 +00:00
commit 4b147d34e0
8 changed files with 140 additions and 12 deletions

View file

@ -674,13 +674,14 @@ body.drawer-open .toast{bottom:calc(var(--drawer-h) + 16px)}
.legend{display:flex;flex-wrap:wrap;gap:8px 16px;margin-top:var(--s-3);font-size:var(--t-sm);color:var(--ash);align-items:center}
.legend span{display:inline-flex;align-items:center;gap:7px}
/* the DAG legend swatches, scoped to the legend (a bare .lg reached label.switch.lg until 7 October 2026) */
.legend .lg{width:10px;height:10px;border-radius:3px;display:inline-block;border:1.5px solid var(--ash);background:var(--row)}
.legend .lg.you{border-color:var(--molten);box-shadow:0 0 0 2px var(--molten-40)}
.legend .lg.pending{border-color:var(--ash)}
.legend .lg.included{border-color:var(--ember)}
.legend .lg.excluded{border-color:var(--ash);opacity:.45}
.legend .lg.proven{background:var(--ember);border-color:var(--ember)}
.legend .lg.locked{border-color:var(--ember);border-radius:50%;box-shadow:0 0 0 2px var(--ember-40)}
/* the key's swatches (key-22, 7 October 2026): the entries, order and tokens come from IgneumDag.legend; a swatch takes its
colour from --lg (set by renderLegend to the entry's token) and its shape from the class, so no state is coloured here */
.legend .lg{--lg:var(--ash);width:10px;height:10px;border-radius:3px;display:inline-block;border:1.5px solid var(--lg);background:var(--lg-fill,var(--row));position:relative;flex:none}
.legend .lg.faded{opacity:.45}
.legend .lg.circle{border-radius:50%;box-shadow:0 0 0 2px color-mix(in srgb,var(--lg) 40%,transparent)}
.legend .lg.ringed{box-shadow:0 0 0 2px color-mix(in srgb,var(--lg) 40%,transparent)}
.legend .lg.tick{border-color:var(--line-2)}
.legend .lg.tick::after{content:"\2713";position:absolute;left:0;top:-4px;font-size:11px;line-height:1;color:var(--lg)}
/* Cards: the Tuning strip above the list */
.tune-strip{padding:var(--s-4) var(--card-pad)}
.ts-row{display:flex;align-items:center;gap:var(--s-4);flex-wrap:wrap}

View file

@ -2007,6 +2007,8 @@ if (typeof document !== 'undefined') (function () {
}
function mountDag() {
if (dag || typeof IgneumDag === 'undefined' || !$('dag-live')) return;
// key-22: the key renders from the scene's one legend export (Your blocks first for this machine's view); the source line stays after it
if (IgneumDag.renderLegend) document.querySelectorAll('[data-legend]').forEach(function (el) { IgneumDag.renderLegend(el, { mine: el.getAttribute('data-legend') === 'mine' }); });
try {
dag = IgneumDag.mount($('dag-live'), { compact: true, window: 60, fps: 60, poll: false, mine: mineFn, tooltip: $('dag-tip'), chip: $('dag-chip'), pauseButton: $('dag-pause'),
onSelect: function (b, why) { if (b && why !== 'update') openFull(b.hash); },

View file

@ -246,7 +246,7 @@
<span class="dag-chip mono" id="dag-chip" hidden>scroll to zoom · Esc to release</span>
<canvas id="dag" aria-hidden="true" hidden></canvas>
</div>
<div class="legend"><span><i class="lg you"></i>Your blocks</span><span><i class="lg pending"></i>pending</span><span><i class="lg included"></i>Included</span><span><i class="lg excluded"></i>Excluded</span><span><i class="lg proven"></i>Proven</span><span><i class="lg locked"></i>Locked checkpoint</span><span class="dim" id="dag-note"></span></div>
<div class="legend" data-legend="mine"><span class="dim" id="dag-note"></span></div>
<!-- the full scene with the block inspector (the words of the site's /live): opened by Inspect or by a click on a block -->
<div class="chain-full" id="chain-full" hidden>
<div class="cf-bar">

View file

@ -1,5 +1,7 @@
/* Igneum DAG / Ember 2.0 (the EMBER 02 renderer pack, 7 Oct 2026). THE SHARED SOURCE: scene/live-dag.js; site/live-dag.js and
* app/igneum-app/ui/live-dag.js are byte-equal copies written by tools/scene/sync.mjs (the gate refuses a drifted copy). Edit here.
* 2.0.5 (key-22, 7 Oct 2026): IgneumDag.legend({mine}) and renderLegend(el,{mine}) are the one key both pages render; the app's
* Included swatch had been ember while the scene draws included blocks in --included, and "pending" lower-case.
* 2.0.4 (7 Oct 2026): the box's height follows the lanes. laneHeight (default 46 px, 40 on a phone, 26 compact) times the lanes
* shown (at most maxLanes, narrowLanes on a phone, never under 2) plus the axis and padding is the scene's wanted height;
* onSize(heightPx, {lanes, laneHeight, narrow, compact}) reports it whenever it changes, so a page can size the box, and with
@ -377,5 +379,25 @@
getSnapshot:function(){return {ok:true,state:{stale:state!=='live'},blocks:blocks.map(cloneBlock),finality:{checkpoints:cps.map(function(c){return Object.assign({},c);})}};}};
instances.set(canvas,api);if(chip)chip.hidden=true;theme();size();emit('onState',state,reason);if(opts.poll!==false)poll();redraw();return api;
}
root.IgneumDag={mount:mount,version:'2.0.4'};
// key-22 (7 October 2026, the app's key against /live): THE key, once. Entries in the order both
// pages show them, each naming the token the scene draws that state with and the swatch's shape; the app passes
// {mine:true} and gets "Your blocks" (ringed, molten) first. tools/scene/legend.test.mjs fails when a page's key or a
// drawn colour drifts from this list. Shapes: square (outlined), circle (the checkpoint ring), tick (the proof mark),
// ringed (the own-block halo); faded dims the swatch as the scene dims excluded blocks.
function legend(o){
var own={id:'you',label:'Your blocks',token:'--molten',shape:'ringed'};
var list=[{id:'pending',label:'Pending',token:'--ash',shape:'square'},{id:'included',label:'Included',token:'--included',shape:'square'},
{id:'excluded',label:'Excluded',token:'--excluded',shape:'square',faded:true},{id:'chain',label:'Selected chain',token:'--ember',shape:'square',fill:'--ember-12'},
{id:'proven',label:'Proven',token:'--bone',shape:'tick'},{id:'locked',label:'Locked checkpoint',token:'--ember',shape:'circle'}];
return (o&&o.mine?[own]:[]).concat(list);
}
// renders the key into el: <span><i class="lg <shape> [faded]" style="--lg:var(token)"></i>Label</span>…, keeping any
// children the page already placed there (the app's source line) after the entries
function renderLegend(el,o){
if(!el)return [];var entries=legend(o),doc=el.ownerDocument,keep=Array.prototype.slice.call(el.childNodes).filter(function(n){return !(n.getAttribute&&n.getAttribute('data-lg'));});
while(el.firstChild)el.removeChild(el.firstChild);
entries.forEach(function(e){var sp=doc.createElement('span');sp.setAttribute('data-lg',e.id);var i=doc.createElement('i');i.className='lg '+e.shape+(e.faded?' faded':'');i.style.setProperty('--lg','var('+e.token+')');if(e.fill)i.style.setProperty('--lg-fill','var('+e.fill+')');sp.appendChild(i);sp.appendChild(doc.createTextNode(e.label));el.appendChild(sp);});
keep.forEach(function(n){el.appendChild(n);});return entries;
}
root.IgneumDag={mount:mount,legend:legend,renderLegend:renderLegend,version:'2.0.5'};
})(window);

View file

@ -1,5 +1,7 @@
/* Igneum DAG / Ember 2.0 (the EMBER 02 renderer pack, 7 Oct 2026). THE SHARED SOURCE: scene/live-dag.js; site/live-dag.js and
* app/igneum-app/ui/live-dag.js are byte-equal copies written by tools/scene/sync.mjs (the gate refuses a drifted copy). Edit here.
* 2.0.5 (key-22, 7 Oct 2026): IgneumDag.legend({mine}) and renderLegend(el,{mine}) are the one key both pages render; the app's
* Included swatch had been ember while the scene draws included blocks in --included, and "pending" lower-case.
* 2.0.4 (7 Oct 2026): the box's height follows the lanes. laneHeight (default 46 px, 40 on a phone, 26 compact) times the lanes
* shown (at most maxLanes, narrowLanes on a phone, never under 2) plus the axis and padding is the scene's wanted height;
* onSize(heightPx, {lanes, laneHeight, narrow, compact}) reports it whenever it changes, so a page can size the box, and with
@ -377,5 +379,25 @@
getSnapshot:function(){return {ok:true,state:{stale:state!=='live'},blocks:blocks.map(cloneBlock),finality:{checkpoints:cps.map(function(c){return Object.assign({},c);})}};}};
instances.set(canvas,api);if(chip)chip.hidden=true;theme();size();emit('onState',state,reason);if(opts.poll!==false)poll();redraw();return api;
}
root.IgneumDag={mount:mount,version:'2.0.4'};
// key-22 (7 October 2026, the app's key against /live): THE key, once. Entries in the order both
// pages show them, each naming the token the scene draws that state with and the swatch's shape; the app passes
// {mine:true} and gets "Your blocks" (ringed, molten) first. tools/scene/legend.test.mjs fails when a page's key or a
// drawn colour drifts from this list. Shapes: square (outlined), circle (the checkpoint ring), tick (the proof mark),
// ringed (the own-block halo); faded dims the swatch as the scene dims excluded blocks.
function legend(o){
var own={id:'you',label:'Your blocks',token:'--molten',shape:'ringed'};
var list=[{id:'pending',label:'Pending',token:'--ash',shape:'square'},{id:'included',label:'Included',token:'--included',shape:'square'},
{id:'excluded',label:'Excluded',token:'--excluded',shape:'square',faded:true},{id:'chain',label:'Selected chain',token:'--ember',shape:'square',fill:'--ember-12'},
{id:'proven',label:'Proven',token:'--bone',shape:'tick'},{id:'locked',label:'Locked checkpoint',token:'--ember',shape:'circle'}];
return (o&&o.mine?[own]:[]).concat(list);
}
// renders the key into el: <span><i class="lg <shape> [faded]" style="--lg:var(token)"></i>Label</span>…, keeping any
// children the page already placed there (the app's source line) after the entries
function renderLegend(el,o){
if(!el)return [];var entries=legend(o),doc=el.ownerDocument,keep=Array.prototype.slice.call(el.childNodes).filter(function(n){return !(n.getAttribute&&n.getAttribute('data-lg'));});
while(el.firstChild)el.removeChild(el.firstChild);
entries.forEach(function(e){var sp=doc.createElement('span');sp.setAttribute('data-lg',e.id);var i=doc.createElement('i');i.className='lg '+e.shape+(e.faded?' faded':'');i.style.setProperty('--lg','var('+e.token+')');if(e.fill)i.style.setProperty('--lg-fill','var('+e.fill+')');sp.appendChild(i);sp.appendChild(doc.createTextNode(e.label));el.appendChild(sp);});
keep.forEach(function(n){el.appendChild(n);});return entries;
}
root.IgneumDag={mount:mount,legend:legend,renderLegend:renderLegend,version:'2.0.5'};
})(window);

View file

@ -1,5 +1,7 @@
/* Igneum DAG / Ember 2.0 (the EMBER 02 renderer pack, 7 Oct 2026). THE SHARED SOURCE: scene/live-dag.js; site/live-dag.js and
* app/igneum-app/ui/live-dag.js are byte-equal copies written by tools/scene/sync.mjs (the gate refuses a drifted copy). Edit here.
* 2.0.5 (key-22, 7 Oct 2026): IgneumDag.legend({mine}) and renderLegend(el,{mine}) are the one key both pages render; the app's
* Included swatch had been ember while the scene draws included blocks in --included, and "pending" lower-case.
* 2.0.4 (7 Oct 2026): the box's height follows the lanes. laneHeight (default 46 px, 40 on a phone, 26 compact) times the lanes
* shown (at most maxLanes, narrowLanes on a phone, never under 2) plus the axis and padding is the scene's wanted height;
* onSize(heightPx, {lanes, laneHeight, narrow, compact}) reports it whenever it changes, so a page can size the box, and with
@ -377,5 +379,25 @@
getSnapshot:function(){return {ok:true,state:{stale:state!=='live'},blocks:blocks.map(cloneBlock),finality:{checkpoints:cps.map(function(c){return Object.assign({},c);})}};}};
instances.set(canvas,api);if(chip)chip.hidden=true;theme();size();emit('onState',state,reason);if(opts.poll!==false)poll();redraw();return api;
}
root.IgneumDag={mount:mount,version:'2.0.4'};
// key-22 (7 October 2026, the app's key against /live): THE key, once. Entries in the order both
// pages show them, each naming the token the scene draws that state with and the swatch's shape; the app passes
// {mine:true} and gets "Your blocks" (ringed, molten) first. tools/scene/legend.test.mjs fails when a page's key or a
// drawn colour drifts from this list. Shapes: square (outlined), circle (the checkpoint ring), tick (the proof mark),
// ringed (the own-block halo); faded dims the swatch as the scene dims excluded blocks.
function legend(o){
var own={id:'you',label:'Your blocks',token:'--molten',shape:'ringed'};
var list=[{id:'pending',label:'Pending',token:'--ash',shape:'square'},{id:'included',label:'Included',token:'--included',shape:'square'},
{id:'excluded',label:'Excluded',token:'--excluded',shape:'square',faded:true},{id:'chain',label:'Selected chain',token:'--ember',shape:'square',fill:'--ember-12'},
{id:'proven',label:'Proven',token:'--bone',shape:'tick'},{id:'locked',label:'Locked checkpoint',token:'--ember',shape:'circle'}];
return (o&&o.mine?[own]:[]).concat(list);
}
// renders the key into el: <span><i class="lg <shape> [faded]" style="--lg:var(token)"></i>Label</span>…, keeping any
// children the page already placed there (the app's source line) after the entries
function renderLegend(el,o){
if(!el)return [];var entries=legend(o),doc=el.ownerDocument,keep=Array.prototype.slice.call(el.childNodes).filter(function(n){return !(n.getAttribute&&n.getAttribute('data-lg'));});
while(el.firstChild)el.removeChild(el.firstChild);
entries.forEach(function(e){var sp=doc.createElement('span');sp.setAttribute('data-lg',e.id);var i=doc.createElement('i');i.className='lg '+e.shape+(e.faded?' faded':'');i.style.setProperty('--lg','var('+e.token+')');if(e.fill)i.style.setProperty('--lg-fill','var('+e.fill+')');sp.appendChild(i);sp.appendChild(doc.createTextNode(e.label));el.appendChild(sp);});
keep.forEach(function(n){el.appendChild(n);});return entries;
}
root.IgneumDag={mount:mount,legend:legend,renderLegend:renderLegend,version:'2.0.5'};
})(window);

View file

@ -99,7 +99,7 @@ tree_checks() {
run "the home hero's loop never idles in view, stops hidden, resumes without a jump" node tools/site-redesign/tests/hero-loop-test.cjs
run "chain scene: the site's and the app's copies are scene/ byte for byte, the palette tokens live once (self-test, then the tree)" bash -c 'node tools/scene/sync.mjs --self-test && node tools/scene/sync.mjs --check'
run "chain scene: a push paints with the document hidden and no animation frame (the blank /live of 7 Oct 2026; known-failed first)" node tools/scene/paint-test.cjs
run "chain scene: the live feed contract (the recorded reply validates; a rewritten miner, a float now, a stray key refused)" node --test tools/scene/feed-contract.test.mjs
run "chain scene: the live feed contract (the recorded reply validates; a rewritten miner, a float now, a stray key refused)" node --test tools/scene/feed-contract.test.mjs tools/scene/legend.test.mjs
run "chain scene parity: one recorded feed through the home fold, /live and the app's Inspect view on build-2, three frames each pixel-equal apart from the app's own-key overlay (a changed token fails first; skipped with no box and no Playwright)" bash tools/scene/parity-remote.sh
run "explorer, emission and public stats unit tests" node --test site/lib/explorer.test.mjs site/lib/emission.test.mjs site/lib/money.test.mjs site/api/public-stats.test.mjs
run "ship tool self-test" node tools/ship-app.mjs --self-test

View file

@ -0,0 +1,59 @@
// node --test tools/scene/legend.test.mjs (key-22, the project lead's screenshot of the app's chain key against /live, 7 October 2026)
// The key is ONE export: IgneumDag.legend({ mine }) in scene/live-dag.js (entries, order, token names, shapes); the app's
// chain card and the site's /live key render from it, so they cannot drift. This test fails when either key's entries,
// order or tokens differ from the export, and when a colour the scene draws on blocks has no key entry.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { execSync } from 'node:child_process';
const here = dirname(fileURLToPath(import.meta.url)), root = join(here, '../..');
const src = readFileSync(join(root, 'scene/live-dag.js'), 'utf8');
const win = { document: { hidden: false, addEventListener() {}, createElement: () => ({ style: {} }) }, addEventListener() {}, matchMedia: () => ({ matches: false, addEventListener() {} }), getComputedStyle: () => ({ getPropertyValue: () => '' }), devicePixelRatio: 1, innerWidth: 1280 };
new Function('window', 'document', src)(win, win.document);
const Dag = win.IgneumDag;
const SITE_ORDER = ['Pending', 'Included', 'Excluded', 'Selected chain', 'Proven', 'Locked checkpoint'];
// the entries a page's markup carries today, from its literal spans (a hard-coded key) or none (a rendered key)
const literal = (html, tag) => [...html.matchAll(new RegExp('<span><i class="' + tag + '[^>]*></i>([^<]+)</span>', 'g'))].map((m) => m[1].trim());
test('the export: Pending first, Included, Excluded, Selected chain, Proven, Locked checkpoint; the app adds Your blocks first; every entry names its token and shape (known-failed on 0.3.22)', () => {
assert.ok(Dag && typeof Dag.legend === 'function', 'IgneumDag.legend exists');
const site = Dag.legend({ mine: false }), app = Dag.legend({ mine: true });
assert.deepEqual(site.map((e) => e.label), SITE_ORDER);
assert.deepEqual(app.map((e) => e.label), ['Your blocks'].concat(SITE_ORDER));
for (const e of app) { assert.match(e.token, /^--[a-z-]+$/, e.label + ' token'); assert.ok(['square', 'circle', 'tick', 'ringed'].includes(e.shape), e.label + ' shape'); assert.ok(e.id); }
assert.equal(app[0].token, '--molten'); assert.equal(app[0].shape, 'ringed');
assert.equal(site.find((e) => e.label === 'Included').token, '--included', 'the key draws Included in the colour the scene draws it');
assert.equal(site.find((e) => e.label === 'Excluded').token, '--excluded');
assert.equal(site.find((e) => e.label === 'Locked checkpoint').shape, 'circle');
assert.equal(site.find((e) => e.label === 'Proven').shape, 'tick');
assert.equal(typeof Dag.renderLegend, 'function', 'one renderer for both pages');
});
test('every colour the scene draws on a block or a band has a key entry', () => {
const tokens = new Set(Dag.legend({ mine: true }).map((e) => e.token));
// the renderer's palette read: col={ember:c('--ember',…), …, blue:c('--included',…), red:c('--excluded',…)}
const read = [...src.matchAll(/c\('(--[a-z0-9-]+)'/g)].map((m) => m[1]);
const drawn = ['--included', '--excluded', '--ember', '--molten', '--bone', '--ash'];
for (const t of drawn) { assert.ok(read.includes(t), 'the scene reads ' + t); assert.ok(tokens.has(t), 'the key has an entry in ' + t); }
});
// the site tree on a release-* branch is frozen and deploys from master alone (tools/scene/sync.mjs's scope rule), so the
// site half of the check binds on master (or with SCENE_SYNC_SCOPE=site) and is a diagnostic elsewhere
const branch = (() => { try { return execSync('git rev-parse --abbrev-ref HEAD', { cwd: root, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); } catch (e) { return ''; } })();
const siteBinds = process.env.SCENE_SYNC_SCOPE === 'site' || branch === 'master';
test('the app key and the site key are the export, not a hand-written list: no literal entries left, and any literal list that remains equals the export in labels and order', (t) => {
const app = readFileSync(join(root, 'app/igneum-app/ui/index.html'), 'utf8'), site = readFileSync(join(root, 'site/live.html'), 'utf8');
const appLit = literal(app, 'lg'), siteLit = literal(site, 'lgi');
assert.ok(/data-legend="mine"/.test(app), 'the app legend renders from IgneumDag.legend({ mine: true })');
assert.deepEqual(appLit, [], 'hand-written entries in the app key: ' + appLit.join(', '));
const siteOk = siteLit.length ? JSON.stringify(siteLit) === JSON.stringify(SITE_ORDER) : /data-legend/.test(site);
if (siteBinds) assert.ok(siteOk, 'the site key differs from the export: ' + (siteLit.length ? siteLit.join(', ') : 'no data-legend hook'));
else if (!siteOk) t.diagnostic('site/live.html still carries a hand-written key (' + siteLit.join(', ') + '); it binds on master once the site lane renders it from IgneumDag.renderLegend');
// the app's source line stays
assert.match(readFileSync(join(root, 'app/igneum-app/ui/app.js'), 'utf8'), /From Igneum’s observer\. Your blocks are ringed\./);
// the copies are the source (the sync gate says the same; this fails first when a legend edit misses the sync)
assert.equal(readFileSync(join(root, 'app/igneum-app/ui/live-dag.js'), 'utf8'), src, 'the app copy of live-dag.js is the scene source');
});