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]>
187 lines
10 KiB
JavaScript
187 lines
10 KiB
JavaScript
#!/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);
|