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)
95 lines
6.8 KiB
JavaScript
95 lines
6.8 KiB
JavaScript
/* 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);
|