Files
stack/agents/dewey/work/wui/checks/slice1-verify.mjs
T
jason.woltjeandClaude Opus 5.5 1f4882d2b4 docs(wui): Dewey's design work: brand board, five mockups, slice 1 S5 views and checks
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]>
2026-10-09 17:54:50 -05:00

431 lines
37 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
// Verification for the slice 1 S5 mockup (mockups/slice1/). Run: node checks/slice1-verify.mjs
// Writes screenshots and a JSON report under evidence/slice1/. Uses the installed Chromium over CDP; no npm.
import { browser } from './browser.mjs';
import { mkdir, writeFile, readFile, mkdtemp, rm } from 'node:fs/promises';
import { execFileSync } from 'node:child_process';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { createHash } from 'node:crypto';
import { DatabaseSync } from 'node:sqlite';
const root = dirname(dirname(fileURLToPath(import.meta.url)));
const page = 'file://' + join(root, 'mockups', 'slice1', 'index.html');
const out = join(root, 'evidence', 'slice1');
await mkdir(out, { recursive: true });
const id = n => `0d1a${String(n).padStart(4, '0')}-5e7c-4b1a-9c3e-1f2a3b4c5d6e`;
const D = { launch: id(1), prd: id(2), prdOld: id(3), conflict: id(4), scope: id(5), merged: id(6) }; // as in mockups/slice1/data.js
const ref = n => `vikunja%3A3%2F${n}`;
const routes = ['#/', '#/inbox', '#/inbox?view=roles', '#/inbox?view=recent', `#/inbox/${D.launch}`, `#/inbox/${D.prd}`, `#/inbox/${D.merged}`, '#/tasks', `#/tasks/${ref(41)}`, `#/tasks/${ref(47)}`, `#/tasks/${ref(48)}`, '#/agents', `#/trail/task/${ref(41)}`, `#/trail/task/${ref(44)}?kind=decision`, `#/trail/decision/${D.prd}`, '#/does-not-exist', `#/inbox/${id(99)}`];
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 check = (name, problems, ok = '') => record(name, problems.length ? 'fail' : 'pass', problems.slice(0, 8).join(' | ') || ok);
const b = await browser();
// Count every way a page could send something. The mockup must never call any of them.
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: `window.__errs=[];window.__net=[];
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 f=window.fetch;window.fetch=(...a)=>{window.__net.push("fetch "+a[0]);return f(...a)};
const o=XMLHttpRequest.prototype.open;XMLHttpRequest.prototype.open=function(...a){window.__net.push("xhr "+a[1]);return o.apply(this,a)};
navigator.sendBeacon=(...a)=>{window.__net.push("beacon "+a[0]);return false};
const W=window.WebSocket;window.WebSocket=function(u){window.__net.push("ws "+u);return new W(u)};
window.__clip=[];` });
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(50); };
const load = async (query = '') => { await b.navigate(page + query); await settle(60); };
const probe = () => b.evaluate(`JSON.stringify({errs:window.__errs, net:window.__net, sw:document.documentElement.scrollWidth, cw:document.documentElement.clientWidth, main:document.getElementById('main').innerText.length, h1:document.querySelector('#main h1')?.textContent||''})`).then(JSON.parse);
const text = () => b.evaluate(`document.getElementById('main').innerText`);
const shot = name => b.screenshot(join(out, name + '.png'));
// 1. Every route at every width: renders, no script errors, no horizontal overflow, nothing sent.
{
const problems = [];
for (const w of widths) {
await b.viewport(w, w < 700 ? 844 : 1000); await load();
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.net.length) problems.push(`${w} ${r} sent: ${p.net.join('; ')}`);
if (p.sw > p.cw) problems.push(`${w} ${r} overflow ${p.sw}>${p.cw}`);
if (p.main < 40) problems.push(`${w} ${r} empty main`);
if ((r === '#/does-not-exist' || r.endsWith(id(99))) && !/not found/i.test(p.h1)) problems.push(`${w} ${r} not-found page missing`);
const name = r.slice(2).replace(/[^a-z0-9]+/gi, '-').replace(/-+$/, '') || 'board';
if (w === 1440 && !r.includes('does-not') && !r.endsWith(id(99))) await shot(`${name.slice(0, 60)}-1440`);
if (w === 390 && ['#/inbox', `#/inbox/${D.launch}`, '#/tasks', '#/agents', `#/trail/task/${ref(41)}`].includes(r)) await shot(`${name.slice(0, 60)}-390`);
}
}
check(`${routes.length} routes × ${widths.length} widths render without script errors, network calls or horizontal overflow`, problems);
}
// 2. Palettes × modes: tokens apply and sampled text/UI pairs meet WCAG contrast on each view.
await b.viewport(1440, 1000);
{
await load();
const palettes = await b.evaluate('window.Brand.palettes.map(p=>p.id)');
const contrast = `(() => {
const parse = c => { const m = c.match(/[\\d.]+/g).map(Number); return c.startsWith('color(') ? [m[0] * 255, m[1] * 255, m[2] * 255, m.length > 3 ? m[3] : 1] : [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 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; 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 r = layers.pop(); while (layers.length) r = blend(layers.pop(), r); return r; };
const sel = ['#main h1', '#status', '.muted', '.source', '.badge-ok', '.badge-attn', '.badge-danger', '.badge-muted', '.btn', '.chip[aria-current=true]', '.chip:not([aria-current])', '.count', 'th', 'code', '.s1-dec-open', '.s1-section[aria-current=page]', '.s1-section:not([aria-current])', '.s1-tkind', '.s1-twhen', '.feat', '#main a', '.s1-key', '.tree-title'];
const out = [];
for (const s of sel) { const el = document.querySelector(s); if (!el) continue; const cs = getComputedStyle(el); const size = parseFloat(cs.fontSize); const large = size >= 24 || (size >= 18.66 && parseInt(cs.fontWeight) >= 700); const r = ratio(cs.color, bgFlat(el)); out.push({ s, r: +r.toFixed(2), need: large ? 3 : 4.5, ok: r >= (large ? 3 : 4.5) }); }
return out;
})()`;
const views = ['#/inbox', `#/inbox/${D.prd}`, '#/tasks', '#/agents', `#/trail/task/${ref(41)}`];
const fails = [], all = [];
for (const p of palettes) for (const m of modes) {
await load(`?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})`);
for (const v of views) {
await go(v);
for (const r of await b.evaluate(contrast)) { all.push({ p, m, v, ...r }); if (!r.ok) fails.push(`${p}/${m} ${v} ${r.s} ${r.r}:1 < ${r.need}`); }
}
if ((p === 'harbor' && m !== 'light') || (p === 'grove' && m === 'dark')) { await go('#/inbox'); await shot(`palette-${p}-${m}-inbox`); }
}
all.sort((a, b) => a.r - b.r); report.contrastLowest = all.slice(0, 12);
check(`${palettes.length} palettes × 3 modes apply; ${all.length} sampled text/UI pairs across 5 views meet WCAG contrast`, fails, `lowest ${all[0].r}:1 (${all[0].p}/${all[0].m} ${all[0].v} ${all[0].s})`);
}
// 3. Agent-authored text is inert: no element or handler from it, controls and bidi marks visible, Markdown as source.
{
const problems = [];
await load();
for (const r of ['#/inbox', `#/inbox/${D.prd}`, `#/trail/decision/${D.prd}`]) {
await go(r);
const st = await b.evaluate(`JSON.stringify({ img: document.querySelectorAll('#main img').length, script: document.querySelectorAll('#main script').length, pwned: window.__pwned || 0, onerr: !!document.querySelector('#main [onerror]') })`).then(JSON.parse);
if (st.img || st.script || st.pwned || st.onerr) problems.push(`${r} created markup from text: ${JSON.stringify(st)}`);
}
await go(`#/inbox/${D.prd}`);
const t = await text();
for (const need of ['<img src=x onerror="window.__pwned=1">', '<script>window.__pwned=2</script>', '[U+202E]exe.txt[U+202C]', '␇', '**"every 30 seconds"**']) if (!t.includes(need)) problems.push(`decision text lacks ${JSON.stringify(need)}`);
if (/‮|\u0007/.test(t)) problems.push('raw bidi or bell character reached the page');
await go('#/tasks');
if (!(await text()).includes('#43 `mosaic inbox` and `mosaic decide`')) problems.push('task title Markdown not shown as source');
check('agent- and tracker-authored text renders inert (no markup, visible controls and bidi marks, Markdown as source)', problems);
}
// 4. Read-only: no form or text field, the only buttons are Copy and Read again, Copy writes only the
// clipboard with the exact command, and nothing is sent anywhere.
{
const problems = [];
await load();
await b.evaluate(`navigator.clipboard.writeText = t => { window.__clip.push(t); return Promise.resolve(); }`);
let copies = 0;
for (const r of routes) {
await go(r);
const st = await b.evaluate(`JSON.stringify({ forms: document.querySelectorAll('form').length, fields: document.querySelectorAll('#main input, #main textarea, #main select').length, buttons: [...document.querySelectorAll('#main button')].filter(x => !x.hasAttribute('data-copy') && !x.hasAttribute('data-retry')).map(x => x.textContent), post: document.documentElement.outerHTML.match(/method=.?post/i) ? 1 : 0 })`).then(JSON.parse);
if (st.forms || st.fields || st.buttons.length || st.post) problems.push(`${r}: ${JSON.stringify(st)}`);
}
for (const d of [D.launch, D.prd]) {
await go(`#/inbox/${d}`);
const cmds = await b.evaluate(`[...document.querySelectorAll('[data-copy]')].map(x => x.dataset.copy)`);
const keys = await b.evaluate(`window.S1App.reads().decision(${JSON.stringify(d)}).options.map(o => o.key)`);
if (JSON.stringify(cmds) !== JSON.stringify(keys.map(k => `mosaic decide ${d} ${k}`))) problems.push(`${d} commands ${JSON.stringify(cmds)}`);
await b.evaluate(`window.__clip = []; [...document.querySelectorAll('[data-copy]')].forEach(x => x.click())`); await settle(30);
const clip = await b.evaluate('window.__clip');
if (JSON.stringify(clip) !== JSON.stringify(cmds)) problems.push(`${d} clipboard got ${JSON.stringify(clip)}`);
copies += clip.length;
if (!/Copied: mosaic decide/.test(await b.evaluate(`document.getElementById('copy-status').textContent`))) problems.push('copy status not shown');
}
// Clipboard refused: the command is selected instead, and the page says so.
await b.evaluate(`navigator.clipboard.writeText = () => Promise.reject(new Error('denied'))`);
await b.evaluate(`document.querySelector('[data-copy]').click()`); await settle(30);
const fb = await b.evaluate(`JSON.stringify({ sel: String(getSelection()), msg: document.getElementById('copy-status').textContent })`).then(JSON.parse);
if (!fb.sel.startsWith('mosaic decide ') || !/press Ctrl\+C/.test(fb.msg)) problems.push('clipboard fallback: ' + JSON.stringify(fb));
// Closed decisions show the outcome, no commands.
for (const d of [D.merged, D.prdOld]) { await go(`#/inbox/${d}`); if (await b.evaluate(`document.querySelectorAll('[data-copy]').length`)) problems.push(`closed decision ${d} still offers commands`); }
const net = await b.evaluate('window.__net');
if (net.length) problems.push('sent: ' + net.join('; '));
const src = (await readFile(join(root, 'mockups', 'slice1', 'app.js'), 'utf8')) + (await readFile(join(root, 'mockups', 'slice1', 'index.html'), 'utf8'));
for (const bad of ['fetch(', 'XMLHttpRequest', 'sendBeacon', 'WebSocket', '<form', 'localStorage.setItem']) if (src.includes(bad)) problems.push(`source contains ${bad}`);
check(`read-only: no forms, fields or write buttons on ${routes.length} routes; ${copies} Copy clicks wrote only the exact command to the clipboard; fallback selects it; closed decisions offer none; nothing sent`, problems);
}
// 5. Inbox rules from the data model: open human-routed first by blocking then oldest; closed ones only in Recent;
// cross-role ones grouped by arbiter; the supersedes chain and DM state show; the approval context the broker
// recorded at decision.raise shows on cards and options, never inferred from the recommendation.
{
const problems = [];
await load(); await go('#/inbox');
const order = await b.evaluate(`[...document.querySelectorAll('.s1-dec-open')].map(a => decodeURIComponent(a.getAttribute('href').split('/').pop()))`);
if (JSON.stringify(order) !== JSON.stringify([D.launch, D.prd])) problems.push('For you order ' + JSON.stringify(order));
if (await b.evaluate(`!document.querySelector('.s1-dec.is-attn .badge-attn') || !/DM sent/.test(document.querySelector('.s1-dec.is-attn').innerText)`)) problems.push('blocking card lacks badge or DM state');
const nav = await b.evaluate(`document.querySelector('.s1-section[aria-current=page]').innerText`);
if (!/Inbox\s*2/.test(nav) || !/1 blocking/.test(nav)) problems.push('section count: ' + nav);
await go('#/inbox?view=roles');
const roles = await b.evaluate(`[...document.querySelectorAll('#main h2')].map(h => h.textContent.replace(/\\s+/g, ' ').trim())`);
if (JSON.stringify(roles) !== JSON.stringify(['For pm 1', 'For cto 1'])) problems.push('roles groups ' + JSON.stringify(roles));
await go('#/inbox?view=recent');
const recent = await text();
if (!/chose “yes”/.test(recent) || !/withdrawn/.test(recent)) problems.push('Recent lacks the resolved or the withdrawn decision');
await go(`#/inbox/${D.prd}`);
const det = await text();
if (!/Earlier versions/.test(det) || !det.includes(D.prdOld.slice(0, 8)) || !/withdrawn/.test(det)) problems.push('supersedes chain missing');
if (!/not needed \(not blocking\)/.test(det)) problems.push('DM line for non-blocking decision');
await go('#/inbox');
const auth = await b.evaluate(`[...document.querySelectorAll('.s1-dec .s1-auth')].map(p => p.textContent)`);
if (JSON.stringify(auth) !== JSON.stringify(['Choosing “yes” approves role.launch on coder.', 'Choosing “a” approves prd.approve on REQ-TASK-2.'])) problems.push('card approval context ' + JSON.stringify(auth));
// #48's conflict: the recommendation is "queue", the recorded approvalChoice is "vikunja".
await go(`#/inbox/${D.conflict}`);
const opts = await b.evaluate(`JSON.stringify([...document.querySelectorAll('.s1-opt')].map(o => [o.querySelector('.s1-key').textContent, [...o.querySelectorAll('.badge')].map(x => x.textContent)]))`).then(JSON.parse);
if (JSON.stringify(opts) !== JSON.stringify([['queue', ['recommended']], ['vikunja', ['approves task.close']]])) problems.push('approval badge does not follow the recorded approvalChoice: ' + JSON.stringify(opts));
if (!/Choosing “vikunja” approves task\.close on vikunja:3\/48\./.test(await text())) problems.push('decision page lacks the approval line');
for (const v of ['roles', 'recent']) { await go(`#/inbox?view=${v}`); if (!/not in the Q1 module/.test(await text())) problems.push(`${v} view does not say its source is outside the Q1 module`); }
await go(`#/inbox/${D.launch}`);
if (!/Seen by jason via cli/.test(await text())) problems.push('seen history missing');
check('inbox: blocking then oldest, closed only in Recent, roles grouped by arbiter, DM state, seen history, supersedes chain, recorded approval context on cards and options, non-Q1 views labelled', problems);
}
// 6. Tasks: bucket groups from the latest snapshot; markers for external change, never-self, conflict and missing; freshness line.
{
const problems = [];
await load(); await go('#/tasks');
const groups = await b.evaluate(`JSON.stringify(window.S1App.reads().tasks().groups.map(g => [g.bucket, g.rows.map(r => r.index)]))`).then(JSON.parse);
const want = [['todo', [46]], ['in-progress', [42, 43]], ['in-review', [41]], ['blocked', [44]], ['done', [48, 45]]];
if (JSON.stringify(groups) !== JSON.stringify(want)) problems.push('groups ' + JSON.stringify(groups));
const t = await text();
for (const need of ['not created by the stack', 'changed in Vikunja: bucket', 'missing: not-found', 'not built yet']) if (!t.includes(need)) problems.push('missing marker text: ' + need);
if (await b.evaluate(`document.querySelector('details.s1-done').open`)) problems.push('done group open by default');
await b.evaluate(`document.querySelector('details.s1-done').open = true`);
const conflict = await b.evaluate(`document.querySelector('a.s1-marker')?.getAttribute('href')`);
if (conflict !== `#/inbox/${D.conflict}`) problems.push('conflict marker link ' + conflict);
// #44 has only self snapshots: no marker. #42's latest row is a stale board read: not external (the v3
// read_at rule), and not shown either, so #42 stays in in-progress (checked by the groups above).
for (const n of [44, 42]) { const x = await b.evaluate(`JSON.stringify(window.S1App.reads().task('vikunja:3/${n}').external)`); if (x !== 'null') problems.push(`#${n} marked external: ${x}`); }
await go(`#/tasks/${ref(42)}`);
if (!/stale read, not shown/.test(await b.evaluate(`document.querySelector('.s1-snaps').textContent`))) problems.push('#42 snapshot list does not label the stale read');
await go('#/tasks');
await go(`#/tasks/${ref(48)}`);
const t48 = await text();
if (!/queue says in-review, Vikunja says done/.test(t48)) problems.push('#48 detail lacks conflict');
if (await b.evaluate(`document.querySelector('a[target=_blank]')?.getAttribute('href')`) !== 'http://127.0.0.1:3456/tasks/48') problems.push('Open in Vikunja link');
check('tasks: groups by bucket from the latest snapshot, markers (changed in Vikunja, not created by the stack, conflict, missing), done collapsed, freshness line, Vikunja link', problems);
}
// 7. Agents: limits, launch state, holders, advisory T3 claims, released role, credential notices without values.
{
const problems = [];
await load(); await go('#/agents');
const t = await text();
for (const need of ['Opus 2 of 4, Sonnet 1 of 4', 'launching allowed', 'advisory', 'expires 2026-10-13', 'pm may launch coder and reviewer', 'not in the Q1 module']) if (!t.includes(need)) problems.push('missing: ' + need);
// The module returns current claims only, so the released reviewer shows as not held.
const rev = await b.evaluate(`[...document.querySelectorAll('.s1-agents tbody tr')].find(r => r.querySelector('th').firstChild.textContent === 'reviewer')?.innerText || ''`);
if (!/not held/.test(rev)) problems.push('released reviewer: ' + rev);
const advisory = await b.evaluate(`[...document.querySelectorAll('.s1-agents tbody tr')].filter(r => r.querySelector('.badge-muted')?.textContent === 'advisory').map(r => r.querySelector('th').firstChild.textContent)`);
if (JSON.stringify(advisory) !== '["cto"]') problems.push('advisory on ' + JSON.stringify(advisory));
if (/\.token|secrets\/|Bearer |-----BEGIN|[A-Za-z0-9]{32,}/.test(t)) problems.push('credential-shaped text on the Agents page');
check('agents: live counts against limits, launch state, holders, advisory only for the T3 claim, released role not held, credential dates without values or paths, non-Q1 sources labelled', problems);
}
// 8. Trail: oldest first, filters partition the rows, the decision trail includes earlier versions, the Q3 request join labelled.
{
const problems = [];
await load(); await go(`#/trail/task/${ref(41)}`);
const times = await b.evaluate(`[...document.querySelectorAll('.s1-trail time')].map(t => t.getAttribute('datetime'))`);
if (times.join() !== [...times].sort().join()) problems.push('not oldest first');
const counts = await b.evaluate(`[...document.querySelectorAll('#main .chips .chip')].map(c => +c.querySelector('.count').textContent)`);
if (counts[0] !== counts.slice(1).reduce((a, c) => a + c, 0) + await b.evaluate(`window.S1App.reads().trail('task','vikunja:3/41').filter(r => r.group === 'other').length`)) problems.push('filter counts do not add up: ' + counts);
const kinds = await b.evaluate(`[...document.querySelectorAll('.s1-tkind')].map(k => k.textContent)`);
for (const k of ['human.input', 'task.created', 'decision.raised', 'decision.resolved', 'session.launched', 'review.verdict']) if (!kinds.includes(k)) problems.push('task trail lacks ' + k);
if (!/the request task\.created names/.test(await text())) problems.push('Q3 request join not labelled');
// The request has no task subject; task.created names it (schema v3a onward). Only that request joins.
const t41 = await text();
if (!t41.includes('Build the bus store next.')) problems.push('#41 trail lacks the request its task.created names');
if (t41.includes('Write the identity runbook first.')) problems.push('#41 trail shows another task\'s request');
await go(`#/trail/task/${ref(43)}`);
if (!(await text()).includes('Then the read verbs')) problems.push('#43 trail lacks its shared request');
await go(`#/trail/task/${ref(41)}?kind=launch`);
const launchKinds = await b.evaluate(`[...new Set([...document.querySelectorAll('.s1-tkind')].map(k => k.textContent))]`);
if (launchKinds.some(k => !k.startsWith('session.') && !k.startsWith('launch.') && !k.startsWith('claim.'))) problems.push('launch filter shows ' + launchKinds);
await go(`#/trail/decision/${D.prd}`);
if (!/decision\.withdrawn/.test(await text())) problems.push('decision trail lacks the withdrawn earlier version');
// The decision trail merges one read per version, so it sorts again; check that order too.
const dTimes = await b.evaluate(`[...document.querySelectorAll('.s1-trail time')].map(t => t.getAttribute('datetime'))`);
if (dTimes.length < 2 || dTimes.join() !== [...dTimes].sort().join()) problems.push('decision trail not oldest first');
check('trail: oldest first, filters partition rows, task trail spans request to verdict, decision trail includes earlier versions, Q3 request join labelled', problems);
}
// 9. States: loading, empty, failed read (keeps data, warns, recovers), broker refusal (shows no data).
{
const problems = [];
await load('?state=loading'); if (!/Reading from the broker/.test(await text())) problems.push('loading');
await load('?state=empty');
const empties = { '#/inbox': /Nothing is waiting on you/, '#/inbox?view=roles': /No open decisions between roles/, '#/inbox?view=recent': /No decisions closed yet/, '#/tasks': /No tasks in this business yet/, '#/agents': /not held/ };
for (const [r, re] of Object.entries(empties)) { await go(r); if (!re.test(await text())) problems.push('empty ' + r); }
await load('?state=error'); await go('#/tasks');
const err = await text();
if (!/last read failed/.test(err) || !/Nothing was changed/.test(err) || !/#41 Bus store/.test(err)) problems.push('failed read does not keep data with a warning');
await shot('state-error-tasks-1440');
await b.evaluate(`document.querySelector('[data-retry]').click()`); await settle(40);
if (/last read failed/.test(await text())) problems.push('Read again did not recover');
await load('?state=refused'); await go('#/inbox');
const ref_ = await text();
if (!/refused the read/.test(ref_) || /Launch a second coder/.test(ref_) || await b.evaluate(`document.querySelectorAll('#main table, #main .s1-dec').length`)) problems.push('refusal shows data or lacks reason');
await shot('state-refused-1440');
check('states: loading, empty for every view, failed read keeps data and recovers, broker refusal shows no data', problems);
}
// 10. Keyboard: skip link first, Enter focuses main, navigation moves focus to the new h1, focus rings on controls.
{
const problems = [];
await load('#/inbox');
await b.key('Tab');
if (!/skip/.test(await b.evaluate(`document.activeElement.className`))) problems.push('first Tab is not the skip link');
await b.key('Enter'); await settle(30);
if (await b.evaluate(`document.activeElement.id`) !== 'main') problems.push('skip link did not focus main');
// The route must stay in the URL, so a reload or bookmark after skipping still opens this view.
if (await b.evaluate(`location.hash`) !== '#/inbox') problems.push('skip link replaced the route in the URL: ' + await b.evaluate(`location.hash`));
await b.key('Tab');
if (!await b.evaluate(`document.getElementById('main').contains(document.activeElement)`)) problems.push('Tab after skip did not enter main');
await b.evaluate(`document.querySelector('.s1-dec-open').focus()`); await b.key('Enter'); await settle(60);
const after = await b.evaluate(`document.activeElement.tagName + ' ' + location.hash`);
if (!after.startsWith('H1 #/inbox/')) problems.push('Enter on a decision did not move focus to its h1: ' + after);
await b.key('Tab'); await b.key('Tab');
const ring = await b.evaluate(`(() => { const el = document.activeElement; const cs = getComputedStyle(el); return el.matches(':focus-visible') ? cs.outlineStyle + ' ' + cs.outlineWidth : 'no focus-visible on ' + el.tagName; })()`);
if (!/solid|auto/.test(ring) || parseFloat(ring.split(' ')[1]) < 2) problems.push('focus ring: ' + ring);
check('keyboard: skip link keeps the route, main focus, focus moves to the new page heading, visible focus rings', problems);
}
// 15. The fixture is valid schema v3b data, and the schema's own views agree with what the page shows.
// Every row the page reads goes into the real schema, in time order, so its CHECKs and triggers judge
// the fixture. Then urgent_inbox, task_external_changes, task_current and tasks_open are compared with
// reads(). Current task state comes from task_current (lead decisions 59 and 60), never the raw latest
// row; the page must mark as stale exactly the rows task_current skipped.
{
const problems = [];
const schemaPath = join(root, '..', '..', '..', 'darkwing', 'work', 'slice1-proto', 'schema-v3b.sql');
const schema = await readFile(schemaPath, 'utf8');
const sha = createHash('sha256').update(schema).digest('hex');
if (!sha.startsWith('179ffe35')) problems.push('schema-v3b.sql changed: ' + sha.slice(0, 8));
await load();
const S = JSON.parse(await b.evaluate(`JSON.stringify(window.S1)`));
const db = new DatabaseSync(':memory:');
db.exec(schema);
const order = ['decisions', 'messages', 'deliveries', 'decision_events', 'role_claims', 'events', 'task_snapshots'];
const cols = Object.fromEntries(order.map(t => [t, db.prepare(`PRAGMA table_info(${t})`).all().map(c => c.name).filter(c => c !== 'seq')]));
const rows = order.flatMap((t, ti) => S[t].map(r => ({ t, ti, r }))).sort((a, b) => a.r.at.localeCompare(b.r.at) || a.ti - b.ti);
for (const { t, r } of rows) {
const extra = Object.keys(r).filter(k => k !== 'seq' && k !== 'proposedJoin' && !cols[t].includes(k));
if (extra.length) problems.push(`${t} row has columns the schema lacks: ${extra}`);
try { db.prepare(`INSERT INTO ${t} (${cols[t].join(',')}) VALUES (${cols[t].map(() => '?').join(',')})`).run(...cols[t].map(c => r[c] ?? null)); }
catch (e) { problems.push(`${t} ${r.id || r.task_ref || r.role || r.decision || ''} at ${r.at}: ${e.message}`); }
}
// Inserting in time order assigns seq; the fixture's own seq must agree with it.
for (const t of order) {
const key = { decisions: 'id', messages: 'id', events: 'id' }[t];
const db_ = db.prepare(`SELECT ${key || 'at'} AS k FROM ${t} ORDER BY seq`).all().map(x => x.k);
const fx = [...S[t]].sort((a, b) => a.seq - b.seq).map(x => key ? x[key] : x.at);
if (db_.join() !== fx.join()) problems.push(`${t}: fixture seq order differs from time order`);
}
const R = JSON.parse(await b.evaluate(`JSON.stringify((() => { const r = window.S1App.reads(); const ts = r.tasks().groups.flatMap(g => g.rows); return {
blocking: r.inbox().human.filter(d => d.blocking && !d.closed).map(d => d.id).sort(),
external: ts.filter(t => t.external).map(t => t.ref).sort(),
missing: r.tasks().missing.map(t => t.ref),
open: ts.filter(t => !t.done).map(t => t.ref + ' ' + t.bucket).sort(),
current: [...ts, ...r.tasks().missing].map(t => { const d = r.task(t.ref); return { ref: t.ref, bucket: d.missing ? 'gone' : d.bucket, stale: d.snapshots.filter(x => x.stale).map(x => x.at) }; }) }; })())`));
const urgent = db.prepare('SELECT id FROM urgent_inbox ORDER BY id').all().map(x => x.id);
if (urgent.join() !== R.blocking.join()) problems.push(`urgent_inbox ${urgent} vs page ${R.blocking}`);
// The view also lists a tombstone (its latest row is a poll); the page shows that task as missing instead.
const ext = db.prepare('SELECT task_ref FROM task_external_changes ORDER BY task_ref').all().map(x => x.task_ref).filter(r => !R.missing.includes(r));
if (ext.join() !== R.external.join()) problems.push(`task_external_changes ${ext} vs page ${R.external}`);
const open = db.prepare('SELECT task_ref, bucket FROM tasks_open').all().map(x => `${x.task_ref} ${S.lookups.buckets[x.bucket]}`).sort();
if (open.join() !== R.open.join()) problems.push(`tasks_open ${JSON.stringify(open)} vs page ${JSON.stringify(R.open)}`);
const page = Object.fromEntries(R.current.map(t => [t.ref, t]));
const cur = db.prepare('SELECT task_ref, seq, fields FROM task_current ORDER BY task_ref').all();
if (cur.map(c => c.task_ref).join() !== Object.keys(page).sort().join()) problems.push(`task_current refs ${cur.map(c => c.task_ref)} vs page ${Object.keys(page).sort()}`);
for (const c of cur) {
const f = JSON.parse(c.fields), p = page[c.task_ref];
if (!p) continue;
const bucket = f.gone ? 'gone' : S.lookups.buckets[f.bucket];
if (bucket !== p.bucket) problems.push(`${c.task_ref}: task_current says ${bucket}, page says ${p.bucket}`);
const skipped = db.prepare('SELECT at FROM task_snapshots WHERE task_ref = ? AND seq > ? ORDER BY seq').all(c.task_ref, c.seq).map(x => x.at);
if (skipped.join() !== p.stale.join()) problems.push(`${c.task_ref}: task_current skipped ${skipped}, page marks stale ${p.stale}`);
}
const marked = R.current.filter(t => t.stale.length).map(t => t.ref);
if (marked.join() !== 'vikunja:3/42') problems.push(`stale reads marked on ${marked}, expected only vikunja:3/42`);
db.close();
check('fixture: every row inserts into schema v3b (179ffe35) in time order; urgent_inbox, task_external_changes, task_current and tasks_open match the page; stale marks are exactly the rows task_current skips (#42 only)', problems, `${rows.length} rows`);
}
// 16. The Q1 stand-in returns what the real broker returns. q1.js ports the four reader verbs; this
// runs the reader from packages/bus as committed in BUS_COMMIT (S2c, row 44) over the same rows in a
// throwaway data root, and compares inbox, tasks, agents and the trail of every task, decision and
// message. The package is extracted from the commit with git archive, so working-tree edits don't count.
// Interim: Rocko's pinned example fixture replaces it (lead decision 56, Q1). The repository is only read.
// DEWEY_S1_REPO points at the repository when the check runs from a copy outside it (mutation scratch).
const BUS_COMMIT = 'd27042fa';
{
const repo = process.env.DEWEY_S1_REPO || join(root, '..', '..', '..', '..');
let tree = null;
try { tree = execFileSync('git', ['-C', repo, 'archive', '--format=tar', BUS_COMMIT, 'packages/bus/package.json', 'packages/bus/schema.sql', 'packages/bus/src'], { maxBuffer: 1 << 26 }); } catch { /* recorded below */ }
if (!tree) record('the Q1 reader at the S2 commit', 'not verified', `git archive ${BUS_COMMIT} failed in ${repo}`);
else {
const problems = [];
const dir = await mkdtemp(join(tmpdir(), 'dewey-s1-bus-')), code = join(dir, 'code'), data = join(dir, 'data');
let store, count = 0, pins = '', subjects = [];
try {
await mkdir(code, { mode: 0o700 }); await mkdir(data, { mode: 0o700 });
execFileSync('tar', ['-x', '-C', code], { input: tree });
const bus = join(code, 'packages', 'bus');
const pin = async f => createHash('sha256').update(await readFile(join(bus, f))).digest('hex').slice(0, 8);
pins = `commit ${BUS_COMMIT}: broker.mjs ${await pin('src/broker.mjs')}, store.mjs ${await pin('src/store.mjs')}, schema.sql ${await pin('schema.sql')}`;
await load();
const S = JSON.parse(await b.evaluate(`JSON.stringify(window.S1)`));
subjects = [...new Set([...S.task_snapshots.map(s => s.task_ref), ...S.decisions.map(d => d.id), ...S.messages.map(m => m.id)])];
const { Store } = await import(pathToFileURL(join(bus, 'src', 'store.mjs')));
const { Broker } = await import(pathToFileURL(join(bus, 'src', 'broker.mjs')));
store = new Store(data);
const order = ['decisions', 'messages', 'deliveries', 'decision_events', 'role_claims', 'events', 'task_snapshots'];
const cols = Object.fromEntries(order.map(t => [t, store.all(`PRAGMA table_info(${t})`).map(c => c.name).filter(c => c !== 'seq')]));
const rows = order.flatMap((t, ti) => S[t].map(r => ({ t, ti, r }))).sort((a, b) => a.r.at.localeCompare(b.r.at) || a.ti - b.ti);
store.transaction(() => { for (const { t, r } of rows) store.run(`INSERT INTO ${t} (${cols[t].join(',')}) VALUES (${cols[t].map(() => '?').join(',')})`, ...cols[t].map(c => r[c] ?? null)); });
const role = { authority: { withinRole: [], crossRole: [] } }, B = S.lookups.business;
const broker = new Broker({ store, businesses: { [B]: { id: B, human: S.lookups.human, roles: { pm: role, cto: role, coder: role, reviewer: role }, arbiters: { technical: 'cto', delivery: 'pm' } } } });
const cap = broker.bindReader({ business: B });
const want = { inbox: broker.request(cap, { verb: 'inbox' }), tasks: broker.request(cap, { verb: 'tasks' }), agents: broker.request(cap, { verb: 'agents' }) };
for (const x of subjects) want[`trail ${x}`] = broker.request(cap, { verb: 'trail', args: { subject: x } });
const got = JSON.parse(await b.evaluate(`JSON.stringify((() => { const q = S1Q1.views(window.S1), out = { inbox: q.inbox(), tasks: q.tasks(), agents: q.agents() }; for (const x of ${JSON.stringify(subjects)}) out['trail ' + x] = q.trail(x); return out; })())`));
// Key order and absent-versus-null columns don't count; values and row order do.
const canon = v => JSON.stringify(v, (k, x) => x && typeof x === 'object' && !Array.isArray(x) ? Object.fromEntries(Object.keys(x).sort().filter(n => x[n] !== null && x[n] !== undefined).map(n => [n, x[n]])) : x);
for (const [name, a] of Object.entries(want)) {
const g = got[name] || []; count += a.length;
if (canon(a) === canon(g)) continue;
const i = Math.max(0, a.findIndex((x, j) => canon(x) !== canon(g[j])));
problems.push(`${name}: candidate ${a.length} rows, stand-in ${g.length}; first difference at ${i}: ${canon(a[i]).slice(0, 200)} vs ${canon(g[i]).slice(0, 200)}`);
}
if (!want.inbox.length || !want.tasks.length || !want.agents.length) problems.push('a candidate read came back empty');
} catch (e) { problems.push(`candidate reader failed: ${e.code || ''} ${e.message}`); }
finally { store?.close(); await rm(dir, { recursive: true, force: true }); }
check(`Q1 stand-in: q1.js returns what the packages/bus reader returns (${pins}) for inbox, tasks, agents and ${subjects.length} trails`, problems, `${count} rows compared`);
}
}
// 11. Long content and 200% text at 320 px stay within the viewport.
{
const problems = [];
await b.viewport(320, 700); await load();
await b.evaluate(`(() => { const d = window.S1.decisions.find(x => x.id === ${JSON.stringify(D.launch)}); d.question = 'Launch a second coder instance so the poller can move '.repeat(6) + 'https://example.invalid/a/very/long/unbroken/path/segment/that/must/wrap/0123456789abcdef0123456789abcdef'; window.S1App.render(); })()`);
for (const r of ['#/inbox', `#/inbox/${D.launch}`, `#/trail/task/${ref(44)}`]) { await go(r); const p = await probe(); if (p.sw > p.cw) problems.push(`${r} overflow with long text`); }
await b.evaluate(`document.documentElement.style.fontSize = '30px'`); await settle(30);
for (const r of ['#/inbox', `#/inbox/${D.launch}`, '#/tasks', '#/agents', `#/trail/task/${ref(41)}`]) { await go(r); const p = await probe(); if (p.sw > p.cw) problems.push(`${r} overflow at 200% text`); }
await go(`#/inbox/${D.launch}`); await shot('text-200-decision-320');
check('long unbroken content and 200% text at 320 px stay within the viewport (tables scroll inside their region)', problems);
}
record('forced-colors rendering', 'not verified', 'slice1.css adds forced-colors rules; headless Chromium here does not emulate them');
record('screen reader behaviour', 'not verified', 'focus moves to the page heading and #announce is updated; 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/slice1/report.json`);
process.exit(failures ? 1 : 0);