diff --git a/app/igneum-app/ui/app.js b/app/igneum-app/ui/app.js
index 8e995b7d..d3556155 100644
--- a/app/igneum-app/ui/app.js
+++ b/app/igneum-app/ui/app.js
@@ -2038,9 +2038,10 @@ if (typeof document !== 'undefined') (function () {
setText('i-title', b.locked ? 'Checkpoint locked' : b.proven ? 'Proof complete' : (b.shards || []).some(function (x) { return x.state === 'proving'; }) ? 'Proof in progress' : 'Block observed');
setText('i-hash', b.hash + (b.number ? ' · #' + fmtN(b.number) : ''));
var sh = b.shards || [], done = sh.filter(function (x) { return x.state === 'verified' || x.state === 'paid'; }).length;
- setText('i-shard-count', sh.length ? done + ' / ' + sh.length + ' complete' : 'no shard plan yet');
+ var sw = typeof IgneumDag !== 'undefined' && IgneumDag.shardWords ? IgneumDag.shardWords(b, Date.now()) : { summary: sh.length ? '' : 'no shard plan', items: sh.map(function (x) { return { word: x.state }; }) };
+ setText('i-shard-count', sh.length ? done + ' / ' + sh.length + ' complete' : sw.summary);
$('i-track').innerHTML = sh.map(function (x) { return ''; }).join('');
- $('i-shards').innerHTML = sh.length ? sh.map(function (x, i) { return '
Shard ' + String(i + 1).padStart(2, '0') + '' + esc(x.state.charAt(0).toUpperCase() + x.state.slice(1)) + ''; }).join('') : '' + (b.chain ? 'No shard plan yet.' : 'Not a chain block: no shards.') + '';
+ $('i-shards').innerHTML = sh.length ? sh.map(function (x, i) { return 'Shard ' + String(i + 1).padStart(2, '0') + '' + esc((sw.items[i] ? sw.items[i].word : x.state).replace(/^./, function (c) { return c.toUpperCase(); })) + ''; }).join('') : '' + esc(sw.summary.replace(/^./, function (c) { return c.toUpperCase(); })) + '.';
setText('i-incl', (b.color === 'blue' ? 'Included' : b.color === 'red' ? 'Excluded' : 'Pending') + (b.chain ? ' / selected chain' : ''));
setText('i-miner', (b.miner === 'you' ? 'you' : b.miner) + (mineB && b.miner !== 'you' ? ' (you)' : ''));
setText('i-blue', fmtN(b.blue_score)); setText('i-daa', fmtN(b.daa));
diff --git a/app/igneum-app/ui/live-dag.js b/app/igneum-app/ui/live-dag.js
index 6ef39583..886d1a7b 100644
--- a/app/igneum-app/ui/live-dag.js
+++ b/app/igneum-app/ui/live-dag.js
@@ -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.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
@@ -303,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;
@@ -379,6 +381,27 @@
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;
}
+ // 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
@@ -399,5 +422,5 @@
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'};
+ root.IgneumDag={mount:mount,legend:legend,renderLegend:renderLegend,shardWords:shardWords,version:'2.0.6'};
})(window);
diff --git a/scene/live-dag.js b/scene/live-dag.js
index 6ef39583..886d1a7b 100644
--- a/scene/live-dag.js
+++ b/scene/live-dag.js
@@ -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.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
@@ -303,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;
@@ -379,6 +381,27 @@
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;
}
+ // 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
@@ -399,5 +422,5 @@
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'};
+ root.IgneumDag={mount:mount,legend:legend,renderLegend:renderLegend,shardWords:shardWords,version:'2.0.6'};
})(window);
diff --git a/site/live-dag.js b/site/live-dag.js
index 6ef39583..886d1a7b 100644
--- a/site/live-dag.js
+++ b/site/live-dag.js
@@ -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.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
@@ -303,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;
@@ -379,6 +381,27 @@
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;
}
+ // 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
@@ -399,5 +422,5 @@
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'};
+ root.IgneumDag={mount:mount,legend:legend,renderLegend:renderLegend,shardWords:shardWords,version:'2.0.6'};
})(window);
diff --git a/tools/ci/pre-push.sh b/tools/ci/pre-push.sh
index 8e24c6e9..54a0c7f7 100755
--- a/tools/ci/pre-push.sh
+++ b/tools/ci/pre-push.sh
@@ -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 tools/scene/legend.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 tools/scene/shard-words.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
diff --git a/tools/scene/legend.test.mjs b/tools/scene/legend.test.mjs
index f972a50e..a91d4cc8 100644
--- a/tools/scene/legend.test.mjs
+++ b/tools/scene/legend.test.mjs
@@ -4,7 +4,7 @@
// 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 { readFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { execSync } from 'node:child_process';
@@ -48,13 +48,15 @@ const SITE_RULED = SITE_ORDER.filter((l) => l !== 'Pending');
// diagnostic on a release branch
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' || !!process.env.SITE_LIVE_HTML || branch === 'master';
+// the app lives on the release branches (master carries no app/igneum-app/ui/live-dag.js), so the app half binds where that copy exists
+const appBinds = existsSync(join(root, 'app/igneum-app/ui/live-dag.js'));
const siteEntries = (html) => [...html.matchAll(/<\/i>([^<]+)<\/span>/g)].map((m) => { const tok = /var\((--[a-z0-9-]+)\)/.exec(m[2] || ''); return { label: m[3].trim(), token: tok ? tok[1] : (/\btick\b/.test(m[1]) ? '--bone' : ''), shape: /\btick\b/.test(m[1]) ? 'tick' : /border-radius:50%/.test(m[2] || '') ? 'circle' : 'square' }; });
test('the app key is the export, not a hand-written list; the site key is the export minus the ruled exceptions (Pending, Your blocks), same wording and tokens on every shared entry', (t) => {
const app = readFileSync(join(root, 'app/igneum-app/ui/index.html'), 'utf8'), site = readFileSync(process.env.SITE_LIVE_HTML || join(root, 'site/live.html'), 'utf8');
const appLit = literal(app, 'lg');
- 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(', '));
+ if (appBinds) { 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(', ')); }
+ else t.diagnostic('no app scene copy in this tree (master): the app half binds on the release branches');
const got = siteEntries(site), exp = Dag.legend({ mine: false }).filter((e) => e.label !== 'Pending'), problems = [];
if (JSON.stringify(got.map((e) => e.label)) !== JSON.stringify(SITE_RULED)) problems.push('the /live key reads ' + got.map((e) => e.label).join(', ') + ', ruled: ' + SITE_RULED.join(', '));
for (const e of exp) { const g = got.find((x) => x.label === e.label); if (!g) continue; if (g.token !== e.token) problems.push(e.label + ' token on /live is ' + g.token + ', the export says ' + e.token); if (g.shape !== e.shape) problems.push(e.label + ' shape on /live is ' + g.shape + ', the export says ' + e.shape); }
@@ -62,7 +64,7 @@ test('the app key is the export, not a hand-written list; the site key is the ex
if (siteBinds) assert.deepEqual(problems, [], 'the /live key against the export:\n' + problems.join('\n'));
else if (problems.length) t.diagnostic('site/live.html on this release branch is the frozen cut (' + problems.join('; ') + '); the check binds on master, where the site deploys from');
// 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\./);
+ if (appBinds) 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');
+ if (appBinds) 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');
});
diff --git a/tools/scene/shard-words.test.mjs b/tools/scene/shard-words.test.mjs
new file mode 100644
index 00000000..e2501b58
--- /dev/null
+++ b/tools/scene/shard-words.test.mjs
@@ -0,0 +1,54 @@
+// node --test tools/scene/shard-words.test.mjs (7 October 2026: "why does the animation on site say no shard plan yet?")
+// IgneumDag.shardWords(block, nowMs) is the one source of the shard sentence the scene's tooltip, the site's /live
+// inspector and the app's inspector print. The empty case says what the block's own facts say: a block under 10 s old
+// with no shards is loading its plan, 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 planned shard with no prover is awaiting one, with the block's age;
+// proving, verified and paid as before.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync, existsSync } 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 NOW = 1_791_378_800_000;
+// the site deploys from master alone and a release-* branch carries site/ frozen as it was cut, so the site inspector's
+// check binds on master (or SCENE_SYNC_SCOPE=site) and is a diagnostic on a release branch
+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';
+const appBinds = existsSync(join(root, 'app/igneum-app/ui/live-dag.js')); // the app lives on the release branches
+const block = (over) => ({ hash: 'ab12', ts: NOW - 42_000, color: 'blue', chain: true, miner: 'k1', parents: [], blue_score: 1, daa: 1, shards: [], locked: false, final: false, proven: false, ...over });
+
+test('the four empty cases (known-failed on 2.0.5): loading under 10 s, excluded, not yet ordered, off the selected chain; an old chain block with no plan says how long', () => {
+ assert.ok(Dag && typeof Dag.shardWords === 'function', 'IgneumDag.shardWords exists');
+ assert.equal(Dag.shardWords(block({ ts: NOW - 4_000 }), NOW).summary, 'shard plan loading');
+ assert.equal(Dag.shardWords(block({ color: 'red', chain: false }), NOW).summary, 'excluded, nothing to prove');
+ assert.equal(Dag.shardWords(block({ color: 'pending', chain: false }), NOW).summary, 'not yet ordered');
+ assert.equal(Dag.shardWords(block({ chain: false }), NOW).summary, 'off the selected chain, no shards planned');
+ assert.equal(Dag.shardWords(block(), NOW).summary, 'no shard plan after 42 s');
+ for (const s of [Dag.shardWords(block({ ts: NOW - 4_000 }), NOW), Dag.shardWords(block({ color: 'red' }), NOW)]) assert.deepEqual(s.items, []);
+ assert.equal(/no shard plan yet/.test(src), false, 'the old words are gone from the scene');
+});
+
+test('a planned shard with no prover is awaiting one, with the block age; assigned, proving, verified and paid as before', (t) => {
+ const sh = [{ i: 0, n: 4, state: 'planned', prover: null }, { i: 1, n: 4, state: 'planned', prover: 'faa34a1a' }, { i: 2, n: 4, state: 'proving', prover: 'b2' }, { i: 3, n: 4, state: 'paid', prover: 'c3', payout: 4.5532 }];
+ const w = Dag.shardWords(block({ shards: sh }), NOW);
+ assert.deepEqual(w.items.map((x) => x.word), ['awaiting a prover · 42 s', 'assigned to faa34a1a', 'proving by b2', 'paid 4.5532 IGN']);
+ assert.equal(w.summary, '1 paid, 1 proving, 1 assigned, 1 awaiting a prover · 42 s');
+ const old = Dag.shardWords(block({ ts: NOW - 400_000, shards: [{ state: 'planned', prover: null }] }), NOW);
+ assert.equal(old.items[0].word, 'awaiting a prover · 7 min'); assert.equal(old.summary, '1 awaiting a prover · 7 min');
+ const done = Dag.shardWords(block({ shards: [{ state: 'verified' }, { state: 'paid', payout: 1 }] }), NOW);
+ assert.equal(done.summary, '1 paid, 1 verified'); assert.deepEqual(done.items.map((x) => x.word), ['verified', 'paid 1 IGN']);
+ // the tooltip line uses it (words() is an instance method, so the source line is the check)
+ assert.match(src, /'Shards: '\+shardWords\(b,nowMs\)\.summary/, 'the tooltip takes the shared sentence');
+ // both inspectors use it: no hand-written shard sentence left
+ const live = readFileSync(join(root, 'site/live.html'), 'utf8'), siteOk = !/no shard plan yet/i.test(live) && /IgneumDag\.shardWords\(/.test(live);
+ if (siteBinds) assert.ok(siteOk, 'the site inspector still carries its own shard sentence');
+ else if (!siteOk) t.diagnostic('site/live.html on this release branch is the frozen cut; the inspector check binds on master, where the site deploys from');
+ if (appBinds) { const app = readFileSync(join(root, 'app/igneum-app/ui/app.js'), 'utf8'); assert.equal(/no shard plan yet/i.test(app), false, 'the app inspector'); assert.match(app, /IgneumDag\.shardWords\(/, 'the app inspector takes the shared sentence'); }
+ else t.diagnostic('no app scene copy in this tree (master): the app half binds on the release branches');
+});