scripts/systemscan.mjs
scripts/systemscan.mjsBrowse 27 files
6,237 tokens
22,785 bytes
Token encoding: o200k_base
Snapshot 24fd22b
← Back to SKILL.md
1#!/usr/bin/env node2/**3 * systemscan.mjs — the multi-screen gate: does this product still have ONE design system?4 *5 * slopscan reads source and catches defaults. shoot.mjs photographs one page. Neither can see the6 * failure mode of a real app: drift. Screen 1 has three button variants, screen 7 invents a fourth,7 * and nobody notices because every screen looks fine on its own. This crawls every route, reads what8 * the browser ACTUALLY PAINTED, and reports the system as built rather than as documented.9 *10 * Usage:11 * node scripts/systemscan.mjs <url> [<url>...]12 * node scripts/systemscan.mjs http://localhost:3000 --routes /,/settings,/billing13 *14 * Options:15 * --routes a,b,c paths to append to the first url (instead of listing full urls)16 * --out DIR output dir (default design/system)17 * --max-variants B variant budget: a number, or per kind — `button=5,select=1,4` (default 4)18 * --width N viewport width (default 1440)19 * --no-shots skip the component contact sheet20 *21 * Writes DIR/SYSTEM-REPORT.md (read it), DIR/system.json, DIR/components.png.22 * Exit 1 when the system is provably broken: a control type over budget, an interactive element with23 * no visible focus state, or a token used on exactly one route.24 */25 26import { mkdir, writeFile } from 'fs/promises';27import { resolve, join } from 'path';28 29const args = process.argv.slice(2);30if (!args.length || args.includes('--help')) {31 console.log(`systemscan.mjs — cross-route design-system drift gate32 33 node scripts/systemscan.mjs <url> [<url>...]34 node scripts/systemscan.mjs http://localhost:3000 --routes /,/settings,/billing35 36 --out DIR --max-variants 4|button=5,select=1 --width 1440 --no-shots`);37 process.exit(0);38}39const get = (f, d) => { const i = args.indexOf(f); return i !== -1 ? args[i + 1] : d; };40const has = f => args.includes(f);41 42const outDir = resolve(get('--out', 'design/system'));43// Per-kind budgets: `--max-variants button=5,select=1` (a bare number sets the default for the rest).44// One global number meant a sheet declaring button:5 and select:1 had to pass 5, leaving every45// smaller kind unpoliced — the sheet's central promise enforced for exactly one control kind.46const rawBudget = get('--max-variants', '4');47const budget = { default: 4, byKind: {} };48for (const part of String(rawBudget).split(',').map(s => s.trim()).filter(Boolean)) {49 const m = part.match(/^([a-z]+)\s*=\s*(\d+)$/i);50 if (m) budget.byKind[m[1].toLowerCase()] = +m[2];51 else if (/^\d+$/.test(part)) budget.default = +part;52 else console.error(`[systemscan] ignoring unparseable budget "${part}" — use 4 or button=5,select=1`);53}54const budgetFor = kind => budget.byKind[kind] ?? budget.default;55const width = parseInt(get('--width', '1440'), 10) || 1440;56const shots = !has('--no-shots');57 58let urls = args.filter(a => /^https?:\/\//.test(a) || a.startsWith('file://'));59const routes = get('--routes', null);60if (routes && urls.length) {61 const base = urls[0].replace(/\/$/, '');62 urls = routes.split(',').map(r => base + (r.startsWith('/') ? r : '/' + r));63}64if (!urls.length) { console.error('[systemscan] no urls. Pass them, or a base url + --routes /a,/b'); process.exit(1); }65 66let chromium;67try { ({ chromium } = await import('playwright')); }68catch { console.error('playwright not found. Install: npm i -D playwright && npx playwright install chromium'); process.exit(2); }69 70// Everything below is read off getComputedStyle, i.e. the system as PAINTED. A token that exists in71// the stylesheet but is never rendered is not part of the system; a one-off inline style is.72const COLLECT = () => {73 const cs = getComputedStyle;74 const px = v => Math.round(parseFloat(v) || 0);75 const vis = el => {76 const s = cs(el);77 if (s.display === 'none' || s.visibility === 'hidden' || +s.opacity === 0) return false;78 const r = el.getBoundingClientRect();79 return r.width > 1 && r.height > 1;80 };81 const all = [...document.querySelectorAll('*')].slice(0, 6000).filter(vis);82 83 const controlKind = el => {84 const t = el.tagName.toLowerCase();85 const role = el.getAttribute('role');86 if (t === 'button' || role === 'button') return 'button';87 if (t === 'a' && /(^|\s)(btn|button)/i.test(el.className || '')) return 'button';88 if (t === 'a') return 'link';89 if (t === 'input') return ['checkbox', 'radio'].includes(el.type) ? 'toggle' : 'input';90 if (t === 'select') return 'select';91 if (t === 'textarea') return 'input';92 if (role === 'tab') return 'tab';93 return null;94 };95 96 // The signature is what a user can SEE. Two buttons with different class names but identical97 // paint are one variant; two with the same class and different paint are two.98 const sig = el => {99 const s = cs(el);100 return [101 s.backgroundColor, s.color, s.borderColor,102 `${px(s.borderTopWidth)}b`, `${px(s.borderTopLeftRadius)}r`,103 `${px(s.fontSize)}/${s.fontWeight}`,104 `${px(s.paddingTop)}x${px(s.paddingLeft)}`,105 s.boxShadow === 'none' ? 'noshadow' : 'shadow',106 ].join(' | ');107 };108 109 // A STATE is not a variant. A disabled secondary button paints differently from an enabled one by110 // design — that is the state matrix system.md demands, and counting it as a fifth button punishes111 // the team that built it while a system with no disabled state at all sails through. Same for a112 // control that inverts because the row it sits in is in an alert state: the component did not113 // multiply, its container changed colour underneath it. Both are counted and reported separately.114 const stateOf = el => {115 if (el.disabled || el.getAttribute('aria-disabled') === 'true') return 'disabled';116 if (el.getAttribute('aria-current') || el.getAttribute('aria-selected') === 'true') return 'current';117 for (let n = el.parentElement, hops = 0; n && hops < 4; n = n.parentElement, hops++) {118 const st = n.getAttribute?.('data-state');119 if (st && st !== 'default') return `in-${st}`;120 }121 return null;122 };123 124 const controls = {};125 const states = {};126 let shotId = 0;127 for (const el of all) {128 const kind = controlKind(el);129 if (!kind) continue;130 const state = stateOf(el);131 if (state) { (states[kind] ??= {})[state] = ((states[kind] ??= {})[state] || 0) + 1; continue; }132 const k = sig(el);133 (controls[kind] ??= {});134 if (!controls[kind][k]) {135 // Tag the exemplar NOW and re-select it by attribute in the screenshot pass. Handing a136 // positional index between two independent DOM walks produced tiles that showed a parent or a137 // sibling rather than the control they were captioned with — which destroys the sheet's whole138 // purpose, because a tile showing the wrong element looks different for the wrong reason.139 const tag = `ss${shotId++}`;140 el.setAttribute('data-ss-shot', tag);141 controls[kind][k] = { count: 0, sample: (el.innerText || el.value || el.type || '').trim().slice(0, 24), tag };142 }143 controls[kind][k].count++;144 }145 146 const tally = (map, key) => { map[key] = (map[key] || 0) + 1; };147 const colors = {}, type = {}, radii = {}, shadows = {}, space = {};148 for (const el of all) {149 const s = cs(el);150 if (s.backgroundColor && s.backgroundColor !== 'rgba(0, 0, 0, 0)') tally(colors, s.backgroundColor);151 // `color` on a wrapper paints no glyph. Tallying it unfiltered made <html>'s inherited initial152 // colour the most common "colour in the product" and inflates the headline count for everyone.153 const leafText = el.children.length === 0 && (el.textContent || '').trim();154 if (s.color && leafText) tally(colors, s.color);155 if (leafText) {156 tally(type, `${px(s.fontSize)}px/${s.fontWeight}/${s.fontFamily.split(',')[0].replace(/["']/g, '')}`);157 }158 const r = px(s.borderTopLeftRadius); if (r) tally(radii, `${r}px`);159 if (s.boxShadow && s.boxShadow !== 'none') tally(shadows, s.boxShadow.slice(0, 60));160 for (const v of [s.paddingTop, s.paddingLeft, s.marginTop]) { const n = px(v); if (n) tally(space, `${n}px`); }161 }162 163 return {164 controls, states, colors, type, radii, shadows, space,165 focusable: all.filter(el => el.matches('a[href],button,input,select,textarea,[tabindex]:not([tabindex="-1"])')).length,166 hasDisabled: all.some(el => el.matches('[disabled],[aria-disabled="true"]')),167 title: document.title.slice(0, 60),168 landmarks: ['header', 'nav', 'main', 'footer', 'aside'].filter(t => document.querySelector(t)).join(','),169 h1: document.querySelectorAll('h1').length,170 };171};172 173/**174 * Focus state cannot be read from a stylesheet: a `:focus-visible` rule may exist and be overridden,175 * and `:focus-visible` itself is a heuristic that programmatic `.focus()` does not reliably trigger.176 * So drive the real thing — press Tab and look at what the browser actually paints. Tabbing also177 * skips disabled and unfocusable elements for free, which a `.focus()` loop reports as failures.178 */179const PROBE_FOCUS = async (page, maxStops = 40) => {180 const paint = () => {181 const cs = getComputedStyle;182 // Only properties that actually PAINT. outline-offset alone draws nothing, and including it183 // let an element that kills its outline still read as "focus state changed".184 const sig = el => { const s = cs(el); return `${s.outlineStyle === 'none' ? 'no-outline' : s.outline} ${s.boxShadow} ${s.borderColor} ${s.backgroundColor} ${s.color} ${s.textDecorationLine}`; };185 const els = [...document.querySelectorAll('a[href],button,input,select,textarea,summary,[tabindex]:not([tabindex="-1"])')];186 els.forEach((el, i) => el.setAttribute('data-ss-i', String(i)));187 return els.map(sig);188 };189 const unfocused = await page.evaluate(paint);190 await page.evaluate(() => (document.activeElement || document.body).blur?.());191 192 const bad = [], seen = new Set();193 for (let i = 0; i < maxStops; i++) {194 await page.keyboard.press('Tab');195 const stop = await page.evaluate(() => {196 const el = document.activeElement;197 if (!el || el === document.body || !el.hasAttribute?.('data-ss-i')) return null;198 const s = getComputedStyle(el);199 const r = el.getBoundingClientRect();200 return {201 i: +el.getAttribute('data-ss-i'),202 sig: `${s.outlineStyle === 'none' ? 'no-outline' : s.outline} ${s.boxShadow} ${s.borderColor} ${s.backgroundColor} ${s.color} ${s.textDecorationLine}`,203 label: el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') + ' “' + (el.innerText || el.value || el.getAttribute('aria-label') || '').trim().slice(0, 20) + '”',204 offscreen: r.width < 1 || r.height < 1,205 };206 });207 if (!stop) continue;208 if (seen.has(stop.i)) break; // wrapped around the tab ring209 seen.add(stop.i);210 if (!stop.offscreen && unfocused[stop.i] === stop.sig) bad.push(stop.label);211 }212 return { probed: seen.size, noFocusRing: bad };213};214 215const browser = await chromium.launch({ headless: true });216const ctx = await browser.newContext({ viewport: { width, height: 900 } });217await mkdir(outDir, { recursive: true });218 219const perRoute = [];220for (const url of urls) {221 const page = await ctx.newPage();222 const errs = [];223 page.on('pageerror', e => errs.push(e.message));224 page.on('console', m => m.type() === 'error' && errs.push(m.text()));225 try {226 process.stderr.write(`[systemscan] ${url}\n`);227 const resp = await page.goto(url, { waitUntil: 'commit', timeout: 30_000 });228 const dcl = await page.waitForLoadState('domcontentloaded', { timeout: 15_000 }).then(() => true).catch(() => false);229 await page.waitForTimeout(dcl ? 2500 : 6000);230 if (!dcl) await page.evaluate(() => window.stop()).catch(() => {});231 const data = await page.evaluate(COLLECT);232 const focus = await PROBE_FOCUS(page);233 const status = resp?.status?.() ?? 0;234 // A route that renders nothing contributes nothing to the counts, so a mistyped route list made235 // the gate QUIETER instead of louder. A gate that goes green on a 404 is worse than no gate.236 const empty = !data.landmarks && !data.h1 && !data.focusable;237 perRoute.push({ url, ...data, focus, errors: errs, status, empty });238 if (status >= 400) console.error(`[systemscan] ${url} → HTTP ${status}`);239 else if (empty) console.error(`[systemscan] ${url} → rendered nothing measurable (no landmark, no h1, no focusable)`);240 241 if (shots) {242 // one exemplar screenshot per distinct control variant, so the report has a picture of the243 // drift and not only a count of it244 for (const [kind, variants] of Object.entries(data.controls)) {245 let i = 0;246 for (const [, v] of Object.entries(variants)) {247 if (!v.tag) continue;248 const name = `${kind}-${perRoute.length}-${i++}.png`;249 try {250 const box = await page.$(`[data-ss-shot="${v.tag}"]`); // the element we actually measured251 if (box) { await box.scrollIntoViewIfNeeded({ timeout: 4000 }); await box.screenshot({ path: join(outDir, 'components', name), timeout: 8000 }); v.shot = `components/${name}`; }252 } catch { /* an element that will not sit still is not worth failing the run over */ }253 }254 }255 }256 } catch (e) {257 perRoute.push({ url, error: e.message.split('\n')[0] });258 } finally { await page.close(); }259}260 261// ---- aggregate across routes -------------------------------------------------262const ok = perRoute.filter(r => !r.error);263if (!ok.length) { console.error('[systemscan] no route could be read.'); await browser.close(); process.exit(1); }264 265// A document scanned twice (`/settings` and `/settings#state-error` are the same DOM) must not count266// twice — summing across URLs meant scanning MORE thoroughly hid genuine one-off variants, which is267// the opposite of what `system.md` tells you to do. Count per document, take the max, then sum.268const docOf = u => u.split('#')[0];269const docs = [...new Set(ok.map(r => docOf(r.url)))];270 271const mergeCount = key => {272 const m = {};273 for (const r of ok) for (const [k, n] of Object.entries(r[key] || {})) {274 (m[k] ??= { perDoc: {} });275 const d = docOf(r.url);276 m[k].perDoc[d] = Math.max(m[k].perDoc[d] || 0, n);277 }278 return Object.entries(m)279 .map(([k, v]) => ({ k, total: Object.values(v.perDoc).reduce((a, b) => a + b, 0), routes: Object.keys(v.perDoc).length }))280 .sort((a, b) => b.total - a.total);281};282 283const controlVariants = {};284for (const r of ok) for (const [kind, vars] of Object.entries(r.controls || {})) {285 (controlVariants[kind] ??= {});286 for (const [sig, v] of Object.entries(vars)) {287 const slot = (controlVariants[kind][sig] ??= { perDoc: {}, sample: v.sample, shot: v.shot });288 const d = docOf(r.url);289 slot.perDoc[d] = Math.max(slot.perDoc[d] || 0, v.count);290 slot.shot ??= v.shot;291 }292}293for (const vars of Object.values(controlVariants)) for (const v of Object.values(vars)) {294 v.count = Object.values(v.perDoc).reduce((a, b) => a + b, 0);295 v.routes = new Set(Object.keys(v.perDoc));296}297 298// States, gathered the same way but never counted against the variant budget.299const controlStates = {};300for (const r of ok) for (const [kind, st] of Object.entries(r.states || {}))301 for (const [name, n] of Object.entries(st)) {302 ((controlStates[kind] ??= {})[name] ??= 0);303 controlStates[kind][name] += n;304 }305 306const fails = [], warns = [];307for (const [kind, vars] of Object.entries(controlVariants)) {308 const n = Object.keys(vars).length, b = budgetFor(kind);309 if (n > b) fails.push(`${kind}: ${n} distinct rendered variants (budget ${b}). A variant nobody can name is drift.`);310 for (const [sig, v] of Object.entries(vars)) {311 if (v.count === 1 && n > 1) warns.push(`${kind} variant used exactly once ("${v.sample}") — either promote it into the system or delete it: ${sig}`);312 }313}314const blank = perRoute.filter(r => !r.error && (r.status >= 400 || r.empty));315if (blank.length) fails.push(`${blank.length} route(s) rendered nothing measurable or returned an error status — a gate that goes green on a 404 is worse than no gate: ${blank.map(r => `${r.url}${r.status >= 400 ? ` (HTTP ${r.status})` : ''}`).join(', ')}`);316const noFocus = ok.flatMap(r => (r.focus?.noFocusRing || []).map(t => `${r.url} → ${t}`));317if (noFocus.length) fails.push(`${noFocus.length} interactive element(s) paint identically when focused — keyboard users cannot see where they are`);318 319const colors = mergeCount('colors'), type = mergeCount('type'), radii = mergeCount('radii'), shadows = mergeCount('shadows');320if (docs.length > 1) {321 for (const [label, list] of [['colour', colors], ['type step', type], ['radius', radii]]) {322 const singles = list.filter(x => x.routes === 1 && x.total >= 3);323 if (singles.length) warns.push(`${singles.length} ${label}(s) appear on exactly one route and nowhere else — that is where the system is splitting: ${singles.slice(0, 4).map(s => s.k).join(' · ')}`);324 }325}326if (type.length > 12) warns.push(`${type.length} distinct type steps across the product — a scale nobody can hold in their head is not a scale`);327if (!ok.some(r => r.hasDisabled)) warns.push('no disabled control appeared on any route — the disabled state is probably undesigned, not absent');328 329// ---- component contact sheet -------------------------------------------------330let sheet = null;331const tiles = Object.entries(controlVariants).flatMap(([kind, vars]) =>332 Object.entries(vars).filter(([, v]) => v.shot).map(([, v], i) => ({ kind, i, ...v })));333if (shots && tiles.length) {334 const html = `<!doctype html><meta charset="utf-8"><style>335 body{margin:0;background:#141414;font:12px/1.3 ui-monospace,monospace;color:#ddd}336 .g{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;padding:12px}337 figure{margin:0;background:#1e1e1e;border-radius:4px;overflow:hidden;padding:10px}338 img{display:block;max-width:100%;margin:0 auto 8px}339 b{color:#fff}340 </style><div class=g>${tiles.map(t =>341 `<figure><img src="${t.shot}"><figcaption><b>${t.kind}</b> ×${t.count} · ${t.routes.size} route(s)</figcaption></figure>`).join('')}</div>`;342 const f = join(outDir, '_components.html');343 await writeFile(f, html, 'utf8');344 const p = await ctx.newPage();345 await p.setViewportSize({ width: 1200, height: 900 });346 await p.goto('file:///' + f.replace(/\\/g, '/'), { waitUntil: 'load', timeout: 20_000 }).catch(() => {});347 await p.waitForTimeout(900);348 // shoot the grid, not the viewport — a fullPage shot of a two-row sheet is mostly empty canvas349 const grid = await p.$('.g');350 await (grid || p).screenshot({ path: join(outDir, 'components.png') });351 await p.close();352 sheet = 'components.png';353}354await browser.close();355 356// ---- report ------------------------------------------------------------------357const L = [];358L.push(`# SYSTEM-REPORT — ${ok.length} route(s), ${new Date().toISOString().slice(0, 10)}`);359L.push('');360L.push('The system as **painted**, not as documented. A token in the stylesheet that never renders is');361L.push('not part of the system; a one-off inline style is. Read this against `design/DESIGN.md` — every');362L.push('number below that DESIGN.md does not account for is drift.');363L.push('');364if (sheet) L.push(`**Look at \`${sheet}\`** — one tile per distinct rendered control variant. Two tiles that look the same to you but appear separately are the drift.\n`);365 366L.push('## Controls');367L.push('');368L.push('| kind | distinct variants | budget | total instances |');369L.push('|---|---|---|---|');370for (const [kind, vars] of Object.entries(controlVariants)) {371 const n = Object.keys(vars).length;372 L.push(`| ${kind} | **${n}** | ${budgetFor(kind)} | ${Object.values(vars).reduce((s, v) => s + v.count, 0)} |`);373}374L.push('');375for (const [kind, vars] of Object.entries(controlVariants)) {376 L.push(`### ${kind}`);377 for (const [sig, v] of Object.entries(vars)) L.push(`- ×${v.count} on ${v.routes.size} route(s) — "${v.sample}" — \`${sig}\``);378 if (controlStates[kind]) {379 const st = Object.entries(controlStates[kind]).map(([n, c]) => `${n} ×${c}`).join(' · ');380 L.push(`- *states (not counted as variants): ${st}*`);381 }382 L.push('');383}384if (Object.keys(controlStates).length) {385 L.push('> States — disabled, current, and controls inside a row carrying a `data-state` — are');386 L.push('> excluded from the variant budget. A disabled button paints differently on purpose; a');387 L.push('> product that has no disabled state at all should not score better than one that does.');388 L.push('');389}390 391L.push('## Tokens as rendered');392L.push('');393for (const [label, list] of [['Colour', colors], ['Type step', type], ['Radius', radii], ['Shadow', shadows]]) {394 L.push(`**${label}** (${list.length} distinct)`);395 for (const x of list.slice(0, 10)) L.push(`- \`${x.k}\` — ${x.total}× on ${x.routes} route(s)`);396 if (list.length > 10) L.push(`- …and ${list.length - 10} more`);397 L.push('');398}399 400L.push('## Per route');401L.push('');402L.push('| route | landmarks | h1 | focusable | console errors |');403L.push('|---|---|---|---|---|');404for (const r of ok) L.push(`| ${r.url} | ${r.landmarks || '—'} | ${r.h1} | ${r.focusable} | ${r.errors.length} |`);405for (const r of perRoute.filter(r => r.error)) L.push(`| ${r.url} | **unreachable** — ${r.error} | | | |`);406L.push('');407 408if (fails.length) { L.push('## FAIL'); fails.forEach(f => L.push(`- ${f}`)); L.push(''); }409if (noFocus.length) { L.push('### Elements with no visible focus state'); noFocus.slice(0, 20).forEach(t => L.push(`- ${t}`)); L.push(''); }410if (warns.length) { L.push('## WARN'); warns.forEach(w => L.push(`- ${w}`)); L.push(''); }411if (!fails.length && !warns.length) L.push('No drift detected. The product renders one system.\n');412 413await writeFile(join(outDir, 'SYSTEM-REPORT.md'), L.join('\n'), 'utf8');414await writeFile(join(outDir, 'system.json'), JSON.stringify({415 routes: perRoute.map(r => ({ ...r, controls: undefined })),416 controls: Object.fromEntries(Object.entries(controlVariants).map(([k, v]) =>417 [k, Object.entries(v).map(([sig, x]) => ({ sig, count: x.count, routes: [...x.routes], sample: x.sample }))])),418 colors, type, radii, shadows, fails, warns,419}, null, 2), 'utf8');420 421console.log(`\n=== systemscan ===`);422console.log(`routes : ${ok.length}/${perRoute.length} read (${docs.length} distinct document(s))`);423console.log(`controls: ${Object.entries(controlVariants).map(([k, v]) => `${k} ${Object.keys(v).length}`).join(' · ') || '—'}`);424console.log(`tokens : ${colors.length} colours · ${type.length} type steps · ${radii.length} radii · ${shadows.length} shadows`);425console.log(`report : ${join(outDir, 'SYSTEM-REPORT.md')}${sheet ? `\nsheet : ${join(outDir, sheet)}` : ''}`);426for (const f of fails) console.log(`FAIL ${f}`);427for (const w of warns.slice(0, 5)) console.log(`WARN ${w}`);428process.exit(fails.length ? 1 : 0);429