diff --git a/scene/README.md b/scene/README.md index e3bcca7ca..01b97463f 100644 --- a/scene/README.md +++ b/scene/README.md @@ -5,7 +5,7 @@ are ONE renderer fed ONE JSON shape. This folder is the source; everything else | 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` | +| `live-dag.js` | EMBER 02 `IgneumDag` 2.0.4: lanes per vote key, parent curves, the selected chain, checkpoint bands, proof glow; the box height follows the lanes (`onSize`, `autoHeight`) | `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 | @@ -25,6 +25,14 @@ option names this machine's vote keys, so its own blocks glow and its lane reads 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 box's height follows the lanes (2.0.4): wanted height = top padding + axis padding + lanes shown times `laneHeight` +(46 px, 40 on a phone, 26 compact; at most `maxLanes` 7, `narrowLanes` 4 on a phone, never under 2). The renderer calls +`onSize(heightPx, {lanes, laneHeight, narrow, compact})` whenever that changes and exposes `getWantedHeight()`; a page that +lays out its own box sets its height from the callback. With `autoHeight` (on by default when the host set no CSS height on +the canvas, forced either way by the option, never in compact mode) the renderer sets `canvas.style.height` itself and shows +every lane up to the cap. Every current host sets a height (/live 420 px, the hero 500 px, the app's card 220 px and its +Inspect view 420 px), so until a page opts in the frames are unchanged. + ## The tests (all in `tools/ci/pre-push.sh`) | Line | What it proves | diff --git a/scene/live-dag.js b/scene/live-dag.js index 0d28305d5..7911c0448 100644 --- a/scene/live-dag.js +++ b/scene/live-dag.js @@ -1,5 +1,10 @@ /* 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.4 (7 Oct 2026): the box's height follows the lanes. laneHeight (default 46 px, 40 on a phone, 26 compact) times the lanes + * shown (at most maxLanes, narrowLanes on a phone, never under 2) plus the axis and padding is the scene's wanted height; + * onSize(heightPx, {lanes, laneHeight, narrow, compact}) reports it whenever it changes, so a page can size the box, and with + * autoHeight (true by default when the host set no CSS height on the canvas, forced either way by the option; never in + * compact mode, the app's card keeps its own height) the renderer sets canvas.style.height itself and lets every lane through. * 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 @@ -61,6 +66,10 @@ // 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; + // autoHeight: the host set no height on the canvas (its computed height is still the attribute's, 150 by default) and this + // is not the compact card; read before size() touches the attribute + var hostHeightSet=(function(){try{var cs=getComputedStyle(canvas),attr=canvas.getAttribute('height'),h=parseFloat(cs.height);return canvas.style.height!==''||!(finite(h)&&Math.round(h)===(attr===null?150:Number(attr)));}catch(e){return true;}})(); + var autoHeight=opts.autoHeight===true||(opts.autoHeight!==false&&!compact&&!hostHeightSet),laneHeightOpt=finite(opts.laneHeight)?opts.laneHeight:0,wantH=0,laneHNow=46; 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; @@ -112,7 +121,8 @@ 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()); + var laneH=laneHeightOpt||(compact?26:narrow?40:46),cap=narrow?narrowLanes:maxLanes;laneHNow=laneH; + var capacity=autoHeight?cap:Math.min(cap,Math.max(2,Math.floor((H-padT-padB)/laneH))),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. @@ -121,6 +131,9 @@ 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);}); + // the wanted box height for these lanes (2.0.4): reported on change; applied here only under autoHeight + var nextH=padT+padB+Math.max(2,lanes.length)*laneH; + if(nextH!==wantH){wantH=nextH;emit('onSize',wantH,{lanes:lanes.length,laneHeight:laneH,narrow:narrow,compact:compact});if(autoHeight&&canvas.style.height!==wantH+'px')canvas.style.height=wantH+'px';} 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); @@ -179,7 +192,9 @@ 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}; + invalidRecords:invalid,omittedRecords:omitted,rendered:blocks.length,frames:frameCount,window:windowS,narrow:narrow,lanes:lanes.length,reducedMotion:reduced||!manualMotion,state:state, + // the lane geometry (2.0.4), so a page that sizes its own box reads no constants + laneHeight:laneHNow,padT:padT,padB:padB,capacity:narrow?narrowLanes:maxLanes,wantedHeight:wantH,autoHeight:autoHeight}; } function poll(){ if(destroyed||opts.poll===false||doc.hidden)return; @@ -356,11 +371,11 @@ 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, + var api={push:push,setPaused:setPaused,zoom:zoom,engage:engage,getWindow:function(){return windowS;},getWantedHeight:function(){return wantH;},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'}; + root.IgneumDag={mount:mount,version:'2.0.4'}; })(window); diff --git a/site/live-dag.js b/site/live-dag.js index 0d28305d5..7911c0448 100644 --- a/site/live-dag.js +++ b/site/live-dag.js @@ -1,5 +1,10 @@ /* 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.4 (7 Oct 2026): the box's height follows the lanes. laneHeight (default 46 px, 40 on a phone, 26 compact) times the lanes + * shown (at most maxLanes, narrowLanes on a phone, never under 2) plus the axis and padding is the scene's wanted height; + * onSize(heightPx, {lanes, laneHeight, narrow, compact}) reports it whenever it changes, so a page can size the box, and with + * autoHeight (true by default when the host set no CSS height on the canvas, forced either way by the option; never in + * compact mode, the app's card keeps its own height) the renderer sets canvas.style.height itself and lets every lane through. * 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 @@ -61,6 +66,10 @@ // 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; + // autoHeight: the host set no height on the canvas (its computed height is still the attribute's, 150 by default) and this + // is not the compact card; read before size() touches the attribute + var hostHeightSet=(function(){try{var cs=getComputedStyle(canvas),attr=canvas.getAttribute('height'),h=parseFloat(cs.height);return canvas.style.height!==''||!(finite(h)&&Math.round(h)===(attr===null?150:Number(attr)));}catch(e){return true;}})(); + var autoHeight=opts.autoHeight===true||(opts.autoHeight!==false&&!compact&&!hostHeightSet),laneHeightOpt=finite(opts.laneHeight)?opts.laneHeight:0,wantH=0,laneHNow=46; 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; @@ -112,7 +121,8 @@ 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()); + var laneH=laneHeightOpt||(compact?26:narrow?40:46),cap=narrow?narrowLanes:maxLanes;laneHNow=laneH; + var capacity=autoHeight?cap:Math.min(cap,Math.max(2,Math.floor((H-padT-padB)/laneH))),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. @@ -121,6 +131,9 @@ 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);}); + // the wanted box height for these lanes (2.0.4): reported on change; applied here only under autoHeight + var nextH=padT+padB+Math.max(2,lanes.length)*laneH; + if(nextH!==wantH){wantH=nextH;emit('onSize',wantH,{lanes:lanes.length,laneHeight:laneH,narrow:narrow,compact:compact});if(autoHeight&&canvas.style.height!==wantH+'px')canvas.style.height=wantH+'px';} 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); @@ -179,7 +192,9 @@ 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}; + invalidRecords:invalid,omittedRecords:omitted,rendered:blocks.length,frames:frameCount,window:windowS,narrow:narrow,lanes:lanes.length,reducedMotion:reduced||!manualMotion,state:state, + // the lane geometry (2.0.4), so a page that sizes its own box reads no constants + laneHeight:laneHNow,padT:padT,padB:padB,capacity:narrow?narrowLanes:maxLanes,wantedHeight:wantH,autoHeight:autoHeight}; } function poll(){ if(destroyed||opts.poll===false||doc.hidden)return; @@ -356,11 +371,11 @@ 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, + var api={push:push,setPaused:setPaused,zoom:zoom,engage:engage,getWindow:function(){return windowS;},getWantedHeight:function(){return wantH;},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'}; + root.IgneumDag={mount:mount,version:'2.0.4'}; })(window); diff --git a/tools/ci/pre-push.sh b/tools/ci/pre-push.sh index f9964dc7f..d3e337e2e 100755 --- a/tools/ci/pre-push.sh +++ b/tools/ci/pre-push.sh @@ -101,6 +101,7 @@ tree_checks() { run "chain scene: the site's and the app's copies are scene/ byte for byte, the palette tokens live once (self-test, then the tree)" bash -c 'node tools/scene/sync.mjs --self-test && node tools/scene/sync.mjs --check' run "chain scene: a push paints with the document hidden and no animation frame (the blank /live of 7 Oct 2026; known-failed first)" node tools/scene/paint-test.cjs run "chain scene: the live feed contract (the recorded reply validates; a rewritten miner, a float now, a stray key refused)" node --test tools/scene/feed-contract.test.mjs + run "chain scene parity: one recorded feed through the home fold, /live and the app's Inspect view on build-2, three frames each pixel-equal apart from the app's own-key overlay (a changed token fails first; skipped with no box and no Playwright)" bash tools/scene/parity-remote.sh run "explorer, emission and public stats unit tests" node --test site/lib/explorer.test.mjs site/lib/emission.test.mjs site/api/public-stats.test.mjs run "ship tool self-test" node tools/ship-app.mjs --self-test run "relay unit tests" node --test relay/test/parse.test.mjs relay/test/auth.test.mjs relay/test/wake.test.mjs relay/test/ember.test.mjs diff --git a/tools/scene/parity-remote.sh b/tools/scene/parity-remote.sh new file mode 100755 index 000000000..b6ecd75a0 --- /dev/null +++ b/tools/scene/parity-remote.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# The scene parity test on a build box (the Mac runs no headless browser: standing rule, 7 October 2026). Carries the files the +# test needs to the box under this lane's prefix, runs tools/scene/parity.mjs there and prints its report; the box's own +# Playwright build and Chromium (root installed the libraries on 7 October 2026, 14:5x UK) do the rendering. +# +# tools/scene/parity-remote.sh [--box N] [--out ] default box 2; --out fetches the frames and the report back +# +# Where it runs: a box named by ~/.config/igneum/build-server[-N] (build@); on a machine with no box file but a local +# Playwright (a CI runner that is itself a box) it runs parity.mjs in place; with neither it prints a skip line and exits 0, +# so a plain GitHub runner stays green while every box-side and Mac-side gate renders for real. +set -uo pipefail +cd "$(git rev-parse --show-toplevel)" || exit 1 +BOX=2; OUT="" +while [ $# -gt 0 ]; do case "$1" in --box) BOX="$2"; shift 2 ;; --out) OUT="$2"; shift 2 ;; *) echo "unknown argument $1" >&2; exit 2 ;; esac; done +HOSTFILE="$HOME/.config/igneum/build-server"; [ "$BOX" = 1 ] || HOSTFILE="$HOSTFILE-$BOX" +if [ ! -s "$HOSTFILE" ]; then + if [ -n "${PLAYWRIGHT_CORE:-}" ] || [ -f /srv/builds/_bin/overlap/node_modules/playwright-core/package.json ]; then exec node tools/scene/parity.mjs ${OUT:+--out "$OUT"}; fi + echo "scene-parity: skipped, no build box file at $HOSTFILE and no Playwright here"; exit 0 +fi +HOST="$(cat "$HOSTFILE")"; KEY="$HOME/.ssh/igneum_ed25519" +SSH=(ssh -i "$KEY" -o BatchMode=yes -o ConnectTimeout=10 "$HOST") +WT="$(basename "$(pwd)")"; DEST="/srv/builds/scene-parity/$WT" +"${SSH[@]}" "mkdir -p $DEST/site $DEST/tools/ui-mock $DEST/tools/scene $DEST/scene $DEST/app/igneum-app/ui" || { echo "scene-parity: box $HOST unreachable" >&2; exit 1; } +RS=(rsync -a --delete -e "ssh -i $KEY -o BatchMode=yes") +"${RS[@]}" --exclude img --exclude verify --exclude '*.png' --exclude '*.ico' site/ "$HOST:$DEST/site/" +"${RS[@]}" scene/ "$HOST:$DEST/scene/" +"${RS[@]}" tools/scene/ "$HOST:$DEST/tools/scene/" +"${RS[@]}" tools/ui-mock/ "$HOST:$DEST/tools/ui-mock/" +"${RS[@]}" tools/site-serve.mjs "$HOST:$DEST/tools/site-serve.mjs" +if [ -f app/igneum-app/ui/live-dag.js ]; then "${RS[@]}" --exclude '*.woff2' app/igneum-app/ui/ "$HOST:$DEST/app/igneum-app/ui/"; else "${SSH[@]}" "rm -rf $DEST/app"; fi +[ -f brand/igneum-coin-1024.png ] && "${SSH[@]}" "mkdir -p $DEST/brand" && "${RS[@]}" brand/igneum-coin-1024.png "$HOST:$DEST/brand/igneum-coin-1024.png" +# the copied-sources rule: re-stamped on the far side before anything runs (touch), so no stale mtime decides anything there +"${SSH[@]}" "cd $DEST && find . -type f -exec touch {} + && git init -q . 2>/dev/null; [ -f /srv/builds/_bin/overlap/env.sh ] && . /srv/builds/_bin/overlap/env.sh; cd $DEST && timeout 240 nice -n 10 node tools/scene/parity.mjs --out $DEST/_out" +RC=$? +if [ -n "$OUT" ]; then mkdir -p "$OUT"; rsync -a -e "ssh -i $KEY -o BatchMode=yes" "$HOST:$DEST/_out/" "$OUT/" 2>/dev/null; fi +exit $RC diff --git a/tools/scene/parity.mjs b/tools/scene/parity.mjs new file mode 100644 index 000000000..6c8a1d13c --- /dev/null +++ b/tools/scene/parity.mjs @@ -0,0 +1,184 @@ +#!/usr/bin/env node +// Scene parity: the same recorded feed through the site's home fold, the site's /live and the miner app's Inspect view, three +// frames each at the same three instants of a fake clock, compared pixel for pixel. Runs on a build box (tools/scene/parity-remote.sh +// carries the tree there and runs this; never a headless browser on the Mac). +// +// node tools/scene/parity.mjs [--out ] the report on stdout, one line per comparison; exit 1 on any difference +// +// What it does, in order (the known-failed case first, as the watcher-trust rule asks): +// 1. serves site/ through tools/site-serve.mjs and app/igneum-app/ui through tools/ui-mock/server.mjs on private ports; +// every page's /api/live is answered from scene/fixtures/live-2026-10-07.json by a Playwright route, the clock is frozen +// at the fixture's `now` (Date, timers, requestAnimationFrame), the viewport is 1280 by 900 at DPR 1, dark; +// 2. KNOWN-FAILED: the app page with one palette token changed (--included) must differ from /live at every instant; +// 3. base: no own key anywhere; home fold, /live and the app's Inspect scene byte-equal at T, T+2 s, T+5 s; +// 4. overlay: the same vote key named on every surface (localStorage on the site, the mock's card ids on the app); the +// three surfaces still byte-equal, and the app's overlay frame differs from its base frame (the overlay is drawn); +// 5. the app's compact card (its own mode, no site counterpart) is rendered once and reported, not compared. +// Every scene canvas is forced to 900 by 420 CSS px so the comparison is of the renderer, the tokens, the options and the +// feed, not of the box the page gives the scene. A surface whose files are absent (master carries no app UI scene) is +// reported as skipped and the remaining surfaces are compared. +import { createRequire } from 'node:module'; +import { spawn } from 'node:child_process'; +import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createHash } from 'node:crypto'; +import { homedir } from 'node:os'; + +const here = dirname(fileURLToPath(import.meta.url)), repo = join(here, '..', '..'); +const out = process.argv.includes('--out') ? process.argv[process.argv.indexOf('--out') + 1] : ''; +if (out) mkdirSync(out, { recursive: true }); +const PW = [process.env.PLAYWRIGHT_CORE, '/srv/builds/_bin/overlap/node_modules/playwright-core/package.json', '/srv/builds/scene-parity/pw/node_modules/playwright-core/package.json', join(homedir(), '.cache/igneum-pw/node_modules/playwright-core/package.json')].filter(Boolean).find(p => existsSync(p)); +const CHROME = [process.env.PLAYWRIGHT_CHROMIUM, join(homedir(), '.cache/ms-playwright/chromium_headless_shell-1194/chrome-linux/headless_shell'), join(homedir(), '.cache/ms-playwright/chromium-1194/chrome-linux/chrome')].filter(Boolean).find(p => existsSync(p)); +if (!PW || !CHROME) { console.log('scene-parity: skipped, no Playwright Chromium here (PLAYWRIGHT_CORE, PLAYWRIGHT_CHROMIUM)'); process.exit(0); } +const { chromium } = createRequire(PW)('playwright-core'); + +const fixture = JSON.parse(readFileSync(join(repo, 'scene/fixtures/live-2026-10-07.json'), 'utf8')); +const T0 = new Date(fixture.now).getTime(); +const KEY = fixture.miners[2].id; // a real key of the recording with a few blocks: the overlay case names it on every surface +const SIZE = { w: 900, h: 420 }, INSTANTS = [0, 2000, 4000]; // the first push and the next two polls (every surface polls every 2 s) +const hasApp = existsSync(join(repo, 'app/igneum-app/ui/live-dag.js')) && existsSync(join(repo, 'tools/ui-mock/server.mjs')); +const problems = [], lines = []; +const say = s => { lines.push(s); console.log(s); }; + +// ---- servers ---- +function serve(cmd, args, env, ready) { + return new Promise((resolve, reject) => { + const p = spawn(process.execPath, [cmd, ...args], { env: { ...process.env, ...env }, stdio: ['ignore', 'pipe', 'pipe'] }); + let log = ''; const t = setTimeout(() => reject(new Error('server did not start: ' + cmd + '\n' + log)), 15000); + const probe = async () => { try { const r = await fetch(ready); if (r.ok || r.status === 404) { clearTimeout(t); resolve(p); return; } } catch { } setTimeout(probe, 200); }; + p.stdout.on('data', d => { log += d; }); p.stderr.on('data', d => { log += d; }); + p.on('exit', c => { clearTimeout(t); reject(new Error(cmd + ' exited ' + c + '\n' + log)); }); + setTimeout(probe, 300); + }); +} +const port = base => base + Math.floor(Math.random() * 200); +const sitePort = port(4600), appPort = port(4900); +const servers = []; +servers.push(await serve(join(repo, 'tools/site-serve.mjs'), [], { PORT: String(sitePort), DATABASE_URL: 'postgres://none' }, `http://127.0.0.1:${sitePort}/live`)); +if (hasApp) servers.push(await serve(join(repo, 'tools/ui-mock/server.mjs'), [String(appPort)], {}, `http://127.0.0.1:${appPort}/t/mock/api/state`)); +const stop = () => { for (const s of servers) try { s.kill(); } catch { } }; +process.on('exit', stop); + +// ---- the browser ---- +const browser = await chromium.launch({ executablePath: CHROME, args: ['--renderer-process-limit=1', '--font-render-hinting=none', '--disable-lcd-text'] }); +// the mount shim: every mounted scene is listed with a push counter, so a capture can wait for a push to have landed +const capture = () => { let real; Object.defineProperty(window, 'IgneumDag', { configurable: true, get() { return real; }, set(v) { real = Object.assign({}, v, { mount(c, o) { const api = v.mount(c, o); const entry = { canvas: c, api, opts: o, pushes: 0 }; const push = api.push; api.push = function (d) { const ok = push(d); if (ok) entry.pushes++; return ok; }; (window.__dags = window.__dags || []).push(entry); return api; } }); } }); }; + +/** One surface: opens the page with the fixture as its feed and the clock frozen at T0; returns frame hashes at the instants. */ +async function surface(name, url, { ownKey, mutate, selectCanvas, prepare } = {}) { + const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1, colorScheme: 'dark', reducedMotion: 'no-preference' }); + const page = await ctx.newPage(); + const errors = []; page.on('pageerror', e => errors.push(String(e.message))); + await ctx.clock.install({ time: T0 }); + await ctx.clock.pauseAt(T0 + 1); // frozen: only runFor moves it, so every surface is read at the same three instants + await page.addInitScript(capture); + // the scene's box before any script runs (lane order keeps the history of earlier layouts, so the first layout must already + // be at the compared size): a style appended the moment exists; no border or padding inside the canvas box + await page.addInitScript(({ sel, w, h, extra }) => { const css = sel + '{width:' + w + 'px!important;height:' + h + 'px!important;max-width:none!important;border:0!important;padding:0!important;box-sizing:content-box!important}' + extra; const add = () => { if (!document.head) return false; const st = document.createElement('style'); st.textContent = css; document.head.appendChild(st); return true; }; if (!add()) new MutationObserver((m, o) => { if (add()) o.disconnect(); }).observe(document, { childList: true, subtree: true }); }, { sel: selectCanvas, w: SIZE.w, h: SIZE.h, extra: mutate || '' }); + if (ownKey) await page.addInitScript(k => { try { localStorage.setItem('igneum-my-key', k); localStorage.setItem('igneum-theme', 'dark'); } catch { } }, ownKey); + else await page.addInitScript(() => { try { localStorage.removeItem('igneum-my-key'); localStorage.setItem('igneum-theme', 'dark'); } catch { } }); + await page.route('**/api/live*', r => r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(fixture) })); + // the app's own keys come from its cards: none in the base case (the mock's recorded card is a real devnet key that is in the + // fixture), exactly the named key in the overlay case + if (name.startsWith('app')) await page.route('**/api/state*', async r => { const res = await r.fetch(); const s = await res.json(); if (s.mining && s.mining.cards) s.mining.cards.forEach((c, i) => { c.ids = ownKey && i === 0 ? [ownKey] : []; }); r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(s) }); }); + await page.goto(url, { waitUntil: 'load' }); + // the mount, then the first push (the page's own poll runs at load; the fetch resolves in real time) + if (prepare) await prepare(page); + await page.waitForFunction(sel => !!(window.__dags || []).find(x => x.canvas.matches(sel)), selectCanvas, { timeout: 15000 }).catch(() => { }); + // the canvas in view, so the IntersectionObserver reports it and the motion loop runs on every surface alike (a canvas below + // the fold paints on push alone since 2.0.3, which is right for a viewer but not a comparison of the loop) + await page.evaluate(sel => { const c = document.querySelector(sel); if (c) c.scrollIntoView({ block: 'center' }); }, selectCanvas); + await page.waitForTimeout(200); + // the capture protocol, the same on every surface: advance the frozen clock exactly to the next poll, wait (in real time) + // for that push to land, read the canvas as the push's own synchronous paint left it. No animation frame runs between the + // push and the read, so the frame depends on the fake time alone, never on how long the fetch took. + const pushes = () => page.evaluate(sel => { const d = (window.__dags || []).find(x => x.canvas.matches(sel)); return d ? d.pushes : -1; }, selectCanvas); + const frames = []; + let at = 0, want = 1; + for (const ms of INSTANTS) { + if (ms > at) { await ctx.clock.runFor(ms - at); at = ms; } + const t0 = Date.now(); while (Date.now() - t0 < 8000 && (await pushes()) < want) { await page.waitForTimeout(50); if ((await pushes()) < want && Date.now() - t0 > 1500) { await ctx.clock.runFor(1); at += 1; } } + if ((await pushes()) < want) say(' push ' + want + ' never landed on ' + name + ' (' + (await pushes()) + ' so far)'); + want++; + const f = await page.evaluate(sel => { + const d = (window.__dags || []).find(x => x.canvas.matches(sel)); if (!d) return { error: 'no scene on ' + sel }; + const c = d.canvas, g = c.getContext('2d'), st = d.api.getStats(); + const px = g.getImageData(0, 0, c.width, c.height).data; let h = 0, lit = 0; + for (let i = 0; i < px.length; i += 4) { if (px[i] + px[i + 1] + px[i + 2] > 90) lit++; } + return { w: c.width, h: c.height, lit, blocks: st.blocks, lanes: st.lanes, window: st.window, narrow: st.narrow, state: st.state, frames: st.frames, png: c.toDataURL('image/png') }; + }, selectCanvas); + if (f.error) { frames.push(f); continue; } + f.hash = createHash('sha256').update(f.png).digest('hex').slice(0, 16); + if (out) writeFileSync(join(out, `${name}-${ms}.png`), Buffer.from(f.png.split(',')[1], 'base64')); + f.pngData = f.png; delete f.png; frames.push(f); + } + await ctx.close(); + return { name, frames, errors }; +} +const describe = s => s.frames.map((f, i) => f.error ? f.error : `T+${INSTANTS[i] / 1000}s ${f.hash} ${f.w}x${f.h} lit ${f.lit} blocks ${f.blocks} lanes ${f.lanes} win ${f.window}${f.narrow ? ' narrow' : ''} ${f.state}`).join('; '); +async function compare(label, a, b, mustDiffer) { + const diffs = []; + for (let i = 0; i < INSTANTS.length; i++) { + const fa = a.frames[i], fb = b.frames[i]; + if (fa.error || fb.error) { diffs.push('absent'); continue; } + if (fa.hash === fb.hash) { diffs.push('equal'); continue; } + const d = (fa.w === fb.w && fa.h === fb.h) ? await pixelDiff(fa.pngData, fb.pngData) : { n: -1 }; + diffs.push('DIFFERS' + (d.n >= 0 ? ' (' + d.n + ' of ' + d.total + ' px, box ' + d.box.join(',') + ')' : ' (sizes differ)')); + } + const bad = mustDiffer ? diffs.some(d => !d.startsWith('DIFFERS')) : diffs.some(d => d !== 'equal'); + say(` ${bad ? 'RED ' : 'ok '} ${label}: ${diffs.map((d, i) => `T+${INSTANTS[i] / 1000}s ${d}`).join(', ')}`); + if (bad) problems.push(label); +} +const dims = (a, b) => a.frames[0].w === b.frames[0].w && a.frames[0].h === b.frames[0].h; +async function pixelDiff(aPng, bPng) { + const ctx = await browser.newContext(); const page = await ctx.newPage(); + const r = await page.evaluate(async ([a, b]) => { + const load = src => new Promise(res => { const i = new Image(); i.onload = () => res(i); i.src = src; }); + const [ia, ib] = await Promise.all([load(a), load(b)]); + const c = document.createElement('canvas'); c.width = ia.width; c.height = ia.height; const g = c.getContext('2d'); + g.drawImage(ia, 0, 0); const pa = g.getImageData(0, 0, c.width, c.height).data; g.clearRect(0, 0, c.width, c.height); g.drawImage(ib, 0, 0); const pb = g.getImageData(0, 0, c.width, c.height).data; + let n = 0, x0 = 1e9, y0 = 1e9, x1 = -1, y1 = -1; + for (let i = 0; i < pa.length; i += 4) { if (pa[i] !== pb[i] || pa[i + 1] !== pb[i + 1] || pa[i + 2] !== pb[i + 2] || pa[i + 3] !== pb[i + 3]) { n++; const p = i / 4, x = p % c.width, y = (p / c.width) | 0; if (x < x0) x0 = x; if (y < y0) y0 = y; if (x > x1) x1 = x; if (y > y1) y1 = y; } } + return { n, box: n ? [x0, y0, x1 - x0 + 1, y1 - y0 + 1] : null, total: c.width * c.height }; + }, [aPng, bPng]); + await ctx.close(); return r; +} + +const SITE_LIVE = `http://127.0.0.1:${sitePort}/live`, SITE_HOME = `http://127.0.0.1:${sitePort}/`, APP = `http://127.0.0.1:${appPort}/t/mock/?scenario=live&theme=dark`; +const openInspect = async page => { await page.waitForSelector('#dag-inspect', { timeout: 15000 }); await page.waitForFunction(() => (window.__dags || []).length > 0, null, { timeout: 15000 }).catch(() => { }); await page.waitForTimeout(300); await page.click('#dag-inspect'); await page.waitForTimeout(300); }; +try { + say(`scene-parity: fixture ${fixture.now}, ${fixture.blocks.length} blocks, key ${KEY} for the overlay case, canvases ${SIZE.w}x${SIZE.h}, instants ${INSTANTS.join('/')} ms`); + const live = await surface('live', SITE_LIVE, { selectCanvas: '#dag' }); + say(' /live base: ' + describe(live)); + const home = await surface('home', SITE_HOME, { selectCanvas: '#dag' }); + say(' home fold base: ' + describe(home)); + for (const s of [live, home]) for (const e of s.errors) say(' page error on ' + s.name + ': ' + e); + if (live.frames.some(f => !f.lit)) problems.push('/live drew nothing'); + await compare('home fold = /live (same renderer, same feed, same options)', home, live, false); + if (hasApp) { + // known-failed first: one token changed on the app side must fail + const mutated = await surface('app-mutated', APP, { selectCanvas: '#dag-full', prepare: openInspect, mutate: ':root{--included:#3B7DD9!important}' }); + say(' app Inspect with --included changed by one unit: ' + describe(mutated)); + await compare('KNOWN-FAILED: app with a changed palette token differs from /live', mutated, live, true); + const app = await surface('app', APP, { selectCanvas: '#dag-full', prepare: openInspect }); + say(' app Inspect base: ' + describe(app)); + for (const e of app.errors) say(' page error on app: ' + e); + if (!dims(app, live)) problems.push('canvas buffers differ in size'); + await compare('app Inspect = /live', app, live, false); + // the overlay: the same key on every surface + const liveK = await surface('live-key', SITE_LIVE, { selectCanvas: '#dag', ownKey: KEY }); + const appK = await surface('app-key', APP, { selectCanvas: '#dag-full', prepare: openInspect, ownKey: KEY }); + say(' /live with the key: ' + describe(liveK)); + say(' app Inspect with the key: ' + describe(appK)); + await compare('app Inspect with this machine\'s key = /live with the same key (the overlay is the same picture)', appK, liveK, false); + await compare('the app\'s overlay frame differs from its base frame (the overlay is drawn)', appK, app, true); + const compact = await surface('app-compact', APP, { selectCanvas: '#dag-live', prepare: async page => { await page.waitForFunction(() => (window.__dags || []).length > 0, null, { timeout: 15000 }).catch(() => { }); } }); + say(' app compact card (its own mode, reported, not compared): ' + describe(compact)); + } else { + say(' app surface: skipped (no app/igneum-app/ui/live-dag.js in this tree; the app lives on the release branches)'); + } +} finally { await browser.close(); stop(); } +say(problems.length ? `scene-parity: RED, ${problems.length} problem(s): ${problems.join('; ')}` : 'scene-parity: ok'); +if (out) writeFileSync(join(out, 'report.txt'), lines.join('\n') + '\n'); +process.exit(problems.length ? 1 : 0);