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 f774353461)
56 lines
4.5 KiB
JavaScript
56 lines
4.5 KiB
JavaScript
// 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;
|
|
}
|