Chain scene 2.0.3: /live and the home fold paint on every push whatever the document's visibility says; the renderer, its palette and its feed contract move to one shared folder scene/ with byte-equal copies checked by the gate (7 October 2026, 15:2x UK)

The blank /live (the project lead, 14:3x UK): IgneumDag 2.0.2 painted only from a requestAnimationFrame loop gated on document.hidden and
the IntersectionObserver, so a page that loaded with document.hidden true (the desktop pane, a background tab) and whose embedder
never fired visibilitychange took every push, reported live and never drew a frame. 2.0.3 paints the current picture synchronously
on push, size and theme change; the motion loop alone waits for a visible document and an intersecting canvas. Confirmed headless
on build-2 against the live feed: hidden document 0 lit pixels before, 110,007 after; the never-intersecting observer repaints
on every push. Known-failed test tools/scene/paint-test.cjs (the 2.0.2 renderer draws nothing in the same world).

The second 2.0.3 change: the phone rule (30 s window, four lanes) keys on the viewport width, not the canvas width; a 640 px hero
on a 1,440 px laptop was rendering as a phone while the app's card beside it was not.

scene/ is the one source: live-dag.js, proof-core.js, tokens.css (the fourteen palette tokens, the brand package's values, dark and
light), feed-contract.md and .json (one JSON shape for the observer's /api/live and the app's api/live), a recorded reply as the
fixture. tools/scene/sync.mjs writes the copies and the scene-tokens block into site.css and app.css; --check is the gate line
(byte-equal scripts, an equal block, the names defined nowhere else, a print block excepted), --self-test fails five known cases
first. The site's token definitions move out of the package's :root line into the block; no value changes on the site.
tools/scene/feed-contract.mjs validates a reply against the key lists; its test refuses a miner rewritten to "you", a float now,
a stray key. Three new lines in tools/ci/pre-push.sh. The app side (branch scene-parity, for 0.3.21) takes the same folder.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit f77435346179a232b7d1bcf6c8efd08c3bce454f)
This commit is contained in:
igneum-labs 2026-10-07 13:28:59 +00:00
parent d0b358aac2
commit 8e267ae147
14 changed files with 979 additions and 8 deletions

35
scene/README.md Normal file
View file

@ -0,0 +1,35 @@
# scene: the chain scene, once
The live devnet animation on the site's home fold and `/live`, and the miner app's "The chain, live" card and its Inspect view,
are ONE renderer fed ONE JSON shape. This folder is the source; everything else is a copy or a reader.
| File | What it is | Copies |
|---|---|---|
| `live-dag.js` | EMBER 02 `IgneumDag` 2.0.3: lanes per vote key, parent curves, the selected chain, checkpoint bands, proof glow | `site/live-dag.js`, `app/igneum-app/ui/live-dag.js` |
| `proof-core.js` | `IgneumProof` 2.0.0: the selected block's shards | `site/proof-core.js`, `app/igneum-app/ui/proof-core.js` |
| `tokens.css` | The fourteen palette tokens the two scripts read from `:root`, dark and light, the brand package's values | the `scene-tokens` block in `site/site.css` and `app/igneum-app/ui/app.css` |
| `feed-contract.md`, `feed-contract.json` | The feed shape, in words and as key lists | read by `tools/scene/feed-contract.mjs` and the app's `src/live.rs` test |
| `fixtures/live-2026-10-07.json` | One recorded `/api/live?window=300` reply | the parity, paint and contract tests |
`node tools/scene/sync.mjs` writes the copies; `--check` is the gate line (byte-equal scripts, an equal token block, the token
names defined nowhere else, a print block excepted); `--self-test` proves the check on known-failed cases first. A side (site or
app) takes part once its `live-dag.js` copy exists; a `release-*` branch checks the app's copies only, because the release
branches carry the site tree as it was when they were cut and the site deploys from master alone.
## The one rule for the two surfaces
Same renderer, same tokens, same feed, same mount options: `window: 60`, `fps: 60`, `poll: false`, the page polls
`/api/live?window=300` every 2 s and pushes. What may differ is the box the scene sits in (the home hero is 500 px tall, `/live`
420 px, the app's card 220 px in `compact` mode and its Inspect view the `/live` height), and the app's viewpoint: its `mine`
option names this machine's vote keys, so its own blocks glow and its lane reads YOUR KEY. That is an overlay on the same
picture, never a second renderer, never a rewritten feed. The phone rule (30 s window, four lanes, bigger nodes) keys on the
viewport width under 720 px, not on the canvas width: a 640 px hero on a laptop is not a phone.
## The tests (all in `tools/ci/pre-push.sh`)
| Line | What it proves |
|---|---|
| `tools/scene/sync.mjs --self-test && --check` | the copies are the source, the tokens live once |
| `tools/scene/paint-test.cjs` | a push paints with the document hidden, the observer silent and no animation frame (the blank `/live` of 7 October 2026) |
| `node --test tools/scene/feed-contract.test.mjs` | the fixture validates; a rewritten miner, a float `now`, a stray key are refused |
| `tools/scene/parity-remote.sh` | on build-2: the fixture through the site's `/live` and the app's UI, three frames each, pixel-equal apart from the app's lane label; a changed token fails first |

20
scene/feed-contract.json Normal file
View file

@ -0,0 +1,20 @@
{
"version": 1,
"doc": "scene/feed-contract.md: the one JSON shape the chain scene reads, from the observer's /api/live and from the miner app's api/live alike",
"top": ["ok", "now", "state", "blocks", "miners", "events", "finality", "proving"],
"top_extensions": ["partial"],
"state": ["stale", "age_s", "network", "node_version", "height", "block_count", "header_count", "blue_score", "difficulty", "hashes_per_second_estimate", "peers", "mempool", "blocks_60s", "blocks_per_second_60s", "blocks_per_minute", "miners_10m", "observer_started_at", "observer_lag_s", "queue_depth", "updated_at"],
"state_extensions": ["source", "you_blocks", "node"],
"block": ["hash", "number", "blue_score", "daa", "ts", "rx", "parents", "chain", "miner", "color", "locked", "final", "shards", "proven"],
"miner": ["id", "blocks", "share", "last_seen", "engine"],
"event": ["ts", "kind", "text"],
"finality": ["supported", "active", "next_index", "latest_locked_index", "latest_locked_hash", "latest_locked_blue_score", "params", "weights", "checkpoints"],
"checkpoint": ["index", "hash", "blue_score", "daa", "state", "signed", "active", "total", "fraction_active", "fraction_total", "votes", "voters", "locked_at", "updated_at"],
"shard": ["i", "n", "state", "prover", "lag", "payout", "pgas"],
"proving_unsupported": ["supported", "reason"],
"proving_supported": ["supported", "active", "activation_daa", "tip_daa", "verifier", "pool", "paid_shards_total", "shard_budget_pgas", "blocks_10m", "blocks_fully_proven_10m", "shards_proven_10m", "shards_paid_10m", "median_proof_lag_s", "provers_10m"],
"color": ["blue", "red", "pending"],
"shard_state": ["planned", "proving", "verified", "paid"],
"checkpoint_state": ["pending", "locked"],
"miner_id": "^[0-9a-f]{8}$"
}

78
scene/feed-contract.md Normal file
View file

@ -0,0 +1,78 @@
# The live feed contract
One JSON shape. The chain scene (`scene/live-dag.js`, `scene/proof-core.js`) reads it, and two producers write it:
| Producer | Where | Source of the rows |
|---|---|---|
| The observer | `site/api/live.mjs` (GET `/api/live?window=N`, igneum.network) | Neon tables the observer (`tools/observer/observer.mjs`) fills from node 1 |
| The miner app | `app/igneum-app/src/live.rs` (GET `api/live?window=N` on the engine's port) | The observer's reply, passed through; when the observer is unreachable, the local node's `igneum_getRecentBlocks` shaped into the same rows (`partial: true`) |
The machine-readable key lists live in `scene/feed-contract.json`. `tools/scene/feed-contract.mjs` validates a reply against them
(the gate runs it over the recorded fixture and over known-bad shapes); the app's Rust test reads the same file, so neither side
can add a field or drop one without the other noticing.
## Top level
| Key | Type | Meaning |
|---|---|---|
| `ok` | true | A reply with `ok: false` carries `error` and nothing else |
| `now` | ISO 8601 string | The producer's clock when the reply was built |
| `state` | object | The network state, below |
| `blocks` | array | The window's blocks, oldest first, at most 1,800 |
| `miners` | array | Vote keys seen in the last 10 minutes |
| `events` | array | The last 30 observer events |
| `finality` | object | Checkpoints and weights |
| `proving` | object | The proving layer |
| `partial` | true, app only | The rows came from the local node: no parents, no proof state, no checkpoints |
## `state`
`stale`, `age_s`, `network`, `node_version`, `height` (the chain block number), `block_count`, `header_count`, `blue_score`,
`difficulty`, `hashes_per_second_estimate`, `peers`, `mempool`, `blocks_60s`, `blocks_per_second_60s`, `blocks_per_minute`
(10 numbers, oldest first), `miners_10m`, `observer_started_at`, `observer_lag_s`, `queue_depth`, `updated_at`. A value the
producer does not know is `null`, never missing.
App extensions, additive and ignored by the scene: `source` (`observer` or `node`), `you_blocks` (this machine's blocks in
the window), `node` (the local node's own numbers for the Cards tab).
## A block
| Key | Type | Meaning |
|---|---|---|
| `hash` | string, the first 16 hex | Both producers cut the hash the same way, so a hash reads the same everywhere |
| `number` | number or null | The chain block number once planned; null off the chain or before the plan |
| `blue_score`, `daa` | number or null | |
| `ts` | number | Header time in milliseconds |
| `rx` | number | When the producer first saw the block, milliseconds |
| `parents` | array of hash strings | Empty on a `partial` reply |
| `chain` | boolean | On the selected chain |
| `miner` | string, 8 hex | The vote key id. NEVER a word: the app marks its own blocks through the scene's `mine` option, not by rewriting this field |
| `color` | `blue`, `red` or `pending` | GHOSTDAG inclusion |
| `locked` | boolean | A locked checkpoint block |
| `final` | boolean | At or before the newest lock |
| `shards` | array of `{i, n, state, prover, lag, payout, pgas}` | `state` is `planned`, `proving`, `verified` or `paid` |
| `proven` | boolean | Every shard verified or paid |
## `miners[]`, `events[]`, `finality`, `proving`
A miner is `{id (8 hex), blocks, share (percent of the 10 minutes), last_seen (ISO or null), engine (string or null)}`.
An event is `{ts, kind, text}`.
`finality` is `{supported, active, next_index, latest_locked_index, latest_locked_hash, latest_locked_blue_score, params, weights,
checkpoints}`; a checkpoint is `{index, hash, blue_score, daa, state (pending or locked), signed, active, total, fraction_active,
fraction_total, votes, voters, locked_at, updated_at}`.
`proving` is `{supported: false, reason}` or the supported object (`active`, `activation_daa`, `tip_daa`, `verifier`, `pool`,
`paid_shards_total`, `shard_budget_pgas`, `blocks_10m`, `blocks_fully_proven_10m`, `shards_proven_10m`, `shards_paid_10m`,
`median_proof_lag_s`, `provers_10m`).
## What the scene does with it
`IgneumDag.mount(canvas, opts).push(reply)` normalises every block (unknown colours become `pending`, unknown shard states
`unknown`, a hash over 256 characters or a non-numeric `ts` drops the row), keeps the newest 340 s, and paints at once.
The window the scene shows (`opts.window`, 30 to 300 s) is independent of the window the page asks the producer for: both the
site and the app ask for 300 s so the viewer can pan and zoom out to the full range (`?window=300`).
## The recorded fixture
`scene/fixtures/live-2026-10-07.json` is one real `/api/live?window=300` reply (7 October 2026, 13:17 UTC, 905 blocks, 15
miners, 40 checkpoints, 14 proven blocks, 50 excluded). The contract test validates it; the parity test renders it through both
surfaces; the paint test pushes it into a hidden document.

File diff suppressed because one or more lines are too long

366
scene/live-dag.js Normal file
View file

@ -0,0 +1,366 @@
/* 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.3 (scene parity, 7 Oct 2026): (1) every push, size and theme change paints the current picture at once; only the motion
* loop waits for a visible document and an intersecting canvas (a page that loads with document.hidden true, or whose embedder
* never fires visibilitychange, showed a blank canvas while reporting live). (2) The narrow (phone) rule keys on the viewport
* width, not the canvas width: a 640 px hero on a laptop is not a phone. The narrow options of 2.0.2 (narrow, narrowBreak 720,
* narrowWindow 30, narrowLanes 4, narrowMinNode 11; on a phone the window shortens until the viewer sets one, four lanes, bigger
* nodes, no hairlines but the selected chain, checkpoint labels as the percent) and the real-data presentation options of 2.0.1:
* maxLanes (own key + top keys + others, default 7), minNode (node half-size floor, default 9.5), hairlineAlpha (non-chain
* edges, default .12, drawn only within laneReach lanes, default 2, and only a merged block's first parent unless selected),
* othersScale (the others lane's size and alpha, default .75), provenGlow (a glow and ring on proven blocks, default on).
* Replacement for the supplied live-dag.js. No framework, network writes or synthetic records.
* Existing mount/push options and public methods are retained; see README.md for additions.
* Every node, parent edge, proof segment and checkpoint is derived from the observer reply.
* Motion interpolates presentation ONLY. It never promotes proof, inclusion or finality.
*/
(function (root) {
'use strict';
var instances = new WeakMap();
var TAU = Math.PI * 2;
function clamp(n, a, b) { return Math.max(a, Math.min(b, n)); }
function finite(v) { return typeof v === 'number' && Number.isFinite(v); }
function num(v) { return finite(v) ? v : null; }
function fmt(v) { return v == null ? 'unknown' : Number(v).toLocaleString('en-GB'); }
function rgba(c, a) {
var s = String(c).trim(), h = s.replace('#', ''), rgb;
if (/^#[0-9a-f]{3}$/i.test(s)) h = h.split('').map(function (x) { return x + x; }).join('');
if (/^[0-9a-f]{6}$/i.test(h)) return 'rgba(' + parseInt(h.slice(0,2),16) + ',' + parseInt(h.slice(2,4),16) + ',' + parseInt(h.slice(4,6),16) + ',' + a + ')';
rgb = s.match(/^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/i);
return rgb ? 'rgba(' + rgb[1] + ',' + rgb[2] + ',' + rgb[3] + ',' + a + ')' : s;
}
function rounded(ctx, x, y, w, h, r) {
r = Math.max(0, Math.min(r, w/2, h/2)); ctx.beginPath(); ctx.moveTo(x+r,y);
ctx.arcTo(x+w,y,x+w,y+h,r); ctx.arcTo(x+w,y+h,x,y+h,r);
ctx.arcTo(x,y+h,x,y,r); ctx.arcTo(x,y,x+w,y,r); ctx.closePath();
}
function cloneBlock(b) {
return {hash:b.hash,ts:b.ts,blue_score:b.blue_score,blue:b.blue_score,daa:b.daa,
parents:b.parents.slice(),chain:b.chain,color:b.color,miner:b.miner,locked:b.locked,
final:b.final,proven:b.proven,shards:b.shards.map(function(s){return {state:s.state};})};
}
function normalize(b) {
if (!b || typeof b.hash !== 'string' || !b.hash.length || b.hash.length>256 || !finite(b.ts) || b.ts<0) return null;
return {hash:b.hash,ts:b.ts,blue_score:num(b.blue_score),daa:num(b.daa),
parents:Array.isArray(b.parents)?Array.from(new Set(b.parents.filter(function(p){return typeof p==='string' && p!==b.hash;}))).slice(0,128):[],
chain:b.chain===true,color:['blue','red','pending'].includes(b.color)?b.color:'pending',
miner:typeof b.miner==='string'?b.miner.slice(0,256):'unknown',locked:b.locked===true,final:b.final===true,proven:b.proven===true,
shards:Array.isArray(b.shards)?b.shards.slice(0,256).map(function(s){return {state:s && ['planned','proving','verified','paid'].includes(s.state)?s.state:'unknown'};}):[]};
}
function mount(canvas, opts) {
opts=opts||{};
if (!canvas || typeof canvas.getContext!=='function') throw new TypeError('IgneumDag.mount requires a canvas element.');
if (instances.has(canvas)) instances.get(canvas).destroy();
var ctx=canvas.getContext('2d'); if(!ctx) throw new Error('A 2D canvas context is required.');
var doc=canvas.ownerDocument, compact=!!opts.compact;
var windowS=clamp(finite(opts.window)?opts.window:(compact?90:120),30,300);
var maxBlocks=clamp(finite(opts.maxBlocks)?opts.maxBlocks:6000,100,20000);
var lag=finite(opts.lagMs)?clamp(opts.lagMs,0,10000):2500;
var fps=clamp(finite(opts.fps)?opts.fps:30,10,60), minFrame=1000/fps;
// narrow (phones, under narrowBreak px, or opts.narrow true/false): window narrowWindow s until the viewer sets one, at most
// narrowLanes lanes, nodes no smaller than narrowMinNode, hairlines off except the selected chain and the selected block,
// checkpoint labels as the percent alone. The owner's call of 7 Oct 2026: faster drift, more gap, on a phone.
var narrow=false,narrowBreak=finite(opts.narrowBreak)?opts.narrowBreak:720,narrowWindow=clamp(finite(opts.narrowWindow)?opts.narrowWindow:30,30,300),narrowLanes=clamp(finite(opts.narrowLanes)?opts.narrowLanes:4,2,12),narrowMinNode=finite(opts.narrowMinNode)?opts.narrowMinNode:11,baseWindow=0,windowTouched=false;
var maxLanes=clamp(finite(opts.maxLanes)?opts.maxLanes:7,2,12), minNode=finite(opts.minNode)?opts.minNode:9.5, hairAlpha=finite(opts.hairlineAlpha)?opts.hairlineAlpha:.12, laneReach=finite(opts.laneReach)?opts.laneReach:2, othersScale=finite(opts.othersScale)?opts.othersScale:.75, provenGlow=opts.provenGlow!==false;
var mq=root.matchMedia?root.matchMedia('(prefers-reduced-motion: reduce)'):null;
var reduced=mq?mq.matches:false, manualMotion=true;
var mediaTheme=root.matchMedia?root.matchMedia('(prefers-color-scheme: dark)'):null;
var col={}, W=0,H=0,dpr=1,padL=100,padR=22,padT=57,padB=37;
var model=new Map(), view=new Map(), blocks=[], cps=[], latestCps=[], lanes=[], laneOf=new Map();
var state='connecting',reason='Waiting for observer',destroyed=false,paused=false,following=true,engaged=false;
var fixedRight=0,pausedRight=0,staleRight=null,reducedRight=Date.now()-lag, lastGood=0, lastData=null;
var selected=null,hover=null,filter='all',queuedCount=0,invalid=0,omitted=0;
var visible=true,raf=0,lastPaint=-Infinity,frameCount=0,lastT=0,freezeT=0;
var pollTimer=0,healthTimer=0,timeout=0,controller=null,inflight=false,pollAgain=false,failures=0;
var remove=[],ro=null,io=null,mo=null,drag=null,pinch=null;
var lastCheckpointStates=new Map(),checkpointBursts=new Map();
var initialAttributes={tabindex:canvas.getAttribute('tabindex'),role:canvas.getAttribute('role'),label:canvas.getAttribute('aria-label'),touch:canvas.style.touchAction,cursor:canvas.style.cursor};
var tip=opts.tooltip||null,chip=opts.chip||null;
var mine=typeof opts.mine==='function'?opts.mine:opts.mine?function(b){return b.miner===opts.mine;}:function(){return false;};
function own(b){try{return !!mine(b);}catch(e){return false;}}
function emit(name){if(destroyed || typeof opts[name]!=='function')return;var args=[].slice.call(arguments,1);try{opts[name].apply(null,args);}catch(e){console.error('IgneumDag '+name+' callback:',e);}}
function on(target,name,handler,config){target.addEventListener(name,handler,config);remove.push(function(){target.removeEventListener(name,handler,config);});}
function theme(){
var s=getComputedStyle(doc.documentElement);
function c(n,f){return s.getPropertyValue(n).trim()||f;}
col={ember:c('--ember','#F2541B'),emberHi:c('--ember-hi','#FF6A2B'),molten:c('--molten','#FFB35C'),
bone:c('--bone','#F4F1EC'),ash:c('--ash','#9A9A9E'),line:c('--line','#2A2A30'),line2:c('--line-2','#3A3A42'),
bg:c('--row',c('--obsidian','#111114')),blue:c('--included','#3B7DD8'),red:c('--excluded','#B0362B'),
surface:c('--graphite','#16161A')};
paint();redraw();
}
function rightTime(){
if(paused)return pausedRight;
if(!following)return fixedRight;
if(state!=='live' && staleRight!==null)return staleRight;
return reduced||!manualMotion?reducedRight:Date.now()-lag;
}
function viewportW(){var w=root.innerWidth||(doc.documentElement?doc.documentElement.clientWidth:0);return w>0?w:W;}
function xOf(ts,right){return padL+(W-padL-padR)*(1-(right-ts)/(windowS*1000));}
function eventXY(ev){var r=canvas.getBoundingClientRect();return {x:(ev.clientX-r.left)*W/(r.width||1),y:(ev.clientY-r.top)*H/(r.height||1)};}
function setState(s,r){
if(state===s && reason===r)return;
if(s!=='live' && state==='live')staleRight=rightTime();
state=s;reason=r||null;
if(s==='live')staleRight=null;
emit('onState',s,reason);redraw();
}
function layout(){
if(!baseWindow)baseWindow=windowS;
var wasNarrow=narrow;narrow=opts.narrow===true||(opts.narrow!==false&&!compact&&W>0&&viewportW()<narrowBreak);
if(narrow!==wasNarrow&&!windowTouched){var nw=narrow?narrowWindow:baseWindow;if(nw!==windowS){windowS=nw;emit('onWindow',windowS);}}
padL=compact||W<510?14:W<750?84:106;padR=compact?12:24;padT=compact?29:58;padB=compact?22:38;
var start=rightTime()-windowS*1000, end=rightTime()+lag+1000,counts=new Map(),mineKeys=new Set();
blocks.forEach(function(b){if(b.ts>=start && b.ts<=end){counts.set(b.miner,(counts.get(b.miner)||0)+1);if(own(b))mineKeys.add(b.miner);}});
var capacity=Math.min(narrow?narrowLanes:maxLanes,Math.max(2,Math.floor((H-padT-padB)/(compact?26:narrow?40:46)))),ids=Array.from(counts.keys());
ids.sort(function(a,b){return (mineKeys.has(b)?1:0)-(mineKeys.has(a)?1:0)||(counts.get(b)-counts.get(a))||a.localeCompare(b);});
var chosen=ids.slice(0,ids.length>capacity?capacity-1:capacity);
// Preserve existing key order instead of re-ranking every poll. Always keep the user's lane visible.
var keep=lanes.filter(function(id){return chosen.includes(id);});
chosen.forEach(function(id){if(!keep.includes(id))keep.push(id);});
keep.sort(function(a,b){return (mineKeys.has(b)?1:0)-(mineKeys.has(a)?1:0);});
lanes=keep;if(ids.length>chosen.length)lanes.push('__others__');
laneOf=new Map();lanes.forEach(function(id,i){laneOf.set(id,i);});
var now=performance.now();
blocks.forEach(function(b){var i=laneOf.has(b.miner)?laneOf.get(b.miner):Math.max(0,lanes.length-1);
var y=padT+(i+.5)*(H-padT-padB)/Math.max(1,lanes.length);
if(!finite(b.y)){b.y=y;b.fromY=y;b.targetY=y;}
if(b.targetY!==y){b.fromY=b.y;b.targetY=y;b.laneAt=now;}
});
}
function size(){
if(destroyed)return;
var nextW=canvas.clientWidth,nextH=canvas.clientHeight,nextDpr=Math.min(root.devicePixelRatio||1,2);
if(W===nextW&&H===nextH&&dpr===nextDpr&&canvas.width===Math.round(nextW*nextDpr)){redraw();return;}
W=nextW;H=nextH;dpr=nextDpr;
canvas.width=Math.round(W*dpr);canvas.height=Math.round(H*dpr);ctx.setTransform(dpr,0,0,dpr,0,0);layout();paint();redraw();
}
function syncView(){
var now=performance.now(),first=view.size===0, next=new Map();
model.forEach(function(b,hash){var old=view.get(hash),v=Object.assign({},b);
v.born=old?old.born:first?now-3000:now;v.newArrival=old?old.newArrival:!first;v.visibleAt=old?old.visibleAt:null;v.changed=old && (old.proven!==b.proven||old.locked!==b.locked||old.color!==b.color||old.shards.map(function(s){return s.state;}).join(',')!==b.shards.map(function(s){return s.state;}).join(','))?now:old?old.changed:now-3000;
v.y=old?old.y:NaN;v.fromY=old?old.fromY:NaN;v.targetY=old?old.targetY:NaN;v.laneAt=old?old.laneAt:now;
next.set(hash,v);
});
view=next;blocks=Array.from(view.values()).sort(function(a,b){return a.ts-b.ts||a.hash.localeCompare(b.hash);});
cps=latestCps.map(function(c){return Object.assign({},c);});
cps.forEach(function(c){var prev=lastCheckpointStates.get(c.index);if(prev==='pending'&&c.state==='locked')checkpointBursts.set(c.index,now);lastCheckpointStates.set(c.index,c.state);});
var cpKeys=new Set(cps.map(function(c){return c.index;}));lastCheckpointStates.forEach(function(_,key){if(!cpKeys.has(key)){lastCheckpointStates.delete(key);checkpointBursts.delete(key);}});
layout();
if(selected && !view.has(selected)){selected=null;emit('onSelect',null,'expired');}
else if(selected)emit('onSelect',cloneBlock(view.get(selected)),'update');
if(hover&&!view.has(hover))hover=null;
queuedCount=0;
}
function push(d){
if(destroyed)return false;
if(!d||d.ok!==true||!d.state||typeof d.state!=='object'||!Array.isArray(d.blocks)){
setState('failed','Invalid observer reply; expected {ok, state, blocks}.');return false;
}
var now=Date.now(), incoming=[], bad=0;
d.blocks.forEach(function(b){var n=normalize(b);if(n)incoming.push(n);else bad++;});
if(d.blocks.length && !incoming.length){setState('failed','Observer reply contains no valid block records.');return false;}
invalid=bad;lastGood=now;reducedRight=now-lag;failures=0;lastData=d;
incoming.forEach(function(b){if(!model.has(b.hash))queuedCount++;model.set(b.hash,b);});
var newest=0;model.forEach(function(b){newest=Math.max(newest,b.ts);});
// Bounded retention uses the newest observed header, not a local wall-clock guess.
var cut=newest-340000;model.forEach(function(b,h){if(b.ts<cut)model.delete(h);});
omitted=0;
if(model.size>maxBlocks){var ordered=Array.from(model.values()).sort(function(a,b){return b.ts-a.ts;});omitted=model.size-maxBlocks;model=new Map(ordered.slice(0,maxBlocks).map(function(b){return[b.hash,b];}));}
latestCps=d.finality&&Array.isArray(d.finality.checkpoints)?d.finality.checkpoints.filter(function(c){return c&&finite(c.blue_score)&&['pending','locked'].includes(c.state);}).slice(-128).map(function(c){return {index:c.index,blue_score:c.blue_score,state:c.state,fraction_total:finite(c.fraction_total)&&c.fraction_total>=0&&c.fraction_total<=1?c.fraction_total:null};}):[];
if(d.state.stale){if(staleRight===null)staleRight=Math.min(now-lag,newest?newest+lag:now-lag);setState('stale','Observer stale'+(finite(d.state.age_s)?'; last update '+Math.round(d.state.age_s)+' s ago':''));}
else setState('live',null);
if(!paused)syncView();
emit('onData',d);emit('onStats',stats());if(!paused){/*paint-on-push*/paint();redraw();}return true;
}
function stats(){
var r=rightTime(),v=blocks.filter(function(b){return b.ts>=r-windowS*1000&&b.ts<=r;});
return {blocks:v.length,included:v.filter(function(b){return b.color==='blue';}).length,excluded:v.filter(function(b){return b.color==='red';}).length,
selectedChain:v.filter(function(b){return b.chain;}).length,proven:v.filter(function(b){return b.proven;}).length,
own:v.filter(own).length,minerKeys:new Set(v.map(function(b){return b.miner;})).size,
lockedCheckpoints:cps.filter(function(c){return c.state==='locked';}).length,paused:paused,following:following,queued:paused?queuedCount:0,
invalidRecords:invalid,omittedRecords:omitted,rendered:blocks.length,frames:frameCount,window:windowS,narrow:narrow,lanes:lanes.length,reducedMotion:reduced||!manualMotion,state:state};
}
function poll(){
if(destroyed||opts.poll===false||doc.hidden)return;
if(inflight){pollAgain=true;return;}
clearTimeout(pollTimer);inflight=true;controller=new AbortController();var signal=controller.signal;
timeout=setTimeout(function(){if(controller)controller.abort();},finite(opts.timeoutMs)?opts.timeoutMs:8000);
var url;
try{url=new URL(opts.url||'/api/live',doc.baseURI);if(!['https:','http:'].includes(url.protocol))throw new Error('Use an HTTP(S) observer URL.');url.searchParams.set('window',String(Math.round(windowS)));}
catch(e){finishError(e);return;}
fetch(url.href,{cache:'no-store',signal:signal,credentials:'same-origin'}).then(function(r){if(!r.ok)throw new Error('HTTP '+r.status);return r.json();}).then(function(d){if(destroyed)return;if(!push(d))throw new Error('Invalid observer reply');}).catch(finishError).finally(finish);
function finishError(e){if(destroyed||doc.hidden)return;failures++;setState(failures>=2?'failed':'stale','Observer unavailable; retrying'+(e&&e.name==='AbortError'?' (timeout)':''));if(!url)finish();}
function finish(){clearTimeout(timeout);timeout=0;controller=null;inflight=false;if(destroyed||opts.poll===false||doc.hidden)return;var again=pollAgain;pollAgain=false;pollTimer=setTimeout(poll,again?0:Math.min(30000,2000*Math.pow(2,Math.min(failures,4))));}
}
function passes(b){return filter==='all'||filter==='chain'&&b.chain||filter==='mine'&&own(b);}
function curve(b,p){var mx=(b.x+p.x)/2;ctx.beginPath();ctx.moveTo(p.x,p.y);ctx.bezierCurveTo(mx,p.y,mx,b.y,b.x,b.y);}
function pointOn(b,p,t){var u=1-t,mx=(b.x+p.x)/2;return {x:u*u*u*p.x+3*u*u*t*mx+3*u*t*t*mx+t*t*t*b.x,y:u*u*u*p.y+3*u*u*t*p.y+3*u*t*t*b.y+t*t*t*b.y};}
function lockIcon(x,y,size,color){ctx.strokeStyle=color;ctx.lineWidth=1.25;rounded(ctx,x-size*.42,y,size*.84,size*.65,1.5);ctx.stroke();ctx.beginPath();ctx.arc(x,y,size*.25,Math.PI,0);ctx.stroke();}
function draw(t){
if(destroyed||!W||!H)return;
frameCount++;var rt=rightTime(),left=rt-windowS*1000,animate=!reduced&&manualMotion&&!paused&&state==='live';var clock=paused?freezeT:t;
ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,W,H);ctx.fillStyle=col.bg;ctx.fillRect(0,0,W,H);
var plotW=W-padL-padR, plotH=H-padT-padB,lh=plotH/Math.max(1,lanes.length);
// Sparse time ticks; true horizontal coordinate is header time, not DAA spacing.
ctx.lineWidth=1;ctx.setLineDash([2,7]);
for(var g=0;g<=6;g++){var gx=padL+plotW*g/6;ctx.strokeStyle=rgba(col.line,.52);ctx.beginPath();ctx.moveTo(gx,padT-10);ctx.lineTo(gx,H-padB);ctx.stroke();}
ctx.setLineDash([]);
lanes.forEach(function(lane,i){var y=padT+(i+.5)*lh, ownLane=blocks.some(function(b){return b.miner===lane&&own(b);});
if(ownLane){var grad=ctx.createLinearGradient(padL,0,W,0);grad.addColorStop(0,rgba(col.molten,.085));grad.addColorStop(1,rgba(col.molten,.005));ctx.fillStyle=grad;rounded(ctx,padL-5,y-lh*.4,plotW+5,lh*.8,7);ctx.fill();}
ctx.strokeStyle=rgba(ownLane?col.molten:col.line,ownLane?.20:.65);ctx.beginPath();ctx.moveTo(padL,y);ctx.lineTo(W-padR,y);ctx.stroke();
if(padL>50){ctx.font='500 10px ui-monospace, SFMono-Regular, Consolas, monospace';ctx.textBaseline='middle';ctx.textAlign='left';ctx.fillStyle=ownLane?col.molten:col.ash;
ctx.fillText(ownLane?'YOUR KEY':lane==='__others__'?'OTHERS':lane.slice(0,8),12,y-5);
ctx.font='8px ui-monospace, monospace';ctx.fillStyle=rgba(col.ash,.78);ctx.fillText(ownLane?'YOUR BLOCKS':lane==='__others__'?'GROUPED KEYS':'MINER KEY',12,y+10);
}
});
var cpLabels=[];
cps.forEach(function(c){
// Exact score only. Never pin a checkpoint to an unrelated nearest block.
var candidates=blocks.filter(function(b){return b.blue_score===c.blue_score;});
var ref=candidates.find(function(b){return b.locked;})||candidates.find(function(b){return b.chain;})||(candidates.length===1?candidates[0]:null);
if(!ref||ref.ts<left||ref.ts>rt)return;
var x=xOf(ref.ts,rt),locked=c.state==='locked',burstAt=checkpointBursts.get(c.index),burst=animate&&burstAt?Math.max(0,1-(t-burstAt)/1400):0;
var band=ctx.createLinearGradient(x-20,0,x+36,0);band.addColorStop(0,rgba(col.ember,0));band.addColorStop(.38,rgba(col.ember,locked?.09:.035));band.addColorStop(1,rgba(col.ember,0));ctx.fillStyle=band;ctx.fillRect(x-20,padT-12,56,plotH+12);
ctx.strokeStyle=rgba(col.ember,locked?.6:.32);ctx.lineWidth=locked?1.5:1;ctx.setLineDash(locked?[]:[3,6]);ctx.beginPath();ctx.moveTo(x,padT-14);ctx.lineTo(x,H-padB);ctx.stroke();ctx.setLineDash([]);
if(burst>0){ctx.strokeStyle=rgba(col.molten,burst*.5);ctx.lineWidth=1;ctx.strokeRect(x-(1-burst)*42,padT-12,(1-burst)*84,plotH+12);}
if(!compact && (narrow || W>510))cpLabels.push({x:x,cp:c,locked:locked});
});
cpLabels.sort(function(a,b){return b.x-a.x;});var cpLast=Infinity;
cpLabels.forEach(function(p){var pct=p.cp.fraction_total===null||p.cp.fraction_total===undefined?'':Math.round(p.cp.fraction_total*100)+'%',label=narrow?(pct||(p.locked?'LOCKED':'PENDING')):(p.locked?'LOCKED':'PENDING')+(pct?' '+pct:''),w=label.length*5.5+(narrow?14:22),x=clamp(p.x-w/2,padL,W-padR-w);if(x+w+8>cpLast)return;cpLast=x;
ctx.fillStyle=col.bg;ctx.strokeStyle=rgba(col.ember,p.locked?.45:.23);rounded(ctx,x,17,w,22,4);ctx.fill();ctx.stroke();ctx.font='9px ui-monospace, monospace';ctx.fillStyle=p.locked?col.ember:col.ash;ctx.textAlign='center';ctx.textBaseline='middle';ctx.fillText(label,x+w/2,28);
});
// Animate lane transitions without changing their underlying key identity.
blocks.forEach(function(b){b.x=xOf(b.ts,rt);var progress=animate?clamp((clock-b.laneAt)/450,0,1):1;progress=1-Math.pow(1-progress,3);b.y=b.fromY+(b.targetY-b.fromY)*progress;b.on=b.ts>=left&&b.ts<=rt&&passes(b);if(b.on&&b.newArrival&&b.visibleAt===null){b.visibleAt=t;b.born=t;}});
ctx.save();ctx.beginPath();ctx.rect(padL-1,padT-15,plotW+2,plotH+25);ctx.clip();
blocks.forEach(function(b){if(!b.on)return;var bSel=selected&&b.hash===selected;b.parents.forEach(function(hash,pi){var p=view.get(hash);if(!p||!passes(p)||p.x>W-padR||p.x<padL-plotW*.2)return;
var path=b.chain&&p.chain,selectedEdge=selected&&(b.hash===selected||p.hash===selected);
if(!path&&!selectedEdge){if(narrow||pi>0)return;var li=laneOf.has(b.miner)?laneOf.get(b.miner):lanes.length-1,lp=laneOf.has(p.miner)?laneOf.get(p.miner):lanes.length-1;if(Math.abs(li-lp)>laneReach)return;}
if(path||selectedEdge){ctx.strokeStyle=rgba(path?col.ember:col.bone,path?.1:.10);ctx.lineWidth=path?8:4;curve(b,p);ctx.stroke();}
ctx.strokeStyle=rgba(path?col.ember:selectedEdge?col.bone:b.color==='red'?col.red:col.ash,path?.68:selectedEdge?.7:hairAlpha);ctx.lineWidth=path?1.65:selectedEdge?1.4:1;curve(b,p);ctx.stroke();
var age=t-b.born;if(animate&&age>=0&&age<1400){var f=clamp(age/1150,0,1),pt=pointOn(b,p,f);ctx.shadowBlur=10;ctx.shadowColor=path?col.ember:col.blue;ctx.fillStyle=path?col.molten:col.blue;ctx.beginPath();ctx.arc(pt.x,pt.y,path?2.2:1.65,0,TAU);ctx.fill();ctx.shadowBlur=0;}
});});
var S=compact?6.5:narrow?clamp(lh*.24,narrowMinNode,15):clamp(lh*.22,minNode,13);
blocks.forEach(function(b){if(!b.on)return;
var age=t-b.born,newness=animate?Math.max(clamp(1-age/1700,0,1),clamp(1-(t-b.changed)/900,0,1)*.65):0;
var ink=b.color==='red'?col.red:b.chain?col.ember:b.color==='blue'?col.blue:col.ash;
var isOwn=own(b),isSelected=b.hash===selected||b.hash===hover,inOthers=!laneOf.has(b.miner),sz=S*(1+(newness*.15))*(inOthers?othersScale:1);
if(newness>0||isOwn||isSelected){var glow=ctx.createRadialGradient(b.x,b.y,0,b.x,b.y,sz*3.8);glow.addColorStop(0,rgba(isOwn?col.molten:ink,newness*.26+(isSelected?.15:isOwn?.06:0)));glow.addColorStop(1,rgba(ink,0));ctx.fillStyle=glow;ctx.fillRect(b.x-sz*4,b.y-sz*4,sz*8,sz*8);}
ctx.globalAlpha=(b.color==='red'?.78:1)*(inOthers&&!isSelected&&!isOwn?othersScale:1);
// a proven block glows and carries a ring, so the proof reads from across the room
if(provenGlow&&b.proven){var pg=ctx.createRadialGradient(b.x,b.y,0,b.x,b.y,sz*3.2);pg.addColorStop(0,rgba(ink,.26));pg.addColorStop(1,rgba(ink,0));ctx.fillStyle=pg;ctx.fillRect(b.x-sz*3.4,b.y-sz*3.4,sz*6.8,sz*6.8);ctx.strokeStyle=rgba(ink,.6);ctx.lineWidth=1;ctx.beginPath();ctx.arc(b.x,b.y,sz+5,0,TAU);ctx.stroke();}
// Inset tiles: inclusion is the outline; proof is the core, never the same status.
ctx.fillStyle=col.bg;ctx.strokeStyle=rgba(ink,b.chain?.95:.8);ctx.lineWidth=b.chain?1.75:1.25;rounded(ctx,b.x-sz,b.y-sz,2*sz,2*sz,3);ctx.fill();ctx.stroke();
var tile=ctx.createLinearGradient(b.x,b.y-sz,b.x,b.y+sz);tile.addColorStop(0,rgba(ink,b.proven?.5:.16));tile.addColorStop(1,rgba(ink,b.proven?.22:.025));ctx.fillStyle=tile;rounded(ctx,b.x-sz+2,b.y-sz+2,2*sz-4,2*sz-4,2);ctx.fill();
if(b.proven){ctx.strokeStyle=rgba(b.color==='red'?col.red:col.bone,.9);ctx.lineWidth=1.3;ctx.beginPath();ctx.moveTo(b.x-3,b.y);ctx.lineTo(b.x-1,b.y+2);ctx.lineTo(b.x+3,b.y-2);ctx.stroke();}
else if(b.color==='red'){ctx.strokeStyle=ink;ctx.lineWidth=1.1;ctx.beginPath();ctx.moveTo(b.x-2,b.y-2);ctx.lineTo(b.x+2,b.y+2);ctx.moveTo(b.x+2,b.y-2);ctx.lineTo(b.x-2,b.y+2);ctx.stroke();}
else{ctx.fillStyle=rgba(ink,.65);ctx.fillRect(b.x-1.3,b.y-1.3,2.6,2.6);}
if(!compact && b.shards.length){var total=b.shards.length,shown=Math.min(total,8),gap=1.6,barW=2*sz,seg=(barW-gap*(shown-1))/shown;
for(var k=0;k<shown;k++){var shard=b.shards[k].state,alpha=shard==='proving'?(animate?.5+.4*Math.sin(t/300+k):.8):1;
ctx.fillStyle=rgba(shard==='paid'?col.molten:shard==='verified'?col.bone:shard==='proving'?col.ember:col.line2,alpha);ctx.fillRect(b.x-sz+k*(seg+gap),b.y+sz+4,Math.max(1,seg),2);
}
}
if(isOwn){ctx.strokeStyle=col.molten;ctx.lineWidth=1.2;var q=sz+4;ctx.beginPath();[[1,1],[1,-1],[-1,1],[-1,-1]].forEach(function(v){ctx.moveTo(b.x+v[0]*(q-4),b.y+v[1]*q);ctx.lineTo(b.x+v[0]*q,b.y+v[1]*q);ctx.lineTo(b.x+v[0]*q,b.y+v[1]*(q-4));});ctx.stroke();}
if(b.locked){ctx.strokeStyle=rgba(col.ember,.6);ctx.lineWidth=1;ctx.beginPath();ctx.arc(b.x,b.y,sz+8,0,TAU);ctx.stroke();}
if(b.final){ctx.fillStyle=rgba(col.ember,.75);ctx.beginPath();ctx.arc(b.x+sz-1,b.y-sz-2,2,0,TAU);ctx.fill();}
if(isSelected){ctx.strokeStyle=col.bone;ctx.lineWidth=1;ctx.setLineDash([3,3]);rounded(ctx,b.x-sz-8,b.y-sz-8,2*sz+16,2*sz+16,6);ctx.stroke();ctx.setLineDash([]);}
ctx.globalAlpha=1;
});ctx.restore();
// Timeline and feed edge. No fabricated DAA ticks or estimated checkpoint positions.
ctx.font=(compact?'8':'9')+'px ui-monospace, monospace';ctx.fillStyle=col.ash;ctx.textBaseline='middle';
var ticks=W<420?2:4;
for(var q=0;q<=ticks;q++){var tx=padL+plotW*q/ticks;ctx.textAlign=q===0?'left':q===ticks?'right':'center';var label=q===ticks?(paused?'PAUSED':!following?'HISTORY':state==='live'?'OBSERVED NOW':state.toUpperCase()):'-'+Math.round(windowS*(1-q/ticks))+'s';ctx.fillText(label,tx,H-(compact?8:13));}
if(!compact){var visibleBlocks=blocks.filter(function(b){return b.on;});ctx.font='8px ui-monospace, monospace';ctx.textAlign='left';ctx.fillStyle=rgba(col.ash,.8);if(padL>50)ctx.fillText('HEADER TIME',12,H-13);
if(visibleBlocks.length && W>600){var last=visibleBlocks[visibleBlocks.length-1];ctx.textAlign='right';ctx.fillText('DAA '+fmt(last.daa),W-padR,8);}
}
if(state==='live'&&following&&!paused){ctx.strokeStyle=rgba(col.ember,.3);ctx.beginPath();ctx.moveTo(W-padR,padT-9);ctx.lineTo(W-padR,H-padB);ctx.stroke();ctx.fillStyle=col.ember;ctx.beginPath();ctx.arc(W-padR,padT-12,2.3,0,TAU);ctx.fill();}
if(!blocks.some(function(b){return b.on;})){
ctx.textAlign='center';ctx.fillStyle=col.ash;ctx.font='12px system-ui, sans-serif';ctx.fillText(state==='connecting'?'Waiting for observer data':filter==='mine'?'No blocks for your key in this window':filter==='chain'?'No selected-chain blocks in this window':'No blocks in this time window',padL+plotW/2,H/2);
}
var active=selected?view.get(selected):hover?view.get(hover):null;if(active&&active.on)showTip(active);else if(tip)tip.hidden=true;
lastT=t;
}
function continuous(){return !destroyed&&!doc.hidden&&visible&&!paused&&!reduced&&manualMotion&&state==='live'&&blocks.length>0;}
function frame(t){raf=0;if(destroyed||doc.hidden||!visible)return;if(t-lastPaint>=minFrame-.5){draw(t);lastPaint=t;}if(continuous())raf=requestAnimationFrame(frame);}
// one synchronous frame of the current picture, whatever the document's visibility says (2.0.3)
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){
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')]};
}
function showTip(b){
if(!tip)return;
if(!b){tip.hidden=true;return;}
var w=words(b),key=w.title+'|'+w.lines.join('|');
if(tip.dataset.igneumKey!==key){tip.replaceChildren();var title=doc.createElement('b');title.textContent=w.title;tip.appendChild(title);w.lines.forEach(function(line){var p=doc.createElement('span');p.textContent=line;p.style.display='block';tip.appendChild(p);});tip.dataset.igneumKey=key;}
tip.hidden=false;tip.style.pointerEvents='none';tip.style.position='absolute';tip.style.left='0px';tip.style.top='0px';tip.style.maxWidth=Math.max(160,W-24)+'px';tip.style.whiteSpace='normal';tip.style.overflowWrap='anywhere';
var host=tip.offsetParent||canvas.parentElement,cr=canvas.getBoundingClientRect(),pr=host.getBoundingClientRect();
var tw=tip.offsetWidth,th=tip.offsetHeight;var x=clamp(b.x+16,8,Math.max(8,W-tw-8)),y=b.y-th-18;if(y<8)y=Math.min(H-th-8,b.y+22);
tip.style.transform='translate('+Math.round(cr.left-pr.left-host.clientLeft+x)+'px,'+Math.round(cr.top-pr.top-host.clientTop+Math.max(8,y))+'px)';
}
function hit(ev){var p=eventXY(ev),best=null,dist=22*22;blocks.forEach(function(b){if(!b.on)return;var d=(b.x-p.x)**2+(b.y-p.y)**2;if(d<dist){dist=d;best=b;}});return best;}
function select(hash,why){if(hash!==null&&!view.has(hash))return false;selected=hash;showTip(hash?view.get(hash):null);emit('onSelect',hash?cloneBlock(view.get(hash)):null,why||'select');redraw();return true;}
function engage(onValue){var value=!!onValue;if(engaged===value)return;engaged=value;canvas.classList.toggle('engaged',value);if(chip)chip.hidden=!value;emit('onEngage',value);}
function setWindow(seconds){var n=Math.round(clamp(seconds,30,300));if(!finite(n)||n===windowS)return;windowTouched=true;windowS=n;layout();emit('onWindow',n);emit('onStats',stats());poll();redraw();}
function zoom(f){if(!finite(f)||f<=0)return;setWindow(windowS*f);}
function follow(){following=true;fixedRight=0;if(paused)pausedRight=Date.now()-lag;layout();emit('onFollow',true);redraw();}
function setPaused(p){p=!!p;if(paused===p)return;
if(p){pausedRight=rightTime();freezeT=performance.now();paused=true;stopFrame();}
else{paused=false;syncView();}
if(opts.pauseButton){opts.pauseButton.setAttribute('aria-pressed',String(paused));opts.pauseButton.textContent=paused?'Resume':'Pause';}
emit('onPause',paused);emit('onStats',stats());redraw();
}
function setFilter(value){if(!['all','chain','mine'].includes(value))return;filter=value;select(null,'filter');layout();redraw();}
function setMotion(value){manualMotion=!!value;reducedRight=Date.now()-lag;stopFrame();redraw();}
function panTo(right){following=false;fixedRight=Math.min(right,Date.now()-lag);if(paused)pausedRight=fixedRight;emit('onFollow',false);layout();redraw();}
canvas.setAttribute('tabindex',canvas.getAttribute('tabindex')||'0');canvas.setAttribute('role','img');
if(!canvas.getAttribute('aria-label'))canvas.setAttribute('aria-label','Interactive block graph. Arrow keys inspect blocks. Plus and minus zoom. Space pauses, F follows, Escape releases.');
canvas.style.touchAction='pan-y';
on(canvas,'pointerdown',function(ev){if(ev.pointerType==='touch'||ev.button!==0)return;canvas.focus({preventScroll:true});engage(true);drag={id:ev.pointerId,x:ev.clientX,start:rightTime(),moved:false};canvas.setPointerCapture(ev.pointerId);});
on(canvas,'pointermove',function(ev){
if(drag&&drag.id===ev.pointerId){var dx=ev.clientX-drag.x;if(Math.abs(dx)>4)drag.moved=true;if(drag.moved){panTo(drag.start-dx*windowS*1000/Math.max(1,W-padL-padR));canvas.style.cursor='grabbing';return;}}
var b=hit(ev);hover=b?b.hash:null;canvas.style.cursor=b?'pointer':'grab';if(!selected)showTip(b);if(!continuous())redraw();
});
on(canvas,'pointerup',function(ev){if(ev.pointerType==='touch')return;if(!drag||drag.id!==ev.pointerId)return;var moved=drag.moved;drag=null;if(canvas.hasPointerCapture(ev.pointerId))canvas.releasePointerCapture(ev.pointerId);canvas.style.cursor='grab';if(!moved){var b=hit(ev);select(b&&selected!==b.hash?b.hash:null,'pointer');}});
on(canvas,'pointercancel',function(){drag=null;});
on(canvas,'pointerleave',function(){hover=null;if(!drag)engage(false);if(!selected)showTip(null);redraw();});
on(canvas,'wheel',function(ev){if(!ev.ctrlKey&&!ev.metaKey&&!engaged)return;if(Math.abs(ev.deltaY)<1)return;ev.preventDefault();zoom(ev.deltaY>0?1.15:1/1.15);},{passive:false});
var touchStart=null;
function dist(touches){return Math.hypot(touches[0].clientX-touches[1].clientX,touches[0].clientY-touches[1].clientY);}
on(canvas,'touchstart',function(ev){if(ev.touches.length===2){pinch={distance:dist(ev.touches),window:windowS};touchStart=null;}else if(ev.touches.length===1){touchStart={x:ev.touches[0].clientX,y:ev.touches[0].clientY};}},{passive:true});
on(canvas,'touchmove',function(ev){if(ev.touches.length===2&&pinch){ev.preventDefault();setWindow(pinch.window*pinch.distance/Math.max(1,dist(ev.touches)));}else if(touchStart&&ev.touches.length){if(Math.hypot(ev.touches[0].clientX-touchStart.x,ev.touches[0].clientY-touchStart.y)>8)touchStart=null;}},{passive:false});
on(canvas,'touchend',function(ev){if(pinch){if(ev.touches.length<2)pinch=null;touchStart=null;return;}if(touchStart&&ev.changedTouches.length){var b=hit(ev.changedTouches[0]);select(b&&selected!==b.hash?b.hash:null,'touch');touchStart=null;}},{passive:true});
on(canvas,'keydown',function(ev){
var key=ev.key;if(['ArrowLeft','ArrowRight','ArrowUp','ArrowDown','Home','End'].includes(key)){
ev.preventDefault();var list=blocks.filter(function(b){return b.on;}),i=list.findIndex(function(b){return b.hash===selected;}),next;
if(!list.length)return;if(key==='Home')next=0;else if(key==='End')next=list.length-1;else next=i<0?list.length-1:clamp(i+(['ArrowRight','ArrowDown'].includes(key)?1:-1),0,list.length-1);select(list[next].hash,'keyboard');
}else if(key===' '){ev.preventDefault();setPaused(!paused);}else if(key==='+'||key==='='){ev.preventDefault();zoom(1/1.2);}else if(key==='-'){ev.preventDefault();zoom(1.2);}else if(key.toLowerCase()==='f'){ev.preventDefault();follow();}
});
on(doc,'keydown',function(ev){if(ev.key==='Escape'&&(engaged||doc.activeElement===canvas)){engage(false);select(null,'escape');}});
on(doc,'pointerdown',function(ev){if(engaged&&ev.target!==canvas&&!(chip&&chip.contains(ev.target)))engage(false);});
if(opts.pauseButton){opts.pauseButton.setAttribute('aria-pressed','false');on(opts.pauseButton,'click',function(){setPaused(!paused);});}
on(root,'resize',size);
if('ResizeObserver'in root){ro=new ResizeObserver(size);ro.observe(canvas);}
if('IntersectionObserver'in root){io=new IntersectionObserver(function(es){visible=es[0].isIntersecting;if(visible)redraw();else stopFrame();},{threshold:0});io.observe(canvas);}
if('MutationObserver'in root){mo=new MutationObserver(theme);mo.observe(doc.documentElement,{attributes:true,attributeFilter:['data-theme','class','style']});}
if(mq&&mq.addEventListener)on(mq,'change',function(e){reduced=e.matches;reducedRight=Date.now()-lag;stopFrame();redraw();});
if(mediaTheme&&mediaTheme.addEventListener)on(mediaTheme,'change',theme);
on(doc,'visibilitychange',function(){if(doc.hidden){stopFrame();clearTimeout(pollTimer);if(controller)controller.abort();}else{poll();redraw();}});
healthTimer=setInterval(function(){if(destroyed||doc.hidden||!lastGood)return;if(state==='live'&&Date.now()-lastGood>(finite(opts.staleAfterMs)?opts.staleAfterMs:15000))setState('stale','No observer update for '+Math.round((Date.now()-lastGood)/1000)+' s');},1000);
function destroy(){if(destroyed)return;destroyed=true;stopFrame();clearTimeout(pollTimer);clearTimeout(timeout);clearInterval(healthTimer);if(controller)controller.abort();remove.forEach(function(fn){fn();});if(ro)ro.disconnect();if(io)io.disconnect();if(mo)mo.disconnect();if(tip)tip.hidden=true;canvas.classList.remove('engaged');
[['tabindex',initialAttributes.tabindex],['role',initialAttributes.role],['aria-label',initialAttributes.label]].forEach(function(pair){if(pair[1]===null)canvas.removeAttribute(pair[0]);else canvas.setAttribute(pair[0],pair[1]);});canvas.style.touchAction=initialAttributes.touch;canvas.style.cursor=initialAttributes.cursor;instances.delete(canvas);model.clear();view.clear();blocks=[];
}
var api={push:push,setPaused:setPaused,zoom:zoom,engage:engage,getWindow:function(){return windowS;},getState:function(){return {state:state,reason:reason};},redraw:redraw,words:words,
destroy:destroy,follow:follow,setWindow:setWindow,setFilter:setFilter,setMotion:setMotion,select:select,getStats:stats,
getViewRange:function(){return {start:rightTime()-windowS*1000,end:rightTime(),filter:filter};},getBlocks:function(){return blocks.map(cloneBlock);},getCheckpoints:function(){return cps.map(function(c){return Object.assign({},c);});},
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.3'};
})(window);

72
scene/proof-core.js Normal file
View file

@ -0,0 +1,72 @@
/* Igneum proof detail. A data-driven companion, not a mining-progress estimate.
* One orbital tile per displayed shard (up to 12). No time-based status promotion.
* IgneumProof.mount(canvas).setBlock(observerBlock); destroy() on unmount.
*/
(function(root){
'use strict';
var mounted=new WeakMap(),TAU=Math.PI*2;
function mount(canvas,opts){
opts=opts||{};if(mounted.has(canvas))mounted.get(canvas).destroy();
var ctx=canvas.getContext('2d');if(!ctx)throw new Error('A 2D canvas context is required.');
var W=0,H=0,dpr=1,block=null,raf=0,disposed=false,paused=false,inView=true,lastPaint=0,frames=0,force=true;
var mq=matchMedia('(prefers-reduced-motion: reduce)'),reduce=mq.matches,motion=true,col={},ro,io,mo;
var phase=0,lastT=0;
function alpha(c,a){if(/^#[a-f\d]{6}$/i.test(c))return 'rgba('+parseInt(c.slice(1,3),16)+','+parseInt(c.slice(3,5),16)+','+parseInt(c.slice(5,7),16)+','+a+')';return c;}
function theme(){var s=getComputedStyle(document.documentElement);function c(k,f){return s.getPropertyValue(k).trim()||f;}col={ember:c('--ember','#F2541B'),gold:c('--molten','#FFB35C'),bone:c('--bone','#F4F1EC'),ash:c('--ash','#9A9A9E'),line:c('--line','#2A2A30'),surface:c('--graphite','#16161A'),bg:c('--row','#111114')};kick();}
function size(){var nw=canvas.clientWidth,nh=canvas.clientHeight,nd=Math.min(devicePixelRatio||1,2);if(W===nw&&H===nh&&dpr===nd&&canvas.width===Math.round(nw*nd)){kick();return;}W=nw;H=nh;dpr=nd;canvas.width=W*dpr;canvas.height=H*dpr;ctx.setTransform(dpr,0,0,dpr,0,0);kick();}
function poly(points,fill,stroke){ctx.beginPath();points.forEach(function(p,i){if(i===0)ctx.moveTo(p[0],p[1]);else ctx.lineTo(p[0],p[1]);});ctx.closePath();if(fill){ctx.fillStyle=fill;ctx.fill();}if(stroke){ctx.strokeStyle=stroke;ctx.stroke();}}
function iso(x,y,w,h,depth,fill,stroke){
poly([[x-w,y],[x,y+h],[x,y+h+depth],[x-w,y+depth]],alpha(fill,.17),alpha(stroke,.4));
poly([[x,y+h],[x+w,y],[x+w,y+depth],[x,y+h+depth]],alpha(fill,.07),alpha(stroke,.32));
poly([[x,y-h],[x+w,y],[x,y+h],[x-w,y]],col.bg,stroke);
poly([[x,y-h],[x+w,y],[x,y+h],[x-w,y]],alpha(fill,.15),null);
}
function line(a,b,c,w){ctx.strokeStyle=c;ctx.lineWidth=w||1;ctx.beginPath();ctx.moveTo(a[0],a[1]);ctx.lineTo(b[0],b[1]);ctx.stroke();}
function draw(t){
if(!W||!H||disposed)return;frames++;
if(lastT&&!reduce&&motion&&!paused)phase+=Math.min(t-lastT,70)/1000;lastT=t;
ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,W,H);
var x=W*.5,y=H*.49,scale=Math.min(W/320,H/240),cw=43*scale,ch=23*scale;
var shards=block?block.shards||[]:[],active=shards.some(function(s){return s.state==='proving';});
var glow=ctx.createRadialGradient(x,y,0,x,y,W*.43);glow.addColorStop(0,alpha(col.ember,.10));glow.addColorStop(1,alpha(col.ember,0));ctx.fillStyle=glow;ctx.fillRect(0,0,W,H);
// A stationary coordinate grid, not fabricated hashrate or work counters.
for(var k=-3;k<=3;k++){line([x-130*scale,y+k*18*scale-46*scale],[x+130*scale,y+k*18*scale+46*scale],alpha(col.line,.45));line([x-130*scale,y+k*18*scale+46*scale],[x+130*scale,y+k*18*scale-46*scale],alpha(col.line,.45));}
ctx.strokeStyle=alpha(col.line,.7);ctx.lineWidth=1;ctx.setLineDash([2,6]);ctx.beginPath();ctx.ellipse(x,y+10*scale,106*scale,59*scale,0,0,TAU);ctx.stroke();ctx.setLineDash([]);
var count=Math.min(12,shards.length);
for(var i=0;i<count;i++){
var angle=-Math.PI/2+i/count*TAU,px=x+Math.cos(angle)*108*scale,py=y+Math.sin(angle)*57*scale;
var state=shards[i].state,ink=state==='proving'?col.ember:state==='paid'?col.gold:state==='verified'?col.bone:col.ash;
var midx=(px+x)/2,midy=(py+y)/2;
ctx.strokeStyle=alpha(ink,state==='planned'?.14:.38);ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(px,py);ctx.quadraticCurveTo(midx,midy-12*scale,x,y);ctx.stroke();
if(state==='proving'&&!reduce&&motion&&!paused){var p=(phase*.48+i*.33)%1,xx=(1-p)*(1-p)*px+2*(1-p)*p*midx+p*p*x,yy=(1-p)*(1-p)*py+2*(1-p)*p*(midy-12*scale)+p*p*y;ctx.fillStyle=col.gold;ctx.shadowBlur=8;ctx.shadowColor=col.ember;ctx.beginPath();ctx.arc(xx,yy,1.8*scale,0,TAU);ctx.fill();ctx.shadowBlur=0;}
iso(px,py,13*scale,7*scale,5*scale,ink,alpha(ink,.72));
if(state==='verified'||state==='paid'){ctx.strokeStyle=ink;ctx.lineWidth=1.2;ctx.beginPath();ctx.moveTo(px-4*scale,py);ctx.lineTo(px-1*scale,py+2*scale);ctx.lineTo(px+4*scale,py-2*scale);ctx.stroke();}
else{ctx.fillStyle=alpha(ink,state==='proving'?.9:.4);ctx.fillRect(px-1.5*scale,py-1.5*scale,3*scale,3*scale);}
ctx.font='8px ui-monospace, monospace';ctx.textAlign='center';ctx.fillStyle=col.ash;ctx.fillText('S'+String(i+1).padStart(2,'0'),px,py+22*scale);
}
// Layered block core. Geometry does not imply three GPUs or three proofs.
var lift=active&&!reduce&&motion&&!paused?Math.sin(phase*1.7)*1.8*scale:0;
ctx.lineWidth=1;
iso(x,y+21*scale,cw,ch,10*scale,col.ember,alpha(col.ember,.34));
iso(x,y+6*scale,cw,ch,8*scale,col.ember,alpha(col.ember,.55));
iso(x,y-11*scale-lift,cw,ch,7*scale,col.ember,alpha(col.ember,.85));
var topY=y-11*scale-lift;
poly([[x,topY-13*scale],[x+25*scale,topY],[x,topY+13*scale],[x-25*scale,topY]],alpha(col.ember,block&&block.proven?.28:.06),alpha(col.gold,.46));
if(block&&block.proven){ctx.strokeStyle=col.gold;ctx.lineWidth=1.8;ctx.beginPath();ctx.moveTo(x-7*scale,topY);ctx.lineTo(x-2*scale,topY+4*scale);ctx.lineTo(x+8*scale,topY-4*scale);ctx.stroke();}
else{ctx.fillStyle=col.ember;ctx.fillRect(x-2*scale,topY-2*scale,4*scale,4*scale);}
if(block&&block.locked){ctx.strokeStyle=col.gold;ctx.lineWidth=1.2;ctx.beginPath();ctx.ellipse(x,y+12*scale,67*scale,38*scale,0,0,TAU);ctx.stroke();ctx.font='8px ui-monospace, monospace';ctx.fillStyle=col.gold;ctx.textAlign='center';ctx.fillText('LOCKED CHECKPOINT',x,H-12);}
else if(!block){ctx.font='10px ui-monospace, monospace';ctx.textAlign='center';ctx.fillStyle=col.ash;ctx.fillText('SELECT A BLOCK',x,H-12);}
else if(shards.length>12){ctx.font='9px ui-monospace, monospace';ctx.textAlign='center';ctx.fillStyle=col.ash;ctx.fillText('+ '+(shards.length-12)+' more shards in the list',x,H-12);}
}
function continuous(){return !disposed&&!document.hidden&&inView&&!reduce&&motion&&!paused&&block&&(block.shards||[]).some(function(s){return s.state==='proving';});}
function frame(t){raf=0;if(disposed||document.hidden||!inView)return;if(force||t-lastPaint>=1000/30){draw(t);lastPaint=t;force=false;}if(continuous())raf=requestAnimationFrame(frame);}
function kick(){force=true;if(!disposed&&!document.hidden&&inView&&!raf)raf=requestAnimationFrame(frame);}
function stop(){if(raf)cancelAnimationFrame(raf);raf=0;lastT=0;}
function visibility(){if(document.hidden)stop();else kick();}
function reduction(e){reduce=e.matches;stop();kick();}
var api={setBlock:function(b){block=b?{hash:b.hash,proven:b.proven===true,locked:b.locked===true,shards:(b.shards||[]).map(function(s){return {state:s.state};})}:null;canvas.setAttribute('aria-label',block?'Block proof detail: '+block.shards.map(function(s,i){return 'shard '+(i+1)+' '+s.state;}).join(', '):'Select a block to inspect its proof shards');kick();},setPaused:function(value){paused=!!value;stop();kick();},setMotion:function(value){motion=!!value;stop();kick();},getStats:function(){return {frames:frames,reducedMotion:reduce||!motion};},destroy:function(){if(disposed)return;disposed=true;stop();ro.disconnect();if(io)io.disconnect();mo.disconnect();root.removeEventListener('resize',size);document.removeEventListener('visibilitychange',visibility);mq.removeEventListener('change',reduction);mounted.delete(canvas);}};
ro=new ResizeObserver(size);ro.observe(canvas);if('IntersectionObserver'in root){io=new IntersectionObserver(function(es){inView=es[0].isIntersecting;if(inView)kick();else stop();});io.observe(canvas);}mo=new MutationObserver(theme);mo.observe(document.documentElement,{attributes:true,attributeFilter:['data-theme','style','class']});
root.addEventListener('resize',size);document.addEventListener('visibilitychange',visibility);mq.addEventListener('change',reduction);mounted.set(canvas,api);theme();size();return api;
}
root.IgneumProof={mount:mount,version:'2.0.0'};
})(window);

6
scene/tokens.css Normal file
View file

@ -0,0 +1,6 @@
/* The chain scene's palette: the full-site redesign package's design system (7 Oct 2026), the brand source of truth for colour.
THE ONE PLACE these fourteen tokens are defined. tools/scene/sync.mjs writes them into site/site.css and app/igneum-app/ui/app.css
between the "scene-tokens" markers (dark; light under [data-theme="light"]; and, for a page without a theme attribute, light under
prefers-color-scheme: light); the gate refuses a copy whose values differ. live-dag.js and proof-core.js read them from :root. */
:root{--obsidian:#0C0C0E;--graphite:#16161A;--row:#111114;--line:#2A2A30;--line-2:#3A3A42;--ember:#F2541B;--ember-hi:#FF6A2B;--molten:#FFB35C;--bone:#F4F1EC;--ink-2:#C9C7C2;--ash:#9A9A9E;--included:#3B7DD8;--excluded:#B0362B;--ember-ink:#0C0C0E}
:root[data-theme="light"]{--obsidian:#F4F1EC;--graphite:#FFFFFF;--row:#FAF8F5;--line:#E2DED8;--line-2:#CFCAC2;--ember:#D0420D;--ember-hi:#E04A14;--molten:#B8731F;--bone:#16161A;--ink-2:#3C3C42;--ash:#6B6B70;--included:#2A62B8;--excluded:#B0362B;--ember-ink:#FFFFFF}

View file

@ -1,4 +1,9 @@
/* Igneum DAG / Ember 2.0 (the EMBER 02 renderer pack, 7 Oct 2026) with the narrow options of 2.0.2 (narrow, narrowBreak 720,
/* 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.3 (scene parity, 7 Oct 2026): (1) every push, size and theme change paints the current picture at once; only the motion
* loop waits for a visible document and an intersecting canvas (a page that loads with document.hidden true, or whose embedder
* never fires visibilitychange, showed a blank canvas while reporting live). (2) The narrow (phone) rule keys on the viewport
* width, not the canvas width: a 640 px hero on a laptop is not a phone. The narrow options of 2.0.2 (narrow, narrowBreak 720,
* narrowWindow 30, narrowLanes 4, narrowMinNode 11; on a phone the window shortens until the viewer sets one, four lanes, bigger
* nodes, no hairlines but the selected chain, checkpoint labels as the percent) and the real-data presentation options of 2.0.1:
* maxLanes (own key + top keys + others, default 7), minNode (node half-size floor, default 9.5), hairlineAlpha (non-chain
@ -82,7 +87,7 @@
bone:c('--bone','#F4F1EC'),ash:c('--ash','#9A9A9E'),line:c('--line','#2A2A30'),line2:c('--line-2','#3A3A42'),
bg:c('--row',c('--obsidian','#111114')),blue:c('--included','#3B7DD8'),red:c('--excluded','#B0362B'),
surface:c('--graphite','#16161A')};
redraw();
paint();redraw();
}
function rightTime(){
if(paused)return pausedRight;
@ -90,6 +95,7 @@
if(state!=='live' && staleRight!==null)return staleRight;
return reduced||!manualMotion?reducedRight:Date.now()-lag;
}
function viewportW(){var w=root.innerWidth||(doc.documentElement?doc.documentElement.clientWidth:0);return w>0?w:W;}
function xOf(ts,right){return padL+(W-padL-padR)*(1-(right-ts)/(windowS*1000));}
function eventXY(ev){var r=canvas.getBoundingClientRect();return {x:(ev.clientX-r.left)*W/(r.width||1),y:(ev.clientY-r.top)*H/(r.height||1)};}
function setState(s,r){
@ -101,7 +107,7 @@
}
function layout(){
if(!baseWindow)baseWindow=windowS;
var wasNarrow=narrow;narrow=opts.narrow===true||(opts.narrow!==false&&!compact&&W>0&&W<narrowBreak);
var wasNarrow=narrow;narrow=opts.narrow===true||(opts.narrow!==false&&!compact&&W>0&&viewportW()<narrowBreak);
if(narrow!==wasNarrow&&!windowTouched){var nw=narrow?narrowWindow:baseWindow;if(nw!==windowS){windowS=nw;emit('onWindow',windowS);}}
padL=compact||W<510?14:W<750?84:106;padR=compact?12:24;padT=compact?29:58;padB=compact?22:38;
var start=rightTime()-windowS*1000, end=rightTime()+lag+1000,counts=new Map(),mineKeys=new Set();
@ -127,7 +133,7 @@
var nextW=canvas.clientWidth,nextH=canvas.clientHeight,nextDpr=Math.min(root.devicePixelRatio||1,2);
if(W===nextW&&H===nextH&&dpr===nextDpr&&canvas.width===Math.round(nextW*nextDpr)){redraw();return;}
W=nextW;H=nextH;dpr=nextDpr;
canvas.width=Math.round(W*dpr);canvas.height=Math.round(H*dpr);ctx.setTransform(dpr,0,0,dpr,0,0);layout();redraw();
canvas.width=Math.round(W*dpr);canvas.height=Math.round(H*dpr);ctx.setTransform(dpr,0,0,dpr,0,0);layout();paint();redraw();
}
function syncView(){
var now=performance.now(),first=view.size===0, next=new Map();
@ -165,7 +171,7 @@
if(d.state.stale){if(staleRight===null)staleRight=Math.min(now-lag,newest?newest+lag:now-lag);setState('stale','Observer stale'+(finite(d.state.age_s)?'; last update '+Math.round(d.state.age_s)+' s ago':''));}
else setState('live',null);
if(!paused)syncView();
emit('onData',d);emit('onStats',stats());if(!paused)redraw();return true;
emit('onData',d);emit('onStats',stats());if(!paused){/*paint-on-push*/paint();redraw();}return true;
}
function stats(){
var r=rightTime(),v=blocks.filter(function(b){return b.ts>=r-windowS*1000&&b.ts<=r;});
@ -276,6 +282,8 @@
}
function continuous(){return !destroyed&&!doc.hidden&&visible&&!paused&&!reduced&&manualMotion&&state==='live'&&blocks.length>0;}
function frame(t){raf=0;if(destroyed||doc.hidden||!visible)return;if(t-lastPaint>=minFrame-.5){draw(t);lastPaint=t;}if(continuous())raf=requestAnimationFrame(frame);}
// one synchronous frame of the current picture, whatever the document's visibility says (2.0.3)
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){
@ -354,5 +362,5 @@
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.2'};
root.IgneumDag={mount:mount,version:'2.0.3'};
})(window);

View file

@ -3,7 +3,12 @@
the text tokens --ember-text and --molten-text exist for contrast on light surfaces, and a compatibility layer at the end
restates the vocabulary our data pages still use (.wrap, .sec, .card, .tbl, .tile, .frame, .lines, .band, states, scenes). */
/* ---------- 1. tokens (the package's) ---------- */
:root{color-scheme:dark;--obsidian:#0C0C0E;--graphite:#16161A;--row:#111114;--line:#2A2A30;--line-2:#3A3A42;--ember:#F2541B;--ember-hi:#FF6A2B;--molten:#FFB35C;--bone:#F4F1EC;--ink-2:#C9C7C2;--ash:#9A9A9E;--included:#3B7DD8;--excluded:#B0362B;--ember-ink:#0C0C0E;--glass:rgba(12,12,14,.93);--ember-tint:rgba(242,84,27,.09);--shadow:0 24px 80px #0005;--sans:'IBM Plex Sans','Plex Sans Fallback',Arial,sans-serif;--display:'Unbounded','Unbounded Fallback','Arial Black',Arial,sans-serif;--mono:'IBM Plex Mono','Plex Mono Fallback',ui-monospace,monospace;--max:1280px;
/* scene-tokens:start (scene/tokens.css, written by tools/scene/sync.mjs; edit the source, never this block) */
:root{--obsidian:#0C0C0E;--graphite:#16161A;--row:#111114;--line:#2A2A30;--line-2:#3A3A42;--ember:#F2541B;--ember-hi:#FF6A2B;--molten:#FFB35C;--bone:#F4F1EC;--ink-2:#C9C7C2;--ash:#9A9A9E;--included:#3B7DD8;--excluded:#B0362B;--ember-ink:#0C0C0E}
:root[data-theme="light"]{--obsidian:#F4F1EC;--graphite:#FFFFFF;--row:#FAF8F5;--line:#E2DED8;--line-2:#CFCAC2;--ember:#D0420D;--ember-hi:#E04A14;--molten:#B8731F;--bone:#16161A;--ink-2:#3C3C42;--ash:#6B6B70;--included:#2A62B8;--excluded:#B0362B;--ember-ink:#FFFFFF}
@media (prefers-color-scheme:light){:root:not([data-theme="dark"]){--obsidian:#F4F1EC;--graphite:#FFFFFF;--row:#FAF8F5;--line:#E2DED8;--line-2:#CFCAC2;--ember:#D0420D;--ember-hi:#E04A14;--molten:#B8731F;--bone:#16161A;--ink-2:#3C3C42;--ash:#6B6B70;--included:#2A62B8;--excluded:#B0362B;--ember-ink:#FFFFFF}}
/* scene-tokens:end */
:root{color-scheme:dark;--glass:rgba(12,12,14,.93);--ember-tint:rgba(242,84,27,.09);--shadow:0 24px 80px #0005;--sans:'IBM Plex Sans','Plex Sans Fallback',Arial,sans-serif;--display:'Unbounded','Unbounded Fallback','Arial Black',Arial,sans-serif;--mono:'IBM Plex Mono','Plex Mono Fallback',ui-monospace,monospace;--max:1280px;
--ember-text:#F2541B;--molten-text:#FFB35C;
/* aliases for the page blocks written before the redesign (7 Oct 2026): fonts, type steps, space, radii, chrome */
--f-sans:var(--sans);--f-display:var(--display);--f-mono:var(--mono);
@ -19,7 +24,7 @@
--ui-accent:var(--ember);--ui-accent-ink:var(--ember-ink);--ui-hot:var(--molten);--ui-hover:var(--ember-hi);--ui-tint:var(--ember-tint);
--code:var(--row);--code-ink:var(--ink-2);
}
:root[data-theme="light"]{color-scheme:light;--obsidian:#F4F1EC;--graphite:#FFFFFF;--row:#FAF8F5;--line:#E2DED8;--line-2:#CFCAC2;--ember:#D0420D;--ember-hi:#E04A14;--molten:#B8731F;--bone:#16161A;--ink-2:#3C3C42;--ash:#6B6B70;--included:#2A62B8;--ember-ink:#FFFFFF;--glass:rgba(244,241,236,.95);--shadow:0 24px 70px #30201810;
:root[data-theme="light"]{color-scheme:light;--glass:rgba(244,241,236,.95);--shadow:0 24px 70px #30201810;
--ember-text:#B8390C;--molten-text:#8F5810;
--scrim:rgba(244,241,236,.72);--hover:rgba(0,0,0,.04);--ok:var(--molten);
}

View file

@ -97,6 +97,9 @@ tree_checks() {
run "faucet unit tests" node --test site/api/faucet.test.mjs
run "redesign package data tests (the /api/live contract the pages read)" node tools/site-redesign/tests/data-tests.cjs
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 "explorer, emission and public stats unit tests" node --test site/lib/explorer.test.mjs site/lib/emission.test.mjs site/api/public-stats.test.mjs
run "ship tool self-test" node tools/ship-app.mjs --self-test
run "interface bundle publisher self-test (pack, and sign when the signer is built)" node tools/ui-ota/publish.mjs --self-test

View file

@ -0,0 +1,56 @@
// The live feed contract, as code: validate(reply) -> [] or a list of one-line problems. The key lists come from
// scene/feed-contract.json (the app's Rust test reads the same file). Extra keys are refused outside the listed extensions, so
// "the same JSON shape" is literal: a producer that adds a field changes the contract file first.
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
export const CONTRACT = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'scene', 'feed-contract.json'), 'utf8'));
const isObj = v => v !== null && typeof v === 'object' && !Array.isArray(v);
const minerId = new RegExp(CONTRACT.miner_id);
function keys(problems, where, obj, want, extensions = []) {
if (!isObj(obj)) { problems.push(where + ' is not an object'); return false; }
for (const k of want) if (!(k in obj)) problems.push(where + ' lacks ' + k);
for (const k of Object.keys(obj)) if (!want.includes(k) && !extensions.includes(k)) problems.push(where + ' carries an unknown key ' + k);
return true;
}
const numOrNull = (problems, where, v) => { if (v !== null && typeof v !== 'number') problems.push(where + ' is neither a number nor null'); };
const bool = (problems, where, v) => { if (typeof v !== 'boolean') problems.push(where + ' is not a boolean'); };
export function validate(reply) {
const p = [];
if (!isObj(reply)) return ['reply is not an object'];
if (reply.ok !== true) return ['reply.ok is not true' + (reply.error ? ' (' + reply.error + ')' : '')];
keys(p, 'reply', reply, CONTRACT.top, CONTRACT.top_extensions);
if (typeof reply.now !== 'string' || Number.isNaN(Date.parse(reply.now))) p.push('now is not an ISO 8601 string');
if (keys(p, 'state', reply.state, CONTRACT.state, CONTRACT.state_extensions)) {
bool(p, 'state.stale', reply.state.stale); numOrNull(p, 'state.age_s', reply.state.age_s); numOrNull(p, 'state.height', reply.state.height);
if (!Array.isArray(reply.state.blocks_per_minute)) p.push('state.blocks_per_minute is not an array');
}
if (!Array.isArray(reply.blocks)) p.push('blocks is not an array');
else reply.blocks.forEach((b, i) => {
const w = 'blocks[' + i + ']';
if (!keys(p, w, b, CONTRACT.block)) return;
if (typeof b.hash !== 'string' || !/^[0-9a-f]{1,64}$/.test(b.hash)) p.push(w + '.hash is not a hex string');
if (typeof b.ts !== 'number' || b.ts < 1e12) p.push(w + '.ts is not milliseconds');
numOrNull(p, w + '.number', b.number); numOrNull(p, w + '.blue_score', b.blue_score); numOrNull(p, w + '.daa', b.daa); numOrNull(p, w + '.rx', b.rx);
if (!Array.isArray(b.parents) || b.parents.some(x => typeof x !== 'string')) p.push(w + '.parents is not an array of strings');
bool(p, w + '.chain', b.chain); bool(p, w + '.locked', b.locked); bool(p, w + '.final', b.final); bool(p, w + '.proven', b.proven);
if (b.miner !== null && !(typeof b.miner === 'string' && minerId.test(b.miner))) p.push(w + '.miner is not an 8-hex vote key id (' + JSON.stringify(b.miner) + ')');
if (!CONTRACT.color.includes(b.color)) p.push(w + '.color is not one of ' + CONTRACT.color.join(', '));
if (!Array.isArray(b.shards)) p.push(w + '.shards is not an array');
else b.shards.forEach((s, j) => { if (keys(p, w + '.shards[' + j + ']', s, CONTRACT.shard) && !CONTRACT.shard_state.includes(s.state)) p.push(w + '.shards[' + j + '].state is not one of ' + CONTRACT.shard_state.join(', ')); });
});
if (!Array.isArray(reply.miners)) p.push('miners is not an array');
else reply.miners.forEach((m, i) => { if (keys(p, 'miners[' + i + ']', m, CONTRACT.miner) && !(typeof m.id === 'string' && minerId.test(m.id))) p.push('miners[' + i + '].id is not an 8-hex id'); });
if (!Array.isArray(reply.events)) p.push('events is not an array');
else reply.events.forEach((e, i) => keys(p, 'events[' + i + ']', e, CONTRACT.event));
if (keys(p, 'finality', reply.finality, CONTRACT.finality)) {
if (!Array.isArray(reply.finality.checkpoints)) p.push('finality.checkpoints is not an array');
else reply.finality.checkpoints.forEach((c, i) => { if (keys(p, 'finality.checkpoints[' + i + ']', c, CONTRACT.checkpoint) && !CONTRACT.checkpoint_state.includes(c.state)) p.push('finality.checkpoints[' + i + '].state is not pending or locked'); });
}
if (isObj(reply.proving)) keys(p, 'proving', reply.proving, reply.proving.supported ? CONTRACT.proving_supported : CONTRACT.proving_unsupported);
else p.push('proving is not an object');
return p;
}

View file

@ -0,0 +1,40 @@
// The live feed contract: the recorded observer reply validates; known-bad shapes are refused, each for its own reason.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { validate, CONTRACT } from './feed-contract.mjs';
const fixture = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'scene', 'fixtures', 'live-2026-10-07.json'), 'utf8'));
const edit = fn => { const r = JSON.parse(JSON.stringify(fixture)); fn(r); return r; };
test('the recorded /api/live reply validates (905 blocks, 15 miners, 40 checkpoints)', () => {
assert.deepEqual(validate(fixture), []);
assert.equal(fixture.blocks.length, 905); assert.equal(fixture.miners.length, 15); assert.equal(fixture.finality.checkpoints.length, 40);
});
test('the app\'s documented extensions are accepted', () => {
const r = edit(r => { r.partial = true; r.state.source = 'observer'; r.state.you_blocks = 2; r.state.node = { peers: 3 }; });
assert.deepEqual(validate(r), []);
});
test('known-bad: a block whose miner is the word "you" (the 0.3.19 app rewrote the field) is refused', () => {
const p = validate(edit(r => { r.blocks[0].miner = 'you'; }));
assert.match(p.join('\n'), /blocks\[0\]\.miner is not an 8-hex vote key id \("you"\)/);
});
test('known-bad: now as a float of seconds (the 0.3.19 node fallback) is refused', () => {
assert.match(validate(edit(r => { r.now = 1791301670.5; })).join('\n'), /now is not an ISO 8601 string/);
});
test('known-bad: a missing proving object, a missing block key, an unknown key', () => {
assert.match(validate(edit(r => { delete r.proving; })).join('\n'), /reply lacks proving/);
assert.match(validate(edit(r => { delete r.blocks[3].rx; })).join('\n'), /blocks\[3\] lacks rx/);
assert.match(validate(edit(r => { r.blocks[3].vote_key_hash = 'abc'; })).join('\n'), /blocks\[3\] carries an unknown key vote_key_hash/);
assert.match(validate(edit(r => { r.miners[0] = { id: r.miners[0].id, vote_key_hash: 'x', blocks_10m: 1 }; })).join('\n'), /miners\[0\] lacks blocks/);
});
test('known-bad: a colour, a shard state and a checkpoint state outside the enums', () => {
assert.match(validate(edit(r => { r.blocks[0].color = 'winning'; })).join('\n'), /color is not one of blue, red, pending/);
assert.match(validate(edit(r => { r.blocks[0].shards = [{ i: 0, n: 1, state: 'jackpot', prover: null, lag: null, payout: null, pgas: null }]; })).join('\n'), /state is not one of planned, proving, verified, paid/);
assert.match(validate(edit(r => { r.finality.checkpoints[0].state = 'maybe'; })).join('\n'), /is not pending or locked/);
});
test('the contract file lists what the Rust test reads', () => {
for (const k of ['top', 'state', 'block', 'miner', 'finality', 'checkpoint', 'shard', 'proving_unsupported']) assert(Array.isArray(CONTRACT[k]) && CONTRACT[k].length > 0, k);
});

View file

@ -0,0 +1,95 @@
/* The chain scene paints on push whatever the document's visibility says (2.0.3, 7 October 2026).
* Found by the coordinator at 14:3x UK: /live mounted, push(d) true, state live, Pause reading Pause, and a canvas with no frame
* drawn after 3 s, because the only paint path was a requestAnimationFrame loop gated on document.hidden and the
* IntersectionObserver. A page that loads with document.hidden true (the desktop pane; a background tab) and whose embedder
* never fires visibilitychange stayed blank for good. Rule: a mounted scene with a live feed that has no block drawn after one
* push FAILS, with the document hidden, with the observer never intersecting, and with requestAnimationFrame never firing.
*
* Loads scene/live-dag.js in a stub window with a 2D context that counts its calls (a block is an arc or a rounded path that is
* filled), pushes the recorded observer fixture, and reads the count. Known-failed first: the same renderer with its
* paint-on-push line removed draws nothing (the class this test guards), and an empty feed draws no block. */
const fs = require('node:fs'), vm = require('node:vm'), assert = require('node:assert/strict'), path = require('node:path');
const root = path.join(__dirname, '..', '..');
const src = fs.readFileSync(path.join(root, 'scene', 'live-dag.js'), 'utf8');
const fixture = JSON.parse(fs.readFileSync(path.join(root, 'scene', 'fixtures', 'live-2026-10-07.json'), 'utf8'));
const T0 = new Date(fixture.now).getTime();
function world(opts) {
opts = opts || {};
const calls = { fill: 0, arc: 0, arcTo: 0, fillText: 0, raf: 0 };
const ctx = new Proxy({}, {
get: (t, k) => {
if (k === 'canvas') return canvas;
return (...a) => { if (k === 'fill') calls.fill++; if (k === 'arc') calls.arc++; if (k === 'arcTo') calls.arcTo++; if (k === 'fillText') calls.fillText++; if (k === 'createRadialGradient' || k === 'createLinearGradient') return { addColorStop() { } }; if (k === 'measureText') return { width: 10 }; if (k === 'getImageData') return { data: new Uint8ClampedArray(4) }; };
}, set: () => true,
});
const canvas = { clientWidth: 900, clientHeight: 420, width: 0, height: 0, style: {}, attrs: {}, getContext: () => ctx, getAttribute(n) { return this.attrs[n] === undefined ? null : this.attrs[n]; }, setAttribute(n, v) { this.attrs[n] = String(v); }, removeAttribute(n) { delete this.attrs[n]; }, addEventListener() { }, removeEventListener() { }, classList: { add() { }, remove() { }, toggle() { } }, getBoundingClientRect: () => ({ left: 0, top: 0, width: 900, height: 420 }), focus() { } };
const doc = { get hidden() { return !!opts.hidden; }, documentElement: { getAttribute: () => null, style: {} }, baseURI: 'https://igneum.network/live', addEventListener() { }, removeEventListener() { }, createElement: () => ({ style: {}, getContext: () => ctx }) };
canvas.ownerDocument = doc;
const window = {
document: doc, devicePixelRatio: 1, innerWidth: 1440,
requestAnimationFrame: () => { calls.raf++; return opts.rafFires ? 1 : ++calls.raf; }, cancelAnimationFrame() { },
performance: { now: () => 1000 }, Date,
matchMedia: q => ({ matches: false, addEventListener() { }, removeEventListener() { } }),
getComputedStyle: () => ({ getPropertyValue: () => '' }),
ResizeObserver: class { constructor(f) { this.f = f; } observe() { } disconnect() { } },
IntersectionObserver: class { constructor(f) { this.f = f; } observe(el) { if (opts.io === 'never') this.f([{ isIntersecting: false, target: el }]); } disconnect() { } },
MutationObserver: class { observe() { } disconnect() { } },
AbortController: class { abort() { } }, URL, setTimeout: () => 0, clearTimeout() { }, setInterval: () => 0, clearInterval() { },
addEventListener() { }, removeEventListener() { }, console,
};
window.window = window;
return { window, canvas, calls };
}
function run(source, opts, reply) {
const w = world(opts);
const origNow = Date.now; Date.now = () => T0 + 1500;
try {
vm.runInNewContext(source + '\n;window.IgneumDag', w.window, { filename: 'live-dag.js' });
const api = w.window.IgneumDag.mount(w.canvas, { poll: false, window: 60, fps: 60 });
const ok = api.push(reply);
return { ok, calls: w.calls, stats: api.getStats(), state: api.getState().state };
} finally { Date.now = origNow; }
}
// a block is two rounded tiles (the outline and the core), four arcTo calls each
const blocksDrawn = r => Math.floor(r.calls.arcTo / 8);
const results = [];
function check(name, fn) { try { fn(); results.push({ name, pass: true }); } catch (e) { results.push({ name, pass: false, error: e.message }); } }
// known-failed first: the renderer without its paint-on-push line (the 2.0.2 behaviour) draws nothing with the document hidden
check('known-failed: 2.0.2 behaviour (no paint on push) draws no block with the document hidden', () => {
assert(src.includes('/*paint-on-push*/paint();'), 'the paint-on-push marker is gone from scene/live-dag.js');
const old = src.replace('/*paint-on-push*/paint();', '');
const r = run(old, { hidden: true }, fixture);
assert.equal(r.ok, true); assert.equal(r.state, 'live'); assert(r.stats.blocks > 0, 'the fixture has blocks in the window');
assert.equal(blocksDrawn(r), 0, 'the old renderer must draw nothing here, or this test guards nothing');
});
check('known-failed: an empty feed draws no block', () => {
const r = run(src, { hidden: true }, { ok: true, state: { stale: false, age_s: 0 }, blocks: [] });
assert.equal(blocksDrawn(r), 0);
});
// the rule
check('document hidden, requestAnimationFrame never fires: blocks are drawn on the first push', () => {
const r = run(src, { hidden: true }, fixture);
assert.equal(r.ok, true); assert.equal(r.state, 'live'); assert(r.stats.blocks > 50, 'blocks in the 60 s window: ' + r.stats.blocks);
assert(blocksDrawn(r) >= r.stats.blocks, 'tiles drawn ' + blocksDrawn(r) + ' for ' + r.stats.blocks + ' blocks in the window');
assert(r.calls.fillText > 0, 'lane labels and the time axis are written');
});
check('the observer never reports the canvas intersecting: blocks are still drawn on push', () => {
const r = run(src, { hidden: false, io: 'never' }, fixture);
assert(blocksDrawn(r) >= r.stats.blocks);
});
check('a visible page still schedules the motion loop after the synchronous paint', () => {
const r = run(src, { hidden: false }, fixture);
assert(r.calls.raf > 0, 'requestAnimationFrame was asked for');
});
check('a hidden page does not spin the motion loop', () => {
const r = run(src, { hidden: true }, fixture);
assert.equal(r.calls.raf, 0);
});
for (const r of results) console.log((r.pass ? ' ok ' : ' FAIL ') + r.name + (r.error ? ': ' + r.error : ''));
const failed = results.filter(r => !r.pass).length;
console.log(failed ? 'paint-test: RED, ' + failed + ' failed' : 'paint-test: ok, ' + results.length + ' checks (two known-failed cases fail as they must)');
process.exit(failed ? 1 : 0);

186
tools/scene/sync.mjs Normal file
View file

@ -0,0 +1,186 @@
#!/usr/bin/env node
// The chain scene has ONE source: scene/ (live-dag.js, proof-core.js, tokens.css). The site and the miner app serve copies,
// because the site is a static folder Vercel builds from site/ and the app embeds ui/ with include_str!. This tool keeps the
// copies honest without symlinks (Windows checkouts, Vercel's root directory, cargo's include paths):
//
// node tools/scene/sync.mjs [--adopt] write the copies that exist (--adopt creates a missing script copy next to an existing
// target, the first time a tree takes the scene): the two scripts byte-for-byte, the palette block into each CSS file
// between the scene-tokens markers (and, on first adoption, strip the same token names
// from the file's other :root blocks, so the block is the only definition)
// node tools/scene/sync.mjs --check the gate: every copy present in this tree is byte-equal to its source, every CSS
// target's block equals the generated one and defines those names nowhere else;
// exit 1 with one line per difference ("which side": the source is always right)
// node tools/scene/sync.mjs --self-test known-failed first: a palette token changed on one side, one byte changed in a
// copy, a stray token definition; then the clean tree passes
//
// Scope: a side (site or app) takes part only once it has adopted the scene, which is when its live-dag.js copy exists
// (master carries no app/igneum-app/ui/live-dag.js; the app lives on the release branches, whose site tree is frozen). On a release-* branch (the branch name, or SCENE_SYNC_SCOPE=app) only the app's copies are checked:
// the release branches carry the site tree as it was when they were cut, and the site deploys from master alone.
import { readFileSync, writeFileSync, existsSync, mkdtempSync, cpSync, rmSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { execSync } from 'node:child_process';
const here = dirname(fileURLToPath(import.meta.url));
const repoDefault = join(here, '..', '..');
export const TOKEN_NAMES = ['--obsidian', '--graphite', '--row', '--line', '--line-2', '--ember', '--ember-hi', '--molten', '--bone', '--ink-2', '--ash', '--included', '--excluded', '--ember-ink'];
const START = '/* scene-tokens:start (scene/tokens.css, written by tools/scene/sync.mjs; edit the source, never this block) */';
const END = '/* scene-tokens:end */';
const TARGETS = [
{ from: 'scene/live-dag.js', to: 'site/live-dag.js', scope: 'site', kind: 'copy' },
{ from: 'scene/proof-core.js', to: 'site/proof-core.js', scope: 'site', kind: 'copy' },
{ from: 'scene/tokens.css', to: 'site/site.css', scope: 'site', kind: 'tokens' },
{ from: 'scene/live-dag.js', to: 'app/igneum-app/ui/live-dag.js', scope: 'app', kind: 'copy' },
{ from: 'scene/proof-core.js', to: 'app/igneum-app/ui/proof-core.js', scope: 'app', kind: 'copy' },
{ from: 'scene/tokens.css', to: 'app/igneum-app/ui/app.css', scope: 'app', kind: 'tokens' },
];
// tokens.css -> { dark: {name: value}, light: {name: value} }; every name must be in both, no extras
export function parseTokens(css) {
const block = sel => { const m = css.match(new RegExp(sel.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\{([^}]*)\\}')); if (!m) throw new Error('tokens.css: no ' + sel + ' block'); return m[1]; };
const map = body => Object.fromEntries(body.split(';').map(s => s.trim()).filter(Boolean).map(s => { const i = s.indexOf(':'); return [s.slice(0, i).trim(), s.slice(i + 1).trim()]; }));
const dark = map(block(':root')), light = map(block(':root[data-theme="light"]'));
for (const set of [dark, light]) {
const names = Object.keys(set).sort().join(','), want = TOKEN_NAMES.slice().sort().join(',');
if (names !== want) throw new Error('tokens.css defines ' + names + ', expected exactly ' + want);
}
return { dark, light };
}
const decl = m => TOKEN_NAMES.map(n => n + ':' + m[n]).join(';');
export function tokenBlock(tokens) {
const light = decl(tokens.light);
return [START,
':root{' + decl(tokens.dark) + '}',
':root[data-theme="light"]{' + light + '}',
'@media (prefers-color-scheme:light){:root:not([data-theme="dark"]){' + light + '}}',
END].join('\n');
}
const blockRe = new RegExp(START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '[\\s\\S]*?' + END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
// @media print blocks keep their own overrides (a printed page is white); everything else defines the names only in the block
export function withoutExempt(css) {
let out = css.replace(blockRe, ''), i;
while ((i = out.indexOf('@media print{')) >= 0) {
let depth = 0, j = i + '@media print'.length;
for (; j < out.length; j++) { if (out[j] === '{') depth++; else if (out[j] === '}' && --depth === 0) { j++; break; } }
out = out.slice(0, i) + out.slice(j);
}
return out;
}
// the token names defined outside the block (any "--name:" inside a declaration list; a var(--name) read is not a definition)
export function strayDefinitions(css) {
const outside = withoutExempt(css);
const found = [];
for (const n of TOKEN_NAMES) { const re = new RegExp('(^|[{;\\s])' + n.replace(/[-]/g, '\\-') + '\\s*:', 'g'); let m; while ((m = re.exec(outside))) found.push(n); }
return found;
}
function stripDefinitions(css) {
// remove "--name:value;" (or the last one before "}") outside the block; keep everything else byte for byte
// the print block and the scene-tokens block keep their text; every other declaration of a token name goes
const keep = [];
const m = css.match(blockRe); if (m) keep.push([css.indexOf(m[0]), css.indexOf(m[0]) + m[0].length]);
let i = -1; while ((i = css.indexOf('@media print{', i + 1)) >= 0) { let depth = 0, j = i + '@media print'.length; for (; j < css.length; j++) { if (css[j] === '{') depth++; else if (css[j] === '}' && --depth === 0) { j++; break; } } keep.push([i, j]); }
keep.sort((a, b) => a[0] - b[0]);
const strip = s => { for (const n of TOKEN_NAMES) s = s.replace(new RegExp('(^|(?<=[{;\\s]))' + n.replace(/[-]/g, '\\-') + '\\s*:[^;}]*;?', 'g'), ''); return s; };
let out = '', at = 0;
for (const [a, b] of keep) { out += strip(css.slice(at, a)) + css.slice(a, b); at = b; }
return out + strip(css.slice(at));
}
export function writeTokens(css, block) {
if (blockRe.test(css)) return css.replace(blockRe, block);
// first adoption: the block goes before the file's first :root rule (after the leading comment), and the names leave the rest
const i = css.search(/^:root\{/m);
if (i < 0) throw new Error('no :root rule to put the scene-tokens block before');
return stripDefinitions(css.slice(0, i) + block + '\n' + css.slice(i));
}
export function scopeFor(repo) {
if (process.env.SCENE_SYNC_SCOPE) return process.env.SCENE_SYNC_SCOPE;
let branch = '';
try { branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: repo, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); } catch { }
if (branch === 'HEAD' && process.env.GITHUB_REF_NAME) branch = process.env.GITHUB_REF_NAME;
return /^release-/.test(branch) ? 'app' : 'all';
}
const adopted = (repo, side) => existsSync(join(repo, side === 'site' ? 'site/live-dag.js' : 'app/igneum-app/ui/live-dag.js'));
export function check(repo, scope = scopeFor(repo)) {
const problems = [], seen = [];
const tokens = parseTokens(readFileSync(join(repo, 'scene/tokens.css'), 'utf8'));
const block = tokenBlock(tokens);
for (const t of TARGETS) {
if (scope !== 'all' && t.scope !== scope) continue;
if (!adopted(repo, t.scope)) continue;
const dst = join(repo, t.to);
if (!existsSync(dst)) { problems.push(t.to + ' is missing while ' + t.scope + ' has adopted the scene'); continue; }
seen.push(t.to);
if (t.kind === 'copy') {
const a = readFileSync(join(repo, t.from)), b = readFileSync(dst);
if (!a.equals(b)) {
let i = 0; while (i < a.length && i < b.length && a[i] === b[i]) i++;
problems.push(t.to + ' differs from ' + t.from + ' at byte ' + i + ' (source ' + a.length + ' bytes, copy ' + b.length + '): the source is right, run tools/scene/sync.mjs');
}
} else {
const css = readFileSync(dst, 'utf8');
const m = css.match(blockRe);
if (!m) { problems.push(t.to + ': no scene-tokens block (run tools/scene/sync.mjs to adopt it)'); continue; }
if (m[0] !== block) {
const theirs = m[0].split('\n'), ours = block.split('\n');
const line = ours.findIndex((l, k) => theirs[k] !== l);
problems.push(t.to + ': scene-tokens block differs from scene/tokens.css at block line ' + (line + 1) + ' (the source is right, run tools/scene/sync.mjs)');
}
const stray = strayDefinitions(css);
if (stray.length) problems.push(t.to + ' defines ' + [...new Set(stray)].join(', ') + ' outside the scene-tokens block: the block is the only definition');
}
}
return { problems, seen, scope };
}
export function sync(repo, scope = 'all', adopt = false) {
const tokens = parseTokens(readFileSync(join(repo, 'scene/tokens.css'), 'utf8'));
const block = tokenBlock(tokens), written = [];
for (const t of TARGETS) {
if (scope !== 'all' && t.scope !== scope) continue;
const dst = join(repo, t.to);
if (!adopt && !adopted(repo, t.scope)) continue;
if (!existsSync(dst) && !(adopt && t.kind === 'copy' && existsSync(dirname(dst)))) continue;
if (t.kind === 'copy') { const src = readFileSync(join(repo, t.from)); if (!existsSync(dst) || !src.equals(readFileSync(dst))) { writeFileSync(dst, src); written.push(t.to); } }
else { const css = readFileSync(dst, 'utf8'), next = writeTokens(css, block); if (next !== css) { writeFileSync(dst, next); written.push(t.to); } }
}
return written;
}
function selfTest(repo) {
const tmp = mkdtempSync(join(tmpdir(), 'scene-sync-'));
const fails = [];
const fixture = () => { rmSync(tmp, { recursive: true, force: true }); for (const d of ['scene', 'site', 'app/igneum-app/ui']) if (existsSync(join(repo, d))) cpSync(join(repo, d), join(tmp, d), { recursive: true, filter: p => !/\/(fonts|img|verify|scenes|api|lib|partials)(\/|$)/.test(p) && !/\.(png|woff2|ico)$/.test(p) }); sync(tmp, 'all'); };
const expectFail = (name, mutate) => { fixture(); mutate(); const r = check(tmp, 'all'); if (!r.problems.length) fails.push('known-failed case passed: ' + name); };
expectFail('a palette token changed on the site side', () => { const p = join(tmp, 'site/site.css'); writeFileSync(p, readFileSync(p, 'utf8').replace('--included:#3B7DD8', '--included:#3B7DD9')); });
expectFail('a palette token changed in the source but not the copies', () => { const p = join(tmp, 'scene/tokens.css'); writeFileSync(p, readFileSync(p, 'utf8').replace('--ember:#F2541B', '--ember:#F2541C')); });
expectFail('one byte changed in the site copy of live-dag.js', () => { const p = join(tmp, 'site/live-dag.js'); const s = readFileSync(p, 'utf8'); writeFileSync(p, s.replace("version:'2.0", "version:'9.0")); });
if (existsSync(join(tmp, 'app/igneum-app/ui/live-dag.js'))) expectFail('one byte changed in the app copy of live-dag.js', () => { const p = join(tmp, 'app/igneum-app/ui/live-dag.js'); writeFileSync(p, readFileSync(p, 'utf8') + '\n'); });
expectFail('a stray --ember definition outside the block', () => { const p = join(tmp, 'site/site.css'); writeFileSync(p, readFileSync(p, 'utf8') + '\n:root{--ember:#000}\n'); });
fixture(); { const p = join(tmp, 'site/site.css'); writeFileSync(p, readFileSync(p, 'utf8') + '\n@media print{:root{--ember:#000}.x{color:var(--ember)}}\n'); const r = check(tmp, 'all'); if (r.problems.length) fails.push('a print-block override was flagged as a stray definition: ' + r.problems.join('; ')); }
fixture(); const clean = check(tmp, 'all'); if (clean.problems.length) fails.push('clean fixture failed: ' + clean.problems.join('; '));
rmSync(tmp, { recursive: true, force: true });
return fails;
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
const repo = process.env.SCENE_SYNC_REPO || repoDefault;
const mode = process.argv[2] || 'write';
if (mode === '--check') {
const r = check(repo);
for (const p of r.problems) console.error('scene-sync: ' + p);
console.log('scene-sync: ' + (r.problems.length ? 'RED, ' + r.problems.length + ' difference(s)' : 'ok') + ' (scope ' + r.scope + ': ' + r.seen.join(', ') + ')');
process.exit(r.problems.length ? 1 : 0);
} else if (mode === '--self-test') {
const fails = selfTest(repo);
for (const f of fails) console.error('scene-sync self-test: ' + f);
console.log('scene-sync self-test: ' + (fails.length ? 'RED' : 'ok, five known-failed cases fail, a print override passes, the clean tree passes'));
process.exit(fails.length ? 1 : 0);
} else if (mode === 'write') {
const w = sync(repo, process.argv.includes('--scope') ? process.argv[process.argv.indexOf('--scope') + 1] : 'all', process.argv.includes('--adopt'));
console.log('scene-sync: wrote ' + (w.length ? w.join(', ') : 'nothing (every copy was current)') + (process.argv.includes('--adopt') ? '' : ' (sides that have not adopted the scene are left alone; --adopt takes one on)'));
} else { console.error('usage: sync.mjs [--check | --self-test]'); process.exit(2); }
}