Files
stack/docs/design/tools/check-design.mjs
T
jason.woltjeandClaude Opus 5.5 811e7ba562 docs(design): handoff package for implementing the design
Jason accepted the draft 1 direction on 2026-10-10; the refined Relay
mark stays as a placeholder. Adds IMPLEMENTING.md (gap map against
row 40 and the bus/CLI modules, strings, boundaries, acceptance,
proposed rows for Sage), tokens.json and generated tokens.css using the
webui's existing names, icons.svg, and tools/build-tokens.mjs and
tools/check-design.mjs. Coverage statuses corrected against the code
(digest implemented; mosaic stop is row 41; priority and agent state
not in the bus reads). No packages/ change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 11:22:06 -05:00

187 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// Browser checks for the design preview (docs/design/mosaic-stack-design.html).
// Usage: node docs/design/tools/check-design.mjs [page.html]
// Needs a local Chromium: $CHROMIUM, else /usr/bin/chromium. No dependencies.
// Exit 0 when every check passes, 1 when any fails, 2 when the browser cannot start.
//
// Checks:
// 1. no script errors while visiting every tab, console view, preview state,
// inspector, terminal screen, palette and mode;
// 2. no sideways page scroll at 400 px on any tab or console view;
// 3. terminal screens are exactly 120 columns (80 for the compact screen) and
// no taller than 36 rows (24 for compact); shell screens stay within 120;
// 4. the preview's palette colours equal tokens.json for every palette and mode.
// The CDP client follows packages/webui/tests/browser.mjs.
import { spawn } from 'node:child_process';
import { mkdtemp, rm } from 'node:fs/promises';
import { readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const page = resolve(process.argv[2] || join(here, '..', 'mosaic-stack-design.html'));
const tokens = JSON.parse(readFileSync(join(here, '..', 'tokens.json'), 'utf8'));
async function browser() {
const profile = await mkdtemp(join(tmpdir(), 'mosaic-design-check-'));
const child = spawn(process.env.CHROMIUM || '/usr/bin/chromium', [
'--headless', '--no-first-run', '--no-default-browser-check',
'--disable-dev-shm-usage', '--remote-debugging-pipe', `--user-data-dir=${profile}`,
], { stdio: ['ignore', 'ignore', 'ignore', 'pipe', 'pipe'] });
const started = new Promise((ok, fail) => { child.once('error', fail); child.once('spawn', ok); });
await started;
let id = 0, buffer = '';
const pending = new Map(), errors = [];
child.stdio[4].on('data', (chunk) => {
buffer += chunk.toString();
let end;
while ((end = buffer.indexOf('\0')) !== -1) {
const m = JSON.parse(buffer.slice(0, end));
buffer = buffer.slice(end + 1);
if (m.method === 'Runtime.exceptionThrown') errors.push(m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text);
if (m.method === 'Runtime.consoleAPICalled' && m.params.type === 'error') errors.push(m.params.args.map((a) => a.value ?? a.description).join(' '));
if (pending.has(m.id)) {
const { ok, fail, timer } = pending.get(m.id);
clearTimeout(timer);
pending.delete(m.id);
m.error ? fail(new Error(JSON.stringify(m.error))) : ok(m.result);
}
}
});
const send = (method, params = {}, sessionId) => new Promise((ok, fail) => {
const key = ++id;
const timer = setTimeout(() => { pending.delete(key); fail(new Error(`CDP timeout: ${method}`)); }, 25000);
pending.set(key, { ok, fail, timer });
child.stdio[3].write(JSON.stringify({ id: key, method, params, ...(sessionId ? { sessionId } : {}) }) + '\0');
});
const { targetId } = await send('Target.createTarget', { url: 'about:blank' });
const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true });
const call = (method, params) => send(method, params, sessionId);
await call('Page.enable');
await call('Runtime.enable');
const evaluate = async (expression) => {
const r = await call('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
if (r.exceptionDetails) throw new Error(r.exceptionDetails.text + ': ' + r.result.description);
return r.result.value;
};
return {
errors, call, evaluate,
viewport: (width, height = 900) => call('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false }),
scheme: (value) => call('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value }] }),
async navigate(url) {
const r = await call('Page.navigate', { url });
if (r.errorText) throw new Error(r.errorText);
for (let n = 0; n < 50 && !(await evaluate('document.readyState === "complete"')); n++) await new Promise((ok) => setTimeout(ok, 100));
},
async close() {
await send('Browser.close').catch(() => {});
if (child.exitCode === null) await new Promise((ok) => child.once('exit', ok));
await rm(profile, { recursive: true, force: true });
},
};
}
const results = [];
const check = (name, failures) => {
results.push({ name, failures });
console.log(`${failures.length ? 'FAIL' : 'pass'} ${name}`);
for (const f of failures.slice(0, 12)) console.log(` ${f}`);
if (failures.length > 12) console.log(` ... ${failures.length - 12} more`);
};
let b;
try { b = await browser(); } catch (err) {
console.error(`cannot start Chromium (${process.env.CHROMIUM || '/usr/bin/chromium'}): ${err.message}`);
process.exit(2);
}
try {
await b.viewport(1280);
await b.scheme('light');
await b.navigate(pathToFileURL(page).href);
await b.evaluate('localStorage.clear(), true').catch(() => {});
const tabs = await b.evaluate('[...document.querySelectorAll("[id^=tab-]")].map(e => e.id.slice(4))');
const views = await b.evaluate('(document.querySelector("#tab-console").click(), [...document.querySelectorAll("#side [data-go]")].map(e => e.dataset.go))');
const states = await b.evaluate('[...document.querySelectorAll("#stateSel option")].map(o => o.value)');
const screens = await b.evaluate('(document.querySelector("#tab-terminal").click(), [...document.querySelectorAll("#screenList [data-scr]")].map(e => e.dataset.scr))');
const nav = (v) => `document.querySelector('#side [data-go="${v}"]').click()`;
const setState = (s) => `(()=>{const e=document.querySelector("#stateSel");e.value=${JSON.stringify(s)};e.dispatchEvent(new Event("change",{bubbles:true}))})()`;
// 1. Script errors across every view, state, inspector and screen.
const before = b.errors.length;
for (const t of tabs) await b.evaluate(`document.querySelector("#tab-${t}").click(), true`);
await b.evaluate('document.querySelector("#tab-console").click(), true');
for (const s of states) {
await b.evaluate(`${setState(s)}, true`);
for (const v of views) {
await b.evaluate(`${nav(v)}, true`);
await b.evaluate('(()=>{const r=document.querySelector("#view tbody tr");if(r)r.click();document.dispatchEvent(new KeyboardEvent("keydown",{key:"Escape",bubbles:true}))})(), true');
}
}
await b.evaluate(`${setState('normal')}, true`);
await b.evaluate('document.querySelector("#tab-terminal").click(), true');
for (const s of screens) await b.evaluate(`document.querySelector('#screenList [data-scr="${s}"]').click(), true`);
check(`no script errors (${tabs.length} tabs, ${views.length} views x ${states.length} states, ${screens.length} terminal screens)`, b.errors.slice(before));
// 3. Terminal widths and heights.
const termFails = [];
for (const s of screens) {
const lines = await b.evaluate(`(document.querySelector('#screenList [data-scr="${s}"]').click(), document.querySelector("#term").innerText.split("\\n"))`);
const shell = await b.evaluate('document.querySelector("#termTitle").textContent.endsWith("shell")');
const compact = await b.evaluate('document.querySelector("#termTitle").textContent.endsWith("80×24")');
const [w, h] = compact ? [80, 24] : [120, 36];
lines.forEach((l, i) => {
const n = [...l].length;
if (shell ? n > w : n !== w) termFails.push(`${s} line ${i + 1}: ${n} columns, expected ${shell ? 'at most ' : ''}${w}`);
});
if (!shell && lines.length > h) termFails.push(`${s}: ${lines.length} rows, expected at most ${h}`);
}
check(`terminal screens fit 120x36 and 80x24 (${screens.length} screens)`, termFails);
// 2. No sideways scroll at 400 px.
await b.viewport(400, 860);
const wide = [];
const overflow = async (label) => {
const r = await b.evaluate('({sw: document.documentElement.scrollWidth, cw: document.documentElement.clientWidth})');
if (r.sw > r.cw) wide.push(`${label}: page is ${r.sw}px wide in a ${r.cw}px viewport`);
};
for (const t of tabs) { await b.evaluate(`document.querySelector("#tab-${t}").click(), true`); await overflow(`tab ${t}`); }
await b.evaluate('document.querySelector("#tab-console").click(), true');
for (const v of views) {
await b.evaluate(`${nav(v)}, true`);
await overflow(`view ${v}`);
await b.evaluate('(()=>{const r=document.querySelector("#view tbody tr");if(r)r.click()})(), true');
await overflow(`view ${v} with inspector`);
await b.evaluate('document.dispatchEvent(new KeyboardEvent("keydown",{key:"Escape",bubbles:true})), true');
}
check(`no sideways scroll at 400 px (${tabs.length} tabs, ${views.length} views)`, wide);
await b.viewport(1280);
// 4. Palettes and modes match tokens.json.
const colourFails = [];
const before4 = b.errors.length;
const modes = await b.evaluate('[...document.querySelectorAll("#modeSeg button")].map(e => e.dataset.mode)');
for (const p of tokens.palettes) {
await b.evaluate(`(()=>{const e=document.querySelector("#palSel");e.value=${JSON.stringify(p.id)};e.dispatchEvent(new Event("change",{bubbles:true}))})(), true`);
for (const m of modes) {
for (const scheme of m === 'system' ? ['light', 'dark'] : ['light']) {
await b.scheme(scheme);
await b.evaluate(`document.querySelector('#modeSeg [data-mode="${m}"]').click(), true`);
const want = p.modes[m === 'system' ? scheme : m];
const got = await b.evaluate('(()=>{const s=getComputedStyle(document.documentElement);return {canvas:s.getPropertyValue("--canvas").trim(),action:s.getPropertyValue("--action").trim(),text:s.getPropertyValue("--text").trim(),danger:s.getPropertyValue("--danger").trim()}})()');
for (const k of Object.keys(got)) {
if (got[k].toLowerCase() !== String(want[k]).toLowerCase()) colourFails.push(`${p.id} ${m}${m === 'system' ? `/${scheme}` : ''} --${k}: page ${got[k]}, tokens.json ${want[k]}`);
}
}
}
}
await b.scheme('light');
check(`palette colours match tokens.json (${tokens.palettes.length} palettes x ${modes.length} modes)`, colourFails.concat(b.errors.slice(before4)));
} finally {
await b.close();
}
const failed = results.filter((r) => r.failures.length).length;
console.log(failed ? `${failed} of ${results.length} checks failed` : `all ${results.length} checks passed`);
process.exit(failed ? 1 : 0);