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