From b30552e12adc3bb3f72a6847aa966a9fb29f59c1 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:13:23 +0000 Subject: [PATCH] /live: the shard sentence from the block's facts and the leaderboard's top ten (7 October 2026). scene/live-dag.js 2.0.6 (IgneumDag.shardWords, the one shard sentence; legend 2.0.5) synced into site/live-dag.js; the /live inspector prints it per shard and as the count line (a planned shard with no prover reads awaiting a prover with the block's age; an empty plan reads loading, excluded, not yet ordered or off the selected chain, never one fixed sentence); the weight leaderboard shows the top 10 by default with a toggle under the table (Show top 20, then Show all N with the live count, then back), the viewer's own key pinned as an extra row below the ten when it sits outside them, the table in a box of fixed height so the page below never jumps, the tag line unchanged (site/leaderboard.js carries the row logic, site/lib/leaderboard.test.mjs on the gate); tools/site-serve.mjs answers /api/live from IGNEUM_LIVE_FIXTURE for captures and tests off the database; the scene tests bind each half where it lives (site on master, app on the release branches) Co-Authored-By: Claude Fable 5.1 --- scene/live-dag.js | 51 ++++++++++++++++- site/leaderboard.js | 17 ++++++ site/lib/leaderboard.test.mjs | 28 ++++++++++ site/live-dag.js | 51 ++++++++++++++++- site/live.html | 17 ++++-- tools/ci/pre-push.sh | 96 ++++++-------------------------- tools/scene/legend.test.mjs | 70 +++++++++++++++++++++++ tools/scene/shard-words.test.mjs | 54 ++++++++++++++++++ tools/site-serve.mjs | 2 + 9 files changed, 297 insertions(+), 89 deletions(-) create mode 100644 site/leaderboard.js create mode 100644 site/lib/leaderboard.test.mjs create mode 100644 tools/scene/legend.test.mjs create mode 100644 tools/scene/shard-words.test.mjs diff --git a/scene/live-dag.js b/scene/live-dag.js index 7911c044..886d1a7b 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 00000000..bf921432 --- /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 00000000..9f8e72ea --- /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 7911c044..886d1a7b 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 7910febb..cfd3ebb2 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} RankKeyBlocks in windowPresenceLast block Waiting for the first read. +

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.

@@ -548,6 +552,7 @@ details.tablebar summary{display:flex;align-items:center} +