From 852a3d3e68baffe7836d2787e4d6a57903d6baa8 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:47:02 +0000 Subject: [PATCH 1/2] Chain scene parity test: one recorded feed through the home fold, /live and the app's Inspect view, three frames each pixel-equal, in the gate (7 October 2026, 15:5x UK) tools/scene/parity.mjs serves site/ (tools/site-serve.mjs) and the app's UI (tools/ui-mock/server.mjs) on a build box, answers every page's /api/live from scene/fixtures/live-2026-10-07.json, freezes the clock at the fixture's instant (Date, timers, requestAnimationFrame) and reads each scene canvas at 900 by 420 px at the first push and the next two polls: the read is the push's own synchronous paint, so a frame depends on the fake time alone, never on how long a fetch took. Known-failed first: the app with --included moved by one unit must differ from /live at every instant (it does: 1,549 / 1,282 / 1,157 px). Then home fold = /live, app Inspect = /live, app with this machine's key = /live with the same key (the overlay is the same picture), and the app's overlay frame differs from its base frame (the overlay is drawn). The compact card is rendered and reported, not compared. A RED line names the differing pixels and their box. tools/scene/parity-remote.sh carries the files to the box (~/.config/igneum/build-server-2 by default) under this lane's prefix and runs it there; a plain CI runner with no box and no Playwright prints a skip line. One new line in tools/ci/pre-push.sh. First run on build-2 at 15:4x UK, release-0.3.21 tree plus scene-parity: every comparison equal at T+0, T+2 and T+4 s. Found on the way and fixed in the harness, not the renderer: lane order keeps the history of earlier layouts (the scene must be at the compared size before its first layout); the app's recorded mock card is a real devnet key (cleared for the base case). Co-Authored-By: Claude Fable 5.1 --- tools/ci/pre-push.sh | 1 + tools/scene/parity-remote.sh | 36 +++++++ tools/scene/parity.mjs | 184 +++++++++++++++++++++++++++++++++++ 3 files changed, 221 insertions(+) create mode 100755 tools/scene/parity-remote.sh create mode 100644 tools/scene/parity.mjs diff --git a/tools/ci/pre-push.sh b/tools/ci/pre-push.sh index 1d05e2a69..bcebd3b70 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); From b8be1b99598e321c9c7a0fabb948329eb7f526d6 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:58:33 +0000 Subject: [PATCH 2/2] Chain scene 2.0.4: the box's height follows the lanes (onSize, autoHeight, the lane geometry in stats) (7 October 2026, 16:3x UK) the project lead's /live screenshot: a fixed tall box with the lanes in its top third and dead space under them. The renderer now knows the height it wants: top padding + axis padding + lanes shown (at most maxLanes 7, narrowLanes 4 on a phone, never under 2) times laneHeight (46 px, 40 on a phone, 26 compact; the mount option laneHeight overrides). It reports it through onSize(heightPx, {lanes, laneHeight, narrow, compact}) whenever it changes and through getWantedHeight(); stats() carries laneHeight, padT, padB, capacity, wantedHeight and autoHeight, so a page that sizes its own box reads no constants. With autoHeight (on by default when the host set no CSS height on the canvas, which is read before the first size(); 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 instead of fitting the lanes to the box. Every current host sets a height (/live 420, the hero 500, the app's card 220 and its Inspect view 420), so the frames are unchanged: the parity test on build-2 stayed equal on every comparison. The site lane switches /live and the home fold to the hook. Co-Authored-By: Claude Fable 5.1 --- scene/README.md | 10 +++++++++- scene/live-dag.js | 23 +++++++++++++++++++---- site/live-dag.js | 23 +++++++++++++++++++---- 3 files changed, 47 insertions(+), 9 deletions(-) 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);