Merge live-22 1d09d0f3 into master (gate: green on 1d09d0f3, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge)

This commit is contained in:
igneum-josh 2026-10-07 19:47:27 +01:00
commit 45845b09d9
11 changed files with 303 additions and 19 deletions

View file

@ -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 Josh 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 Josh, 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.

View file

@ -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: <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,shardWords:shardWords,version:'2.0.6'};
})(window);

17
site/leaderboard.js Normal file
View file

@ -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);

View file

@ -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');
});

View file

@ -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: <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,shardWords:shardWords,version:'2.0.6'};
})(window);

View file

@ -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}
<thead><tr><th class="num">Rank</th><th>Key</th><th class="num">Blocks in window</th><th class="num">Presence</th><th>Last block</th></tr></thead>
<tbody id="w-body"><tr><td colspan="5">Waiting for the first read.</td></tr></tbody>
</table></div>
<p class="lb-more"><button type="button" class="linkish" id="w-toggle" hidden>Show top 20</button></p>
<p class="note" style="margin-top:14px;font-size:12px" id="w-note">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.</p>
</div>
<div class="dp">
@ -548,6 +552,7 @@ details.tablebar summary{display:flex;align-items:center}
</script>
<!-- footer:end -->
<script src="/leaderboard.js"></script>
<script src="/live-dag.js" defer></script>
<script src="/proof-core.js" defer></script>
<script>
@ -569,8 +574,9 @@ details.tablebar summary{display:flex;align-items:center}
(function(){var w=fn&&fn.weights,keys=w&&w.keys?w.keys.slice():[],p=fn&&fn.params||{};keys.sort(function(a,b){return (b.blocks||0)-(a.blocks||0);});
var seen={};(d.miners||[]).forEach(function(m){seen[m.id]=m;});
$('w-tag').textContent=keys.length?keys.length+' keys · window '+fmtN(p.weightWindow)+' DAA · dust '+fmtN(p.dust):'30-day weight';
$('w-body').innerHTML=keys.length?keys.slice(0,24).map(function(k,i){var m=seen[k.id],last=m&&m.last_seen?Math.max(0,Math.round((now-new Date(m.last_seen).getTime())/1000))+' s ago':'not in 10 min';
return '<tr'+(mine&&k.id===mine?' class="you"':'')+'><td class="num">'+(i+1)+'</td><td class="mono">'+esc(k.id)+(mine&&k.id===mine?' (you)':'')+'</td><td class="num">'+fmtN(k.blocks)+'</td><td class="num">'+(!k.voter?(k.stripped_until_daa?'stripped':'under dust'):k.participation===null||k.participation===undefined?'pending':Math.round(k.participation*100)+'%')+'</td><td>'+last+'</td></tr>';}).join('')+(keys.length>24?'<tr><td colspan="5">and '+(keys.length-24)+' more keys</td></tr>':''):'<tr><td colspan="5">No weights in the reply.</td></tr>';
$('w-body').innerHTML=keys.length?(function(){var LB=window.IgneumLeaderboard,r=LB?LB.rows(keys,lbShow,mine):{rows:keys.slice(0,24).map(function(k,i){return Object.assign({},k,{rank:i+1,you:!!mine&&k.id===mine});}),pinned:null,toggle:null};var tg=$('w-toggle');if(tg){tg.hidden=!r.toggle;if(r.toggle){tg.textContent=r.toggle.label;tg.dataset.next=r.toggle.next;}}
return r.rows.concat(r.pinned?[Object.assign({},r.pinned,{pin:true})]:[]).map(function(k){var m=seen[k.id],last=m&&m.last_seen?Math.max(0,Math.round((now-new Date(m.last_seen).getTime())/1000))+' s ago':'not in 10 min';
return '<tr'+(k.you?(k.pin?' class="you pinned"':' class="you"'):'')+'><td class="num">'+k.rank+'</td><td class="mono">'+esc(k.id)+(k.you?' (you)':'')+'</td><td class="num">'+fmtN(k.blocks)+'</td><td class="num">'+(!k.voter?(k.stripped_until_daa?'stripped':'under dust'):k.participation===null||k.participation===undefined?'pending':Math.round(k.participation*100)+'%')+'</td><td>'+last+'</td></tr>';}).join('');})():'<tr><td colspan="5">No weights in the reply.</td></tr>';
var tot=(d.miners||[]).length,voters=keys.filter(function(k){return k.voter;}).length,signing=keys.filter(function(k){return k.voter&&k.participation>=.9;}).length,lock=null;(fn&&fn.checkpoints||[]).forEach(function(c){if(c.state==='locked'&&(!lock||c.index>lock.index))lock=c;});
var rows=[['Keys with a block in 10 min',tot,tot],['Voters above the dust line',voters,keys.length||tot],['Voters signing 90% of points or more',signing,voters],['Weight signing at the last lock',lock&&lock.fraction_total!==null&&lock.fraction_total!==undefined?Math.round(lock.fraction_total*100)+'%':'pending',null]];
$('census').innerHTML=rows.map(function(r){var pc=r[2]&&typeof r[1]==='number'?r[1]/r[2]*100:(typeof r[1]==='string'&&/%$/.test(r[1])?parseFloat(r[1]):0);return '<div class="id-row"><span class="name"><i class="sw"></i>'+r[0]+'</span><span class="pct">'+(typeof r[1]==='number'?fmtN(r[1])+(r[2]&&r[2]!==r[1]?' of '+fmtN(r[2]):''):r[1])+'</span><span class="bar"><i style="width:'+Math.min(100,pc).toFixed(1)+'%"></i></span></div>';}).join('');})();
@ -611,8 +617,9 @@ details.tablebar summary{display:flex;align-items:center}
$('i-title').textContent=b.locked?'Checkpoint locked':b.proven?'Proof complete':(b.shards||[]).some(function(x){return x.state==='proving';})?'Proof in progress':'Block observed';
$('i-hash').textContent=b.hash+(b.number?' · #'+fmtN(b.number):'');
var sh=b.shards||[],done=sh.filter(function(x){return x.state==='verified'||x.state==='paid';}).length;
$('i-shard-count').textContent=sh.length?done+' / '+sh.length+' complete':'no shard plan yet';$('i-track').innerHTML=sh.map(function(x){return '<i class="'+esc(x.state)+'"></i>';}).join('');
$('i-shards').innerHTML=sh.length?sh.map(function(x,i){return '<li class="'+esc(x.state)+'"><span>Shard '+String(i+1).padStart(2,'0')+'</span><span>'+esc(x.state.charAt(0).toUpperCase()+x.state.slice(1))+'</span></li>';}).join(''):'<li class="muted">'+(b.chain?'No shard plan yet.':'Not a chain block: no shards.')+'</li>';
var sw=window.IgneumDag&&IgneumDag.shardWords?IgneumDag.shardWords(b,Date.now()):{summary:sh.length?'':'no shard plan',items:sh.map(function(x){return {word:x.state};})},cap=function(s){return s.replace(/^./,function(c){return c.toUpperCase();});};
$('i-shard-count').textContent=sh.length?done+' / '+sh.length+' complete':sw.summary;$('i-track').innerHTML=sh.map(function(x){return '<i class="'+esc(x.state)+'"></i>';}).join('');
$('i-shards').innerHTML=sh.length?sh.map(function(x,i){return '<li class="'+esc(x.state)+'"><span>Shard '+String(i+1).padStart(2,'0')+'</span><span>'+esc(cap(sw.items[i]?sw.items[i].word:x.state))+'</span></li>';}).join(''):'<li class="muted">'+esc(cap(sw.summary))+'.</li>';
$('i-incl').textContent=(b.color==='blue'?'Included':b.color==='red'?'Excluded':'Pending')+(b.chain?' / selected chain':'');
$('i-miner').textContent=b.miner+(mineB?' (you)':'');$('i-blue').textContent=fmtN(b.blue_score);$('i-daa').textContent=fmtN(b.daa);
$('i-parents').textContent=(b.parents||[]).length+' observed'+((b.parents||[]).length?': '+b.parents.join(', '):'');$('i-time').textContent=hms(b.ts)+' UTC';
@ -629,6 +636,7 @@ details.tablebar summary{display:flex;align-items:center}
// the Proven tile reads its own 600 s window every 10 s (provenTile below): proofs land minutes after the block, so the scene's short window would read zero
$('m-cp').textContent=cp?(cp.state==='locked'?'Locked':'Pending'):'None';$('m-cp-w').textContent=cp&&cp.fraction_total!==null&&cp.fraction_total!==undefined?Math.round(cp.fraction_total*100)+'% weight':'';$('m-cp-note').textContent=cp?'#'+fmtN(cp.index)+' · blue score '+fmtN(cp.blue_score):'no checkpoint in the reply';
$('c-count').textContent=st.blocks+' blocks';var r=dag.getViewRange(),lo=null,hi=null;dag.getBlocks().forEach(function(b){if(b.ts>=r.start&&b.ts<=r.end){if(lo===null||b.blue_score<lo)lo=b.blue_score;if(hi===null||b.blue_score>hi)hi=b.blue_score;}});$('blue-range').textContent=lo===null?'blue score pending':'blue score '+fmtN(lo)+' to '+fmtN(hi);}
var lbShow=10;document.getElementById('w-toggle').addEventListener('click',function(){lbShow=parseInt(this.dataset.next,10)||10;if(lastD)render(lastD);});
function mountDag(){if(dag||!window.IgneumDag)return;
dag=IgneumDag.mount($('dag'),{poll:false,window:60,fps:60,mine:mine||undefined,tooltip:$('dag-tip'),chip:$('dag-chip'),pauseButton:$('dag-pause'),
onWindow:function(w){$('z-pct').textContent=w+' s';if(lastD)render(lastD);},

View file

@ -6,9 +6,11 @@
# the full gate on landing. Retries while master moves. Never force; never from a dirty branch.
#
# tools/ci/merge-to-master.sh [<branch>] [--tries N] default: the current branch, 6 tries
# MERGE_REMOTE=box tools/ci/merge-to-master.sh ... land on another remote's master (the box mirror /srv/igneum.git while GitHub is
# suspended, 7 October 2026); the default stays origin
set -euo pipefail
ROOT=$(git rev-parse --show-toplevel); cd "$ROOT"
BRANCH="$(git rev-parse --abbrev-ref HEAD)"; TRIES=6
BRANCH="$(git rev-parse --abbrev-ref HEAD)"; TRIES=6; R="${MERGE_REMOTE:-origin}"
while [ $# -gt 0 ]; do case "$1" in --tries) TRIES="$2"; shift 2 ;; -*) echo "unknown option $1" >&2; exit 2 ;; *) BRANCH="$1"; shift ;; esac; done
[ -z "$(git status --porcelain --untracked-files=no)" ] || { echo "merge-to-master: the tree has uncommitted tracked changes; commit first" >&2; exit 1; }
SHA=$(git rev-parse "$BRANCH"); G=$(cd "$(git rev-parse --git-common-dir)" && pwd -P)
@ -19,18 +21,18 @@ if [ ! -f "$G/igneum-gate-green/$SHA" ]; then
fi
AUTHOR=(-c user.name=igneum-josh -c user.email=337424239+igneum-josh@users.noreply.github.com)
for i in $(seq 1 "$TRIES"); do
git fetch -q origin master; TIP=$(git rev-parse origin/master)
if git merge-base --is-ancestor "$SHA" "$TIP"; then echo "merge-to-master: ${SHA:0:8} is already on origin/master $(git log -1 --format=%h origin/master)"; exit 0; fi
git fetch -q "$R" master; TIP=$(git rev-parse "$R/master")
if git merge-base --is-ancestor "$SHA" "$TIP"; then echo "merge-to-master: ${SHA:0:8} is already on $R/master $(git log -1 --format=%h "$R/master")"; exit 0; fi
W=$(mktemp -d "${TMPDIR:-/tmp}/merge-to-master.XXXXXX"); rmdir "$W"
git worktree add -q --detach "$W" "$TIP"
if ( cd "$W" && git "${AUTHOR[@]}" merge -q --no-ff -m "Merge $BRANCH ${SHA:0:8} into master (gate: green on ${SHA:0:8}, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge)" "$SHA" ); then
if ( cd "$W" && git push -q origin HEAD:master ); then
git worktree remove --force "$W"; git fetch -q origin master
echo "merge-to-master: pushed on try $i: origin/master $(git log -1 --format='%h %ci' origin/master) $(TZ=Europe/London date '+%H:%M %Z')"; exit 0
if ( cd "$W" && git push -q "$R" HEAD:master ); then
git worktree remove --force "$W"; git fetch -q "$R" master
echo "merge-to-master: pushed on try $i: $R/master $(git log -1 --format='%h %ci' "$R/master") $(TZ=Europe/London date '+%H:%M %Z')"; exit 0
fi
echo "merge-to-master: try $i: the push was rejected (master moved or the hook was red); again"
else
echo "merge-to-master: the merge of $BRANCH onto ${TIP:0:8} does not apply cleanly; resolve on the branch (git merge origin/master) and retry" >&2
echo "merge-to-master: the merge of $BRANCH onto ${TIP:0:8} does not apply cleanly; resolve on the branch (git merge $R/master) and retry" >&2
git worktree remove --force "$W"; exit 1
fi
git worktree remove --force "$W" 2>/dev/null || true

View file

@ -117,10 +117,10 @@ 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 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 "no text overlaps: every served page at 390 to 1600 px, light and dark, the hero at each step (self-test first; IGNEUM_OVERLAP_APPS=1 adds the miner and wallet UIs)" overlap_sweep
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 "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/lib/leaderboard.test.mjs site/api/public-stats.test.mjs
run "ship tool self-test" node tools/ship-app.mjs --self-test
run "relay unit tests" node --test relay/test/parse.test.mjs relay/test/auth.test.mjs relay/test/wake.test.mjs relay/test/ember.test.mjs
run "miner app notice strip and update card tests" node --test app/igneum-app/ui/notices.test.mjs app/igneum-app/ui/update-card.test.mjs app/igneum-app/ui/view.test.mjs app/igneum-app/ui/tune-line.test.mjs

View file

@ -0,0 +1,70 @@
// node --test tools/scene/legend.test.mjs (key-22, Josh'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, 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 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 /live key as ruled (7 October 2026): Included, Excluded, Selected chain, Proven, Locked checkpoint; no Pending and no
// Your blocks (the site has no "you"). The exceptions are the ruling's; every entry the two surfaces share must carry the
// same wording and the same token as the export, so the swatches cannot drift apart again.
const SITE_RULED = SITE_ORDER.filter((l) => l !== 'Pending');
// the site deploys from master alone and a release-* branch carries site/ frozen as it was cut (tools/scene/sync.mjs's scope
// rule), so the site half binds on master (or SCENE_SYNC_SCOPE=site, or a site/live.html passed as SITE_LIVE_HTML) 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' || !!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(/<span><i class="lgi([^"]*)"(?: style="([^"]*)")?><\/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');
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); }
if (got.some((e) => /your/i.test(e.label))) problems.push('the site shows Your block; the ruling has none');
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
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)
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');
});

View file

@ -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');
});

View file

@ -33,6 +33,8 @@ createServer(async (req, res) => {
if (p.startsWith('/api/')) {
const name = p.slice(5).replace(/[^a-z0-9_-]/gi, '');
if (!existsSync(join(site, 'api', name + '.mjs'))) { res.statusCode = 404; return res.end('no such function'); }
// IGNEUM_LIVE_FIXTURE=<path>: answer /api/live from a recorded reply with its clock moved to now (captures and tests off the database)
if (name === 'live' && process.env.IGNEUM_LIVE_FIXTURE) { const f = JSON.parse(readFileSync(process.env.IGNEUM_LIVE_FIXTURE, 'utf8')); const shift = Date.now() - new Date(f.now).getTime(); f.now = new Date().toISOString(); for (const b of f.blocks || []) b.ts += shift; res.setHeader('Content-Type', 'application/json'); return res.end(JSON.stringify(f)); }
try { const h = await api(name); return await h({ method: req.method, url: req.url, headers: req.headers, query: Object.fromEntries(url.searchParams), socket: req.socket }, shim(res)); }
catch (e) { res.statusCode = 500; return res.end(String(e.stack || e)); }
}