Brand board revisions, the five dashboard mockups with their verify
script and evidence, the slice 1 S5 design note (SLICE1-VIEWS.md, rev 3
plus the S2c section 10 update), the Console mockup of inbox, tasks,
agents and trail (mockups/slice1/) and checks/slice1-verify.mjs (16
checks; check 16 pinned to bus at d27042fa). Also the CHAT-03 progress
log and the 2026-09-26 dirty-tree manifest. Design and evidence only;
no package code. Row 40 (#1522), committed on its own at Sage's
direction, 2026-10-09.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
245 lines
23 KiB
JavaScript
245 lines
23 KiB
JavaScript
// Verification for the five WUI mockups. Run: node checks/mockups-verify.mjs
|
||
// Writes screenshots and a JSON report under evidence/mockups/. Uses the installed Chromium over CDP; no npm.
|
||
import { browser } from './browser.mjs';
|
||
import { mkdir, writeFile, readFile } from 'node:fs/promises';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { dirname, join } from 'node:path';
|
||
|
||
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
||
const base = 'file://' + join(root, 'mockups') + '/';
|
||
const out = join(root, 'evidence', 'mockups');
|
||
await mkdir(out, { recursive: true });
|
||
|
||
const designs = ['d1-rail', 'd2-deck', 'd3-console', 'd4-board', 'd5-stream'];
|
||
const routes = ['#/', '#/sessions', '#/sessions/s-01a07cfa', '#/sessions/s-01a08111', '#/projects', '#/projects/mosaic-stack', '#/agents', '#/work', '#/runs', '#/messages', '#/library', '#/settings', '#/launch', '#/does-not-exist'];
|
||
const widths = [320, 390, 768, 960, 1440, 1920, 2560, 3440];
|
||
const modes = ['light', 'dim', 'dark'];
|
||
const report = { generated: new Date().toISOString(), checks: [] };
|
||
let failures = 0;
|
||
function record(name, status, detail = '') { report.checks.push({ name, status, detail }); if (status === 'fail') failures++; const mark = { pass: 'PASS', fail: 'FAIL', 'not verified': 'N/V ' }[status]; console.log(`${mark} ${name}${detail ? ' : ' + detail : ''}`); }
|
||
|
||
const b = await browser();
|
||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: 'window.__errs=[];window.addEventListener("error",e=>window.__errs.push(e.message+" @"+(e.filename||"").split("/").pop()+":"+e.lineno));window.addEventListener("unhandledrejection",e=>window.__errs.push("rejection: "+e.reason));' });
|
||
const settle = ms => b.evaluate(`new Promise(r=>setTimeout(r,${ms}))`);
|
||
const go = async hash => { await b.evaluate(`location.hash=${JSON.stringify(hash)}`); await settle(60); };
|
||
const page = async (d, query = '') => { await b.navigate(`${base}${d}.html${query}`); await settle(60); };
|
||
const probe = () => b.evaluate(`JSON.stringify({errs:window.__errs, sw:document.documentElement.scrollWidth, cw:document.documentElement.clientWidth, main:(document.getElementById('main')||{}).innerHTML?.length||0, title:document.title, h1:document.querySelector('#main h1')?.textContent||''})`).then(JSON.parse);
|
||
|
||
// 1. Every route in every design at every width: renders, no script errors, no horizontal overflow.
|
||
for (const d of designs) {
|
||
const problems = [];
|
||
for (const w of widths) {
|
||
await b.viewport(w, w < 700 ? 844 : 1000);
|
||
await page(d);
|
||
for (const r of routes) {
|
||
await go(r);
|
||
const p = await probe();
|
||
if (p.errs.length) problems.push(`${w} ${r} errors: ${p.errs.join('; ')}`);
|
||
if (p.sw > p.cw) problems.push(`${w} ${r} overflow ${p.sw}>${p.cw}`);
|
||
if (p.main < 200) problems.push(`${w} ${r} empty main`);
|
||
if (r === '#/does-not-exist' && !/not found/i.test(p.h1)) problems.push(`${w} not-found page missing`);
|
||
if (r === '#/' && (w === 390 || w === 1440 || w === 3440)) await b.screenshot(join(out, `${d}-home-${w}.png`));
|
||
if (w === 1440 && ['#/sessions/s-01a07cfa', '#/work', '#/launch', '#/settings'].includes(r)) await b.screenshot(join(out, `${d}-${r.slice(2).replace(/[^a-z0-9]+/gi, '-')}-1440.png`));
|
||
if (w === 390 && r === '#/sessions/s-01a07cfa') await b.screenshot(join(out, `${d}-session-390.png`));
|
||
}
|
||
}
|
||
record(`${d}: ${routes.length} routes × ${widths.length} widths render without script errors or horizontal overflow`, problems.length ? 'fail' : 'pass', problems.slice(0, 6).join(' | '));
|
||
}
|
||
// Index page
|
||
{
|
||
const problems = [];
|
||
for (const w of widths) { await b.viewport(w, 1000); await page('index'); const p = await probe(); if (p.errs.length || p.sw > p.cw) problems.push(`${w}: ${p.errs.join(';')} ${p.sw > p.cw ? 'overflow' : ''}`); }
|
||
await b.viewport(1440, 1000); await page('index'); await b.screenshot(join(out, 'index-1440.png'));
|
||
await b.viewport(390, 844); await b.screenshot(join(out, 'index-390.png'));
|
||
const links = await b.evaluate(`[...document.querySelectorAll('#designs a.primary')].map(a=>a.getAttribute('href').split('?')[0])`);
|
||
record('index: renders at all widths and links all five designs', problems.length || designs.some(d => !links.includes(d + '.html')) ? 'fail' : 'pass', problems.join(' | ') || links.join(', '));
|
||
}
|
||
|
||
// 2. Palettes and modes: every palette in every mode applies tokens, and measured text contrast on the rendered board meets 4.5:1 (3:1 for large text and UI parts).
|
||
await b.viewport(1440, 1000);
|
||
{
|
||
const palettes = await (async () => { await page('d1-rail'); return b.evaluate('window.Brand.palettes.map(p=>p.id)'); })();
|
||
const fails = [];
|
||
const contrastScript = `(() => {
|
||
const parse = c => { if (c.startsWith('color(')) { const m = c.match(/[\\d.]+/g).map(Number); return [m[0] * 255, m[1] * 255, m[2] * 255, m.length > 3 ? m[3] : 1]; } const m = c.match(/[\\d.]+/g).map(Number); return [m[0], m[1], m[2], m.length > 3 ? m[3] : 1]; };
|
||
const lum = c => { const m = parse(c); const f = v => { v /= 255; return v <= .03928 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4; }; return .2126 * f(m[0]) + .7152 * f(m[1]) + .0722 * f(m[2]); };
|
||
const ratio = (a, b) => { const x = lum(a), y = lum(b); return (Math.max(x, y) + .05) / (Math.min(x, y) + .05); };
|
||
const bgOf = el => { let e = el; while (e) { const bg = getComputedStyle(e).backgroundColor; if (bg && !bg.startsWith('rgba(0, 0, 0, 0)') && bg !== 'transparent') return bg; e = e.parentElement; } return getComputedStyle(document.documentElement).backgroundColor; };
|
||
const blend = (fg, bg) => { const f = parse(fg), g = parse(bg); const a = f[3]; if (a >= 1) return fg; return 'rgb(' + [0,1,2].map(i => Math.round(f[i] * a + g[i] * (1 - a))).join(', ') + ')'; };
|
||
const bgFlat = el => { let e = el, acc = null; const layers = []; while (e) { const bg = getComputedStyle(e).backgroundColor; if (bg && bg !== 'transparent' && !bg.startsWith('rgba(0, 0, 0, 0)')) layers.push(bg); e = e.parentElement; } layers.push(getComputedStyle(document.documentElement).backgroundColor); let result = layers.pop(); while (layers.length) result = blend(layers.pop(), result); return result; };
|
||
const sel = ['#main h1', '.wait-detail', '.wait-since', '.goal-report', '.muted', '.badge-ok', '.badge-attn', '.badge-danger', '.badge-muted', '.btn.primary', '.btn:not(.primary)', '.nav-link[aria-current=page]', '.feat-implemented', '.feat-planned', '.feat-proposed', '.feat-mockup', '.cfg-changed', '.count', 'th', '.tab[aria-current=page]', '.bar-link[aria-current=page]', '.cmd-link[aria-current=page]', 'a.rail-count', '.rail-count', '.tab-dot', '.chip[aria-current=true]', '.gen', '.event time', 'code', '.stream a', '.crumbs a', '.legend-link'];
|
||
const out = [];
|
||
for (const s of sel) { const el = document.querySelector(s); if (!el) continue; const cs = getComputedStyle(el); const fg = cs.color; const bg = bgFlat(el); const size = parseFloat(cs.fontSize); const large = size >= 24 || (size >= 18.66 && parseInt(cs.fontWeight) >= 700); const r = ratio(fg, bg); out.push({ s, r: +r.toFixed(2), need: large ? 3 : 4.5, ok: r >= (large ? 3 : 4.5) }); }
|
||
return out;
|
||
})()`;
|
||
const worst = [];
|
||
for (const p of palettes) for (const m of modes) {
|
||
await page('d1-rail', `?palette=${p}&mode=${m}`);
|
||
const applied = await b.evaluate(`document.documentElement.dataset.palette+'/'+document.documentElement.dataset.mode`);
|
||
if (applied !== `${p}/${m}`) fails.push(`${p}/${m} not applied (${applied})`);
|
||
const rows = await b.evaluate(contrastScript);
|
||
for (const r of rows) if (!r.ok) fails.push(`${p}/${m} ${r.s} ${r.r}:1 < ${r.need}`);
|
||
worst.push(...rows.map(r => ({ p, m, ...r })));
|
||
if (p === 'harbor' || (p === 'graphite' && m === 'dim') || (p === 'grove' && m === 'dark')) await b.screenshot(join(out, `palette-${p}-${m}.png`));
|
||
}
|
||
worst.sort((a, b) => a.r - b.r);
|
||
report.contrastLowest = worst.slice(0, 12);
|
||
record(`palettes × modes: ${palettes.length} palettes × 3 modes apply, and ${worst.length} sampled text/UI pairs meet WCAG contrast`, fails.length ? 'fail' : 'pass', fails.slice(0, 8).join(' | ') || `lowest ${worst[0].r}:1 (${worst[0].p}/${worst[0].m} ${worst[0].s})`);
|
||
}
|
||
|
||
// 3. Demo states on every design's board: loading, empty, error render with recovery, and the error state offers retry.
|
||
for (const d of designs) {
|
||
const problems = [];
|
||
for (const s of ['loading', 'empty', 'error']) {
|
||
await page(d, `?state=${s}`);
|
||
const t = await b.evaluate(`document.getElementById('main').innerText`);
|
||
if (s === 'loading' && !/Loading/.test(t)) problems.push('loading text missing');
|
||
if (s === 'empty' && !/No .* yet/.test(t)) problems.push('empty text missing');
|
||
if (s === 'error' && !(/Could not load/.test(t) && /unchanged/.test(t) && await b.evaluate(`!!document.querySelector('[data-action=retry]')`))) problems.push('error state lacks explanation or retry');
|
||
if (d === 'd1-rail' || d === 'd4-board') await b.screenshot(join(out, `${d}-state-${s}-1440.png`));
|
||
}
|
||
// Retry recovers
|
||
await b.evaluate(`document.querySelector('[data-action=retry]').click()`); await settle(60);
|
||
if (await b.evaluate(`window.MosaicApp.ctx.demoState`) !== 'normal') problems.push('retry did not recover');
|
||
record(`${d}: loading, empty, and error states render and error recovers via retry`, problems.length ? 'fail' : 'pass', problems.join(' | '));
|
||
}
|
||
|
||
// 4. Primary flows (simulated): launch conflict, retired-workspace refusal, successful simulated launch, empty-message guard, disabled controls on a session you already control, config-changed notice non-blocking.
|
||
for (const d of designs) {
|
||
const problems = [];
|
||
await page(d); await go('#/launch');
|
||
const submit = async (set) => b.evaluate(`(() => { const f = document.querySelector('form[data-form=launch]'); ${Object.entries(set).map(([k, v]) => `f.elements['${k}'].value = ${JSON.stringify(v)}; f.elements['${k}'].dispatchEvent(new Event('change', {bubbles:true}));`).join('')} f.requestSubmit(); return f.querySelector('.form-result').innerText; })()`);
|
||
const conflict = await submit({ agent: 'dewey', project: 'mosaic-stack', workspace: 'wui' });
|
||
if (!/Already running/.test(conflict) || !/Nothing was launched twice/.test(conflict)) problems.push('conflict path: ' + conflict.slice(0, 80));
|
||
await b.evaluate(`(() => { const f = document.querySelector('form[data-form=launch]'); f.elements.project.value = 'mosaic-stack'; f.elements.project.dispatchEvent(new Event('change', {bubbles:true})); })()`);
|
||
const refusal = await submit({ agent: 'rocko', workspace: 'archive-2025' });
|
||
if (!/Refused/.test(refusal) || !/Nothing was launched/.test(refusal)) problems.push('refusal path: ' + refusal.slice(0, 80));
|
||
const ok = await submit({ agent: 'sage', project: 'mosaic-stack', workspace: 'skills' });
|
||
if (!/Simulated launch/.test(ok)) problems.push('success path: ' + ok.slice(0, 80));
|
||
if (d === 'd2-deck') await b.screenshot(join(out, 'd2-deck-launch-refusal-1440.png'));
|
||
await go('#/sessions/s-01a08111');
|
||
const guard = await b.evaluate(`(() => { const f = document.querySelector('form[data-form=composer]'); f.requestSubmit(); return f.querySelector('.form-result').innerText; })()`);
|
||
if (!/Write a message first/.test(guard)) problems.push('empty message guard: ' + guard.slice(0, 60));
|
||
const disabled = await b.evaluate(`[...document.querySelectorAll('.head-actions button')].map(b => b.textContent.trim() + ':' + b.disabled).join(',')`);
|
||
if (!/Observe:true/.test(disabled) || !/You control:true/.test(disabled)) problems.push('controller buttons: ' + disabled);
|
||
await go('#/sessions/s-01a07cfa');
|
||
const cfg = await b.evaluate(`(() => { const p = document.querySelector('.cfg-panel'); return (p.classList.contains('cfg-changed') ? 'changed ' : '') + (document.querySelector('form[data-form=composer] textarea').disabled ? 'composer-disabled' : 'composer-enabled'); })()`);
|
||
if (cfg !== 'changed composer-enabled') problems.push('config notice: ' + cfg);
|
||
const toast = await b.evaluate(`(() => { document.querySelector('[data-action^="config-check"]').click(); return document.querySelector('#toasts .toast')?.innerText || ''; })()`);
|
||
if (!/Simulated/.test(toast) || !/No live effect/.test(toast)) problems.push('simulated action not labelled: ' + toast.slice(0, 60));
|
||
// Settings: palette + mode persist through reload
|
||
await go('#/settings');
|
||
await b.evaluate(`(() => { const s = document.getElementById('palette-select'); s.value = 'grove'; s.dispatchEvent(new Event('change', {bubbles:true})); const r = document.querySelector('[data-theme=mode][value=dim]'); r.checked = true; r.dispatchEvent(new Event('change', {bubbles:true})); })()`);
|
||
await page(d);
|
||
const persisted = await b.evaluate(`document.documentElement.dataset.palette + '/' + document.documentElement.dataset.mode`);
|
||
if (persisted !== 'grove/dim') problems.push('persistence: ' + persisted);
|
||
await b.evaluate(`localStorage.removeItem('mosaic-wui-mockup-v1')`);
|
||
record(`${d}: launch conflict, retired refusal, simulated launch, empty-message guard, controller-disabled buttons, non-blocking config notice, labelled simulation, appearance persistence`, problems.length ? 'fail' : 'pass', problems.join(' | '));
|
||
}
|
||
|
||
// 5. Keyboard path: skip link first, Tab reaches navigation and the first waiting action, Escape closes overlays, design-specific keys.
|
||
for (const d of designs) {
|
||
const problems = [];
|
||
await b.viewport(1440, 1000); await page(d);
|
||
await b.key('Tab');
|
||
const first = await b.evaluate(`document.activeElement.className + '|' + document.activeElement.textContent.trim()`);
|
||
if (!/skip/.test(first)) problems.push('first tab is not the skip link: ' + first);
|
||
await b.key('Enter'); await settle(30);
|
||
const afterSkip = await b.evaluate(`document.activeElement.id || document.activeElement.tagName`);
|
||
if (afterSkip !== 'main') problems.push('skip link did not focus main: ' + afterSkip);
|
||
await b.key('Tab');
|
||
const inMain = await b.evaluate(`document.getElementById('main').contains(document.activeElement) && (document.activeElement.tagName + ' ' + document.activeElement.textContent.trim().slice(0, 30))`);
|
||
if (!inMain) problems.push('tab after skip did not enter main content');
|
||
// Every interactive element in the board has a visible focus style (outline) when focused
|
||
const focusOk = await b.evaluate(`(() => { const els = [...document.querySelectorAll('#main a, #main button, #main select, #main textarea')].slice(0, 40); let bad = 0; for (const el of els) { el.focus(); const cs = getComputedStyle(el); if (!document.activeElement.isSameNode(el)) continue; if (!cs.outlineStyle || cs.outlineStyle === 'none' || parseFloat(cs.outlineWidth) < 2) { if (!el.closest('.segmented')) bad++; } } return bad; })()`);
|
||
// Chromium computed outline on :focus-visible needs real keyboard focus; if programmatic focus reports none, verify via matches
|
||
if (focusOk > 0) { const visible = await b.evaluate(`(() => { const el = document.querySelector('#main a'); el.focus(); return el.matches(':focus-visible') ? getComputedStyle(el).outlineWidth : 'no-focus-visible'; })()`); if (visible === 'no-focus-visible') problems.push(`focus ring: ${focusOk} elements without outline (programmatic focus)`); }
|
||
if (d === 'd3-console') {
|
||
await b.key('k', 'KeyK', 2); await settle(50);
|
||
const open = await b.evaluate(`document.getElementById('palette').open && document.activeElement.id`);
|
||
if (open !== 'cmd-input') problems.push('Ctrl+K did not open the command palette');
|
||
await b.evaluate(`(() => { const i = document.getElementById('cmd-input'); i.value = 'sess'; i.dispatchEvent(new Event('input', {bubbles:true})); })()`);
|
||
await b.key('ArrowDown'); await b.key('Enter'); await settle(80);
|
||
const dest = await b.evaluate(`location.hash`);
|
||
if (!/sessions/.test(dest)) problems.push('palette Enter did not navigate: ' + dest);
|
||
await go('#/');
|
||
await b.evaluate(`document.querySelector('table.sessions tbody tr').focus()`);
|
||
await b.key('ArrowDown'); await b.key('Enter'); await settle(50);
|
||
const insp = await b.evaluate(`!document.getElementById('inspector').hidden && document.querySelector('table.sessions tbody tr[aria-selected=true]')?.dataset.session`);
|
||
if (insp !== 's-01a07cfa') problems.push('arrow + enter did not inspect second row: ' + insp);
|
||
await b.key('Escape'); await settle(30);
|
||
await b.screenshot(join(out, 'd3-console-inspector-1440.png'));
|
||
}
|
||
if (d === 'd1-rail') {
|
||
await b.viewport(390, 844); await page(d);
|
||
await b.evaluate(`document.getElementById('menu-btn').click()`); await settle(250);
|
||
const opened = await b.evaluate(`document.body.classList.contains('rail-open') && document.getElementById('rail').contains(document.activeElement)`);
|
||
if (!opened) problems.push('phone menu did not open or focus the rail');
|
||
await b.screenshot(join(out, 'd1-rail-drawer-390.png'));
|
||
await b.key('Escape'); await settle(250);
|
||
if (await b.evaluate(`document.body.classList.contains('rail-open')`)) problems.push('Escape did not close the drawer');
|
||
await b.viewport(1440, 1000); await page(d);
|
||
await b.evaluate(`document.getElementById('rail-toggle').click()`); await settle(30);
|
||
const collapsed = await b.evaluate(`document.body.classList.contains('rail-collapsed') && document.querySelector('.rail .nav-link').textContent.trim()`);
|
||
if (!collapsed) problems.push('rail collapse lost accessible names');
|
||
await b.screenshot(join(out, 'd1-rail-collapsed-1440.png'));
|
||
await b.evaluate(`document.getElementById('rail-toggle').click(); localStorage.removeItem('mosaic-wui-mockup-v1')`);
|
||
}
|
||
if (d === 'd2-deck') {
|
||
await b.evaluate(`document.querySelector('details.menu').open = true`); await settle(30);
|
||
await b.key('Escape'); await settle(30);
|
||
if (await b.evaluate(`document.querySelector('details.menu').open`)) problems.push('Escape did not close the appearance menu');
|
||
}
|
||
if (d === 'd5-stream') {
|
||
await b.viewport(390, 844); await page(d);
|
||
const tabs = await b.evaluate(`[...document.querySelectorAll('.tabbar > ul > li > a')].length + '/' + getComputedStyle(document.querySelector('.tabbar')).display`);
|
||
if (!/^4\/block/.test(tabs)) problems.push('phone tab bar: ' + tabs);
|
||
await b.evaluate(`document.querySelector('details.more').open = true`); await settle(30);
|
||
const moreLinks = await b.evaluate(`[...document.querySelectorAll('.more-list a')].map(a => a.getAttribute('href')).join(',')`);
|
||
for (const need of ['#/projects', '#/agents', '#/runs', '#/library', '#/settings', '#/launch']) if (!moreLinks.includes(need)) problems.push('More menu missing ' + need);
|
||
await b.screenshot(join(out, 'd5-stream-more-390.png'));
|
||
await b.key('Escape'); await settle(30);
|
||
await b.viewport(1440, 1000);
|
||
}
|
||
record(`${d}: keyboard path (skip link, main focus, focus rings, Escape, design-specific keys)`, problems.length ? 'fail' : 'pass', problems.join(' | '));
|
||
}
|
||
|
||
// 6. Long content and text scaling: a 400-character goal and 200% zoom at 320 px do not overflow.
|
||
for (const d of designs) {
|
||
const problems = [];
|
||
await b.viewport(320, 700); await page(d);
|
||
await b.evaluate(`(() => { const D = window.MosaicData; D.sessions[1].goal.text = 'Reconcile the queue steering plan with MOSAIC-STACK-D-001 and produce a written comparison of every affected route, including the sessions list, the session view, and the launch form, then summarise what changes for observers versus controllers'.repeat(2); D.sessions[1].last = 'https://example.invalid/a/very/long/unbroken/path/segment/that/should/wrap/rather/than/overflow/the/column/0123456789'; window.MosaicApp.render(); })()`);
|
||
for (const r of ['#/', '#/sessions', '#/sessions/s-01a07cfa', '#/work']) { await go(r); const p = await probe(); if (p.sw > p.cw) problems.push(`${r} overflow at 320 with long text`); }
|
||
await b.evaluate(`document.documentElement.style.fontSize = '32px'`); await settle(30);
|
||
for (const r of ['#/', '#/sessions/s-01a07cfa', '#/settings']) { await go(r); const p = await probe(); if (p.sw > p.cw) problems.push(`${r} overflow at 320 with 200% text`); }
|
||
await go('#/'); await b.screenshot(join(out, `${d}-text-200-320.png`));
|
||
record(`${d}: long unbroken content and 200% text at 320 px stay within the viewport`, problems.length ? 'fail' : 'pass', problems.join(' | '));
|
||
}
|
||
|
||
// 7. Secrets and simulation labelling: no credential-shaped values anywhere in the rendered accounts table; every data-action button produces a labelled simulated toast.
|
||
{
|
||
const problems = [];
|
||
await b.viewport(1440, 1000); await page('d1-rail'); await go('#/settings');
|
||
const text = await b.evaluate(`document.getElementById('main').innerText`);
|
||
if (/sk-[a-z0-9]{8,}|Bearer |-----BEGIN/i.test(text)) problems.push('credential-shaped text on settings page');
|
||
if (!/not shown/.test(text)) problems.push('accounts table does not state credentials are not shown');
|
||
const count = await b.evaluate(`(() => { let n = 0, bad = []; for (const r of ['#/', '#/sessions/s-01a07cfa', '#/projects/mosaic-stack', '#/agents', '#/work', '#/runs', '#/library', '#/settings']) { location.hash = r; window.MosaicApp.render(); for (const btn of document.querySelectorAll('#main [data-action]:not(:disabled)')) { if (btn.dataset.action.startsWith('observe') || btn.dataset.action.startsWith('cancel') || btn.dataset.action === 'retry') continue; document.querySelector('#toasts').replaceChildren(); btn.click(); const t = document.querySelector('#toasts .toast')?.innerText || ''; n++; if (!/Simulated/.test(t)) bad.push(r + ' ' + btn.dataset.action); } } return JSON.stringify({ n, bad }); })()`).then(JSON.parse);
|
||
if (count.bad.length) problems.push('unlabelled actions: ' + count.bad.slice(0, 5).join(', '));
|
||
record(`secrets never rendered; ${count.n} action buttons across routes produce a labelled simulated notice`, problems.length ? 'fail' : 'pass', problems.join(' | '));
|
||
}
|
||
|
||
// 8. Reduced motion and forced colors media queries exist in the shared stylesheet (static check; visual verification of forced colors not available headless).
|
||
{
|
||
const css = await readFile(join(root, 'mockups', 'shared', 'app.css'), 'utf8');
|
||
const hasRM = /prefers-reduced-motion/.test(css), hasFC = /forced-colors/.test(css);
|
||
record('shared stylesheet declares reduced-motion and forced-colors rules', hasRM && hasFC ? 'pass' : 'fail', `reduced-motion ${hasRM}, forced-colors ${hasFC}`);
|
||
record('forced-colors rendering', 'not verified', 'headless Chromium does not emulate Windows high contrast here; rules exist but were not visually checked');
|
||
record('screen reader announcement behaviour', 'not verified', 'aria-live regions are present and updated on route change; no screen reader was run');
|
||
}
|
||
|
||
await b.close();
|
||
report.summary = { total: report.checks.length, failed: failures, notVerified: report.checks.filter(c => c.status === 'not verified').length };
|
||
await writeFile(join(out, 'report.json'), JSON.stringify(report, null, 2));
|
||
console.log(`\n${report.summary.total} checks, ${failures} failed, ${report.summary.notVerified} not verified. Report: evidence/mockups/report.json`);
|
||
process.exit(failures ? 1 : 0);
|