diff --git a/docs/plans/release-0.3.21.md b/docs/plans/release-0.3.21.md
index d8db7b724..5101e1f27 100644
--- a/docs/plans/release-0.3.21.md
+++ b/docs/plans/release-0.3.21.md
@@ -82,3 +82,16 @@ No node gate for 0.3.21: the app tree ships over the node already in the field (
## 7. LIVE on the Mac, 18:41:50 BST (main's ruling: the Mac entry now, Windows as its own entry)
Deploy runbook r0321/deploy.sh --mac-only --go (preflight: the staged manifest equals the live one on consensus.override, floor 900000, 16 fields, interface 1.0.1; the DMG present and read back). Copied into the live folder at 18:40:44 BST, Vercel deploy, the live manifest read back at 18:41:50 BST: version 0.3.21, channel devnet, mac Igneum-Miner-0.3.21-c4459193.dmg 490919d9 (44,538,877 bytes), windows none, floor 900000, ui 1.0.1; the DMG from the live URL 490919d9. The 0.3.20 hive package and the floor file unchanged. The Mac poller takes it within the hour or on Check now. The Windows entry lands as its own entry when an installer passes PC 2's smoke: (2a) a per-user Inno Setup 6 install on PC 2 by job (unelevated, fail clean) and (2b) the window host by mingw on the box run in parallel; a PC 1 build job (MSVC) wins over (2b) if the project lead allows one; (2c) windows.yml when GitHub returns is the last resort. Testnet re-arm line (the node lane, 18:38 BST): fork 6ed56f63 on release-0.3.22-node, digest 87d103b6 with no file, genesis 52a3e6a9 (5 October 00:00Z, past), every gate green on the exact commit; Devnet 3's digest on that binary still 83eb50cd. The 0.3.22 node pin candidate: N15 dfae08e5 as 34a2dbaa on 6ed56f63, gates running from 18:39 BST. Signing bonus (for the project lead, the node lane): supply unchanged, only who is paid (a silent producer 72 and the pool 28 instead of 80 and 20; fees untouched); waits for 0.3.23 whatever the decision (c7ea1e21 silent_split missing).
+**scene-parity-21-sizing in (15:0x BST):** 7e15bf2f on 3dc0832a: live-dag.js 2.0.4 (the box's height follows the lanes through onSize and autoHeight, for /live; the app passes neither, its frames byte-identical, the parity test equal on every comparison); scene/, site/ and the app copy byte-equal; no Rust change, the app gate of 20c9153b stands; pre-push 56 green. The same 2.0.4 is on master at b9017422 and served by igneum.network.
+
+**pool-finish-21 in (15:0x BST):** 2eea335f (the ten pool commits rebased onto 3dc0832a, no conflict; the pool-fee sentence in site/miner.html). Lines at that tip on build-2: igneum-pool 28; the app gate 229 + 32 + 8. Merged after scene-parity-21-sizing (JS only, so the Rust gate stands). The app side of 0.3.21 now carries: driver-check, miner-reliability-21 (cbd6f3a4), gpu-logos-21 with the Prove switch fix, earnings-tidy-21, scene-parity-21 and its sizing (live-dag.js 2.0.4), pool-finish-21; still mine: the under-12 GB prove-instead switch (section 2). The app gate on the final tree runs on build-2 about 19:30 BST or as soon as the switch lands.
+
+**N15 rides 0.3.21's node line (the node lane, 15:1x BST):** branch numbering-fix at d8bceca5 (a6864e36 then d8bceca5, from 52e96c94): chain_path checks the first added block's selected parent against the tip record before anything is appended and hands the orphan records above the fork point to the reorg unwind (the shape that left p1-5090 two high: a reorg's removed list one short at 15:51Z on 6 October); a start-time self-check once per process walks the records from the restart pin against the DAG's selected parents, names the first break, unwinds above it and lets the follower re-walk (the shape that left p2-3090-3 46 high from a snapshot carrying its source's break); recordsContinuous and continuityBreak on igneum_getProvingStatus and igneum_getExecStatus (null, true, or false with the break's block), box-prover claiming only on true. The number is canonical by construction from the pin; the carrier's refusal by number stands (the statement binds the number). Two unit tests known-failed first; the exec suite 35 and the kaspad check green on build-2 at 14:13Z; the live line is the fleet's restart of p1-5090 and p2-3090-3 on the 0.3.21 candidate (the self-check naming #155958 and #158875, the unwind, offset 0 after). The 0.3.21 node order, final, on byte 5: 52e96c94, f067f7c1, b0444f51, 437f0438, 2e32d5f6, f95178a1, a6864e36, d8bceca5.
+
+**update-return-21 in (15:3x BST):** 9d838ae5, one commit on b4289c4a (the app half: ota.rs, engine.rs, jobrun.rs, ember.rs, platform.rs, main.rs, bootcheck.rs; app/windows/host.cpp; Igneum-Miner.iss /IGNOTA=2; one round, main.rs's mod list by union). Lines on the tree: build-2 app tests 247 + 32 + 8; UI 74; the Windows cross green on build-1 (igneum-app.exe 4,172,288 B sha256 5b0598ad…, system DLLs only). host.cpp compiles in the cut's windows.yml run: the named gate line. The relay half stays on update-return for the relay lane's line through master.
+
+**first-block-21 in (16:0x BST):** 6e555d47 on 064fb02b (ladder.rs: first_block_shown persisted in ladder.json, Ladder::start_run; engine.rs start_run at load and started_at on the state; app.js staleCard, firstWait, the first rung reading the flag; the ui-mock secondblock scenario). Lines: build-2 app gate 254 + 32 + 8 (seven new ladder tests); UI 67 (view 46, one new known-failed first); pre-push 56. No installer, node or host change. Captures ~/Desktop/igneum-previews-2026-10-07/first-block/01 and 02.
+
+**Three more in (16:2x BST):** update-return-21b a64c193f (the eGPU card kind from a USB4 or Thunderbolt router in the device's parent chain, the Power Helper's fault line and its stale-prefix fix; app 255 + 32 + 8, UI 76); first-block-21 at cc9141cb replacing 6e555d47 (main's "seen once": the first-block card waits until a window has shown it, POST api/card/seen, ladder.rs card_seen; app 255 + 32 + 8, UI 67); miner-reliability-21 at 018440ae (the register: MF-11 in the update-return lane's words, MF-12 the pool stall, MF-13 the Power Helper's stale count; code unchanged since 4a28eb59, CI success). UI tests on the merged tree 76 of 76; the app gate on build-2 at the merged tip below. GitHub refused pushes with "Internal Server Error" from about 15:25 to 16:18 BST, transient.
+
+**The node order, final (16:3x BST):** on 55768f88: c631c64b first (the test-only fix of the two stale integration targets, 1026 → 1282; both green on build-2 at 15:34Z), then 52e96c94, f067f7c1, b0444f51, 437f0438, 2e32d5f6, f95178a1, a6864e36, d8bceca5; the tip's suite set runs the consensus crate whole (`-p kaspa-consensus` without `--lib`: the lib and both integration targets) beside consensus-core, the miner, kaspa-pow, the exec suite and the three checks (the suite rule from the 0.3.20 known-red finding). era-vdf-node 394a5902 is 0.3.22's.
diff --git a/scene/live-dag.js b/scene/live-dag.js
index 7911c0448..886d1a7b1 100644
--- a/scene/live-dag.js
+++ b/scene/live-dag.js
@@ -1,5 +1,9 @@
/* 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.6 (7 Oct 2026): IgneumDag.shardWords(block, nowMs) is the one shard sentence (tooltip, /live inspector, app inspector); the
+ * empty case reads from the block's facts (loading, excluded, not yet ordered, off the chain) instead of one fixed sentence.
+ * 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
@@ -301,13 +305,13 @@
function paint(){if(destroyed||!W||!H)return;var t=performance.now();draw(t);lastPaint=t;}
function redraw(){if(destroyed||raf||doc.hidden||!visible)return;lastPaint=-Infinity;raf=requestAnimationFrame(frame);}
function stopFrame(){if(raf)cancelAnimationFrame(raf);raf=0;}
- function words(b){
+ function words(b,nowMs){
if(!b)return {title:'No block selected',lines:[]};
var inclusion=b.color==='blue'?'included':b.color==='red'?'excluded':'pending';
var bits=[inclusion];if(b.chain)bits.push('selected chain');if(b.proven)bits.push('proven');if(b.locked)bits.push('locked checkpoint');if(b.final)bits.push('final');
return {title:b.hash+' '+bits.join(' / '),lines:[(own(b)?'Your block / ':'')+'miner key '+b.miner,
'Blue score '+fmt(b.blue_score===undefined?b.blue:b.blue_score)+' / DAA '+fmt(b.daa),b.parents.length+' parent'+(b.parents.length===1?'':'s'),
- 'Shards: '+(b.shards.length?b.shards.map(function(s){return s.state;}).join(', '):'no shard plan yet')]};
+ 'Shards: '+shardWords(b,nowMs).summary]};
}
function showTip(b){
if(!tip)return;
@@ -377,5 +381,46 @@
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'};
+ // the shard sentence, once (7 Oct 2026, the fixed sentence the site's animation showed on every planless block): from the
+ // block's own facts. No shards: an excluded block has nothing to prove, a pending block is not yet ordered, a blue block off
+ // the selected chain gets no plan, a chain block under 10 s old is loading its plan, an older one says how long it has waited.
+ // With shards: a planned shard with no prover is awaiting one (with the block's age), assigned, proving, verified, paid.
+ function ageWord(sec){sec=Math.max(0,Math.round(sec));return sec<90?sec+' s':sec<5400?Math.round(sec/60)+' min':Math.round(sec/3600)+' h';}
+ function shardWords(b,nowMs){
+ var now=finite(nowMs)?nowMs:Date.now(),age=b&&finite(b.ts)?Math.max(0,(now-b.ts)/1000):0,sh=(b&&b.shards)||[];
+ if(!sh.length){
+ if(!b)return {summary:'',items:[],state:'none'};
+ if(b.color==='red')return {summary:'excluded, nothing to prove',items:[],state:'excluded'};
+ if(b.color!=='blue')return {summary:'not yet ordered',items:[],state:'pending'};
+ if(!b.chain)return {summary:'off the selected chain, no shards planned',items:[],state:'none'};
+ if(age<10)return {summary:'shard plan loading',items:[],state:'loading'};
+ return {summary:'no shard plan after '+ageWord(age),items:[],state:'late'};
+ }
+ var items=sh.map(function(x,i){var st=x.state,w=st==='planned'?(x.prover?'assigned to '+x.prover:'awaiting a prover · '+ageWord(age)):st==='proving'?'proving'+(x.prover?' by '+x.prover:''):st==='verified'?'verified':st==='paid'?'paid'+(finite(x.payout)?' '+x.payout+' IGN':''):'unknown';return {i:i,state:st,word:w};});
+ var n={paid:0,verified:0,proving:0,assigned:0,awaiting:0};
+ sh.forEach(function(x){if(x.state==='planned'){if(x.prover)n.assigned++;else n.awaiting++;}else if(n[x.state]!==undefined)n[x.state]++;});
+ var parts=[];if(n.paid)parts.push(n.paid+' paid');if(n.verified)parts.push(n.verified+' verified');if(n.proving)parts.push(n.proving+' proving');if(n.assigned)parts.push(n.assigned+' assigned');if(n.awaiting)parts.push(n.awaiting+' awaiting a prover · '+ageWord(age));
+ return {summary:parts.join(', '),items:items,state:n.awaiting?'awaiting':n.proving?'proving':'done'};
+ }
+ // 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: Label…, 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,shardWords:shardWords,version:'2.0.6'};
})(window);
diff --git a/site/leaderboard.js b/site/leaderboard.js
new file mode 100644
index 000000000..bf9214327
--- /dev/null
+++ b/site/leaderboard.js
@@ -0,0 +1,17 @@
+/* The /live weight leaderboard's rows (7 October 2026): the top 10 by default, a toggle "Show top 20" then "Show all N" (the
+ count live), then back to the ten; the viewer's own key is always visible, as its row inside the ten or as a pinned extra
+ row below them with its true rank. A classic script (window.IgneumLeaderboard) for live.html; module.exports for the test. */
+(function (root) {
+ var L = {};
+ L.rows = function (keys, show, mine) {
+ keys = (keys || []).slice(); var total = keys.length, n = Math.min(show || 10, total);
+ var rows = keys.slice(0, n).map(function (k, i) { return Object.assign({}, k, { rank: i + 1, you: !!mine && k.id === mine }); });
+ var pinned = null;
+ if (mine) { var idx = -1; for (var i = 0; i < keys.length; i++) if (keys[i].id === mine) { idx = i; break; } if (idx >= n) pinned = Object.assign({}, keys[idx], { rank: idx + 1, you: true }); }
+ var toggle = null;
+ if (total > 10) { if (n <= 10 && total > 20) toggle = { label: 'Show top 20', next: 20 }; else if (n < total) toggle = { label: 'Show all ' + total, next: total }; else toggle = { label: 'Show top 10', next: 10 }; }
+ return { rows: rows, pinned: pinned, toggle: toggle, total: total, shown: n };
+ };
+ root.IgneumLeaderboard = L;
+ if (typeof module === 'object' && module && module.exports) module.exports = L;
+})(typeof window !== 'undefined' ? window : globalThis);
diff --git a/site/lib/leaderboard.test.mjs b/site/lib/leaderboard.test.mjs
new file mode 100644
index 000000000..9f8e72ea6
--- /dev/null
+++ b/site/lib/leaderboard.test.mjs
@@ -0,0 +1,28 @@
+// node --test site/lib/leaderboard.test.mjs (the /live weight leaderboard, 7 October 2026: top 10 by default, a toggle
+// "Show top 20" then "Show all N", the viewer's own key pinned below the ten when it sits outside them)
+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';
+const here = dirname(fileURLToPath(import.meta.url));
+const load = (p) => { const mod = { exports: {} }; new Function('module', 'window', readFileSync(p, 'utf8'))(mod, undefined); return mod.exports; };
+const L = load(join(here, '../leaderboard.js'));
+const keys = Array.from({ length: 41 }, (_, i) => ({ id: 'k' + String(i + 1).padStart(2, '0'), blocks: 1000 - i * 20, voter: true, participation: 1 }));
+
+test('top 10 by default, the toggle reads Show top 20, then Show all 41 with the live count, then back to Show top 10', () => {
+ const a = L.rows(keys, 10, null);
+ assert.equal(a.rows.length, 10); assert.equal(a.rows[0].rank, 1); assert.equal(a.rows[9].rank, 10); assert.equal(a.pinned, null);
+ assert.equal(a.toggle.label, 'Show top 20'); assert.equal(a.toggle.next, 20);
+ const b = L.rows(keys, 20, null); assert.equal(b.rows.length, 20); assert.equal(b.toggle.label, 'Show all 41'); assert.equal(b.toggle.next, 41);
+ const c = L.rows(keys, 41, null); assert.equal(c.rows.length, 41); assert.equal(c.toggle.label, 'Show top 10'); assert.equal(c.toggle.next, 10);
+ assert.equal(L.rows(keys.slice(0, 8), 10, null).toggle, null, 'no toggle when the ten hold everyone');
+ assert.equal(L.rows(keys.slice(0, 15), 10, null).toggle.label, 'Show all 15', 'between 10 and 20 the second step is the whole list');
+});
+
+test('the viewer\'s own key is always visible: inside the ten as its row, outside them as a pinned extra row with its true rank', () => {
+ const inside = L.rows(keys, 10, 'k03'); assert.equal(inside.pinned, null); assert.ok(inside.rows.some((r) => r.id === 'k03' && r.you));
+ const outside = L.rows(keys, 10, 'k27'); assert.equal(outside.rows.length, 10); assert.equal(outside.pinned.id, 'k27'); assert.equal(outside.pinned.rank, 27); assert.equal(outside.pinned.you, true);
+ assert.equal(L.rows(keys, 41, 'k27').pinned, null, 'shown in the full list, so not pinned twice');
+ assert.equal(L.rows(keys, 10, 'zz').pinned, null, 'a key not in the table pins nothing');
+});
diff --git a/site/live-dag.js b/site/live-dag.js
index 7911c0448..886d1a7b1 100644
--- a/site/live-dag.js
+++ b/site/live-dag.js
@@ -1,5 +1,9 @@
/* 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.6 (7 Oct 2026): IgneumDag.shardWords(block, nowMs) is the one shard sentence (tooltip, /live inspector, app inspector); the
+ * empty case reads from the block's facts (loading, excluded, not yet ordered, off the chain) instead of one fixed sentence.
+ * 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
@@ -301,13 +305,13 @@
function paint(){if(destroyed||!W||!H)return;var t=performance.now();draw(t);lastPaint=t;}
function redraw(){if(destroyed||raf||doc.hidden||!visible)return;lastPaint=-Infinity;raf=requestAnimationFrame(frame);}
function stopFrame(){if(raf)cancelAnimationFrame(raf);raf=0;}
- function words(b){
+ function words(b,nowMs){
if(!b)return {title:'No block selected',lines:[]};
var inclusion=b.color==='blue'?'included':b.color==='red'?'excluded':'pending';
var bits=[inclusion];if(b.chain)bits.push('selected chain');if(b.proven)bits.push('proven');if(b.locked)bits.push('locked checkpoint');if(b.final)bits.push('final');
return {title:b.hash+' '+bits.join(' / '),lines:[(own(b)?'Your block / ':'')+'miner key '+b.miner,
'Blue score '+fmt(b.blue_score===undefined?b.blue:b.blue_score)+' / DAA '+fmt(b.daa),b.parents.length+' parent'+(b.parents.length===1?'':'s'),
- 'Shards: '+(b.shards.length?b.shards.map(function(s){return s.state;}).join(', '):'no shard plan yet')]};
+ 'Shards: '+shardWords(b,nowMs).summary]};
}
function showTip(b){
if(!tip)return;
@@ -377,5 +381,46 @@
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'};
+ // the shard sentence, once (7 Oct 2026, the fixed sentence the site's animation showed on every planless block): from the
+ // block's own facts. No shards: an excluded block has nothing to prove, a pending block is not yet ordered, a blue block off
+ // the selected chain gets no plan, a chain block under 10 s old is loading its plan, an older one says how long it has waited.
+ // With shards: a planned shard with no prover is awaiting one (with the block's age), assigned, proving, verified, paid.
+ function ageWord(sec){sec=Math.max(0,Math.round(sec));return sec<90?sec+' s':sec<5400?Math.round(sec/60)+' min':Math.round(sec/3600)+' h';}
+ function shardWords(b,nowMs){
+ var now=finite(nowMs)?nowMs:Date.now(),age=b&&finite(b.ts)?Math.max(0,(now-b.ts)/1000):0,sh=(b&&b.shards)||[];
+ if(!sh.length){
+ if(!b)return {summary:'',items:[],state:'none'};
+ if(b.color==='red')return {summary:'excluded, nothing to prove',items:[],state:'excluded'};
+ if(b.color!=='blue')return {summary:'not yet ordered',items:[],state:'pending'};
+ if(!b.chain)return {summary:'off the selected chain, no shards planned',items:[],state:'none'};
+ if(age<10)return {summary:'shard plan loading',items:[],state:'loading'};
+ return {summary:'no shard plan after '+ageWord(age),items:[],state:'late'};
+ }
+ var items=sh.map(function(x,i){var st=x.state,w=st==='planned'?(x.prover?'assigned to '+x.prover:'awaiting a prover · '+ageWord(age)):st==='proving'?'proving'+(x.prover?' by '+x.prover:''):st==='verified'?'verified':st==='paid'?'paid'+(finite(x.payout)?' '+x.payout+' IGN':''):'unknown';return {i:i,state:st,word:w};});
+ var n={paid:0,verified:0,proving:0,assigned:0,awaiting:0};
+ sh.forEach(function(x){if(x.state==='planned'){if(x.prover)n.assigned++;else n.awaiting++;}else if(n[x.state]!==undefined)n[x.state]++;});
+ var parts=[];if(n.paid)parts.push(n.paid+' paid');if(n.verified)parts.push(n.verified+' verified');if(n.proving)parts.push(n.proving+' proving');if(n.assigned)parts.push(n.assigned+' assigned');if(n.awaiting)parts.push(n.awaiting+' awaiting a prover · '+ageWord(age));
+ return {summary:parts.join(', '),items:items,state:n.awaiting?'awaiting':n.proving?'proving':'done'};
+ }
+ // 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: Label…, 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,shardWords:shardWords,version:'2.0.6'};
})(window);
diff --git a/site/live.html b/site/live.html
index 7910febbd..7fef5a98e 100644
--- a/site/live.html
+++ b/site/live.html
@@ -158,6 +158,9 @@
.three{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--s-4);margin-top:var(--s-4)}
.two{display:grid;grid-template-columns:minmax(0,1.4fr) minmax(0,1fr);gap:var(--s-4);margin-top:var(--s-4)}
.lb table td,.lb table th{white-space:nowrap}.lb .you td{color:var(--molten-text)}
+/* the leaderboard (7 October 2026): ten rows by default in a box of fixed height, so the page below never jumps when the toggle opens; the
+ extra rows scroll inside; the viewer's pinned row sits under a dashed rule */
+.tbl.lb{height:432px;overflow:auto}.lb .pinned td{border-top:1px dashed var(--line-2)}.lb-more{margin:8px 0 0;font-size:12px}.lb-more .linkish{color:var(--ash)}
@media (max-width:900px){.two{grid-template-columns:1fr}}
@media (max-width:1000px){.three{grid-template-columns:1fr}}
.dp{border:var(--hair);border-radius:16px;background:var(--row);padding:25px;min-width:0}
@@ -460,6 +463,7 @@ details.tablebar summary{display:flex;align-items:center}
Rank Key Blocks in window Presence Last block
Weight is blue blocks over the window, never hashrate: a card that arrived today sits at the bottom. The dust line and the window are read from the node.