create-cmp-cli 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/package.json +6 -2
  2. package/packages/harness/package.json +38 -0
  3. package/packages/harness/src/approve.mjs +247 -0
  4. package/packages/harness/src/arch-doc.mjs +69 -0
  5. package/packages/harness/src/comment.mjs +76 -0
  6. package/packages/harness/src/lib/a11y.mjs +113 -0
  7. package/packages/harness/src/lib/affected-tests.mjs +147 -0
  8. package/packages/harness/src/lib/approvals.mjs +1403 -0
  9. package/packages/harness/src/lib/arch-doc.mjs +451 -0
  10. package/packages/harness/src/lib/audit-cadence.mjs +290 -0
  11. package/packages/harness/src/lib/comments.mjs +252 -0
  12. package/packages/harness/src/lib/component-stories.mjs +183 -0
  13. package/packages/harness/src/lib/determinism.mjs +179 -0
  14. package/packages/harness/src/lib/device-lease.mjs +249 -0
  15. package/packages/harness/src/lib/evidence-badge.mjs +158 -0
  16. package/packages/harness/src/lib/evidence-level.mjs +117 -0
  17. package/packages/harness/src/lib/feature-brief.mjs +324 -0
  18. package/packages/harness/src/lib/flight-recorder.mjs +332 -0
  19. package/packages/harness/src/lib/harness-lock.mjs +147 -0
  20. package/packages/harness/src/lib/harness-region.mjs +159 -0
  21. package/packages/harness/src/lib/inputs-hash.mjs +194 -0
  22. package/packages/harness/src/lib/reachability.mjs +211 -0
  23. package/packages/harness/src/lib/receipt-validate.mjs +234 -0
  24. package/packages/harness/src/lib/render.mjs +254 -0
  25. package/packages/harness/src/lib/spec-coverage.mjs +131 -0
  26. package/packages/harness/src/lib/step-cache.mjs +221 -0
  27. package/packages/harness/src/lib/token-drift.mjs +94 -0
  28. package/packages/harness/src/lib/tree.mjs +108 -0
  29. package/packages/harness/src/preview-gallery.mjs +122 -0
  30. package/packages/harness/src/receipt-check.mjs +96 -0
  31. package/packages/harness/src/record-audit.mjs +83 -0
  32. package/packages/harness/src/refusal-demo.mjs +498 -0
  33. package/packages/harness/src/retrospective.mjs +51 -0
  34. package/packages/harness/src/scaffold-feature.mjs +723 -0
  35. package/packages/harness/src/setup-hooks.mjs +33 -0
  36. package/packages/harness/src/verify.mjs +1709 -0
  37. package/packages/harness/src/walkthrough.mjs +499 -0
  38. package/packages/harness/src/watch.mjs +622 -0
  39. package/packages/receipts/package.json +36 -0
  40. package/packages/receipts/src/index.mjs +16 -0
  41. package/packages/receipts/src/inputs-hash.mjs +194 -0
  42. package/packages/receipts/src/receipt-validate.mjs +234 -0
  43. package/src/commands/upgrade.mjs +96 -0
  44. package/src/lib/harness-upgrade.mjs +159 -2
  45. package/src/scaffold.mjs +60 -1
  46. package/template/AGENTS.md +5 -0
  47. package/template/CLAUDE.md +30 -0
  48. package/template/gitignore +8 -0
  49. package/template/qa/lib/harness-lock.mjs +147 -0
  50. package/template/qa/lib/harness-region.mjs +159 -0
  51. package/template/qa/lib/inputs-hash.mjs +1 -1
  52. package/template/qa/lib/receipt-validate.mjs +1 -1
  53. package/template/qa/preview-gallery.mjs +17 -2
  54. package/template/qa/verify.mjs +95 -1
@@ -0,0 +1,499 @@
1
+ #!/usr/bin/env node
2
+ // walkthrough.mjs — the generated, committable walkthrough report (A2/C8/C9),
3
+ // and the run-to-run diff (A3).
4
+ //
5
+ // node qa/walkthrough.mjs [--port 9500] [--settle 1200] [--out <dir>]
6
+ // node qa/walkthrough.mjs --compare <runDirA> <runDirB> [--out <dir>]
7
+ //
8
+ // WHAT THIS IS. Evidence, not decoration: one run walks the live app and emits
9
+ // `qa/evidence/walkthrough/<stamp>/` containing per-screen pixels + tree +
10
+ // a11y — captured from the SAME frame (pixels are read before and after the
11
+ // tree; a capture only counts when both reads hash identically) — plus a DB
12
+ // appendix read at capture time, and a self-contained report.html styled from
13
+ // the app's own design-system catalog (that is why every app's report arrives
14
+ // auto-branded in its own tokens). `manifest.json` is the machine half: the
15
+ // console's Walkthrough section and `--compare` both consume it, never the HTML.
16
+ //
17
+ // COVERAGE MODEL — route-jumps for coverage, taps only where the shell demands
18
+ // them, honesty about the rest:
19
+ // • shell tabs: discovered live (descendants of `app_bottom_nav` tagged
20
+ // `nav_<slug>`), visited by tapping — tabs are in-shell state, not routes.
21
+ // • parameterless routes from Routes (Screen.kt): visited via the debug
22
+ // inspector's `/inspect/navigate` — mechanical, no guessed tap coordinates.
23
+ // • parameterized routes (`detail/{itemId}`): NOT walked, listed in
24
+ // `notWalked` with the reason. Entity-bearing routes need a behaviour flow
25
+ // (a real tap on a real row), which is e2e's job, not coverage's.
26
+ // • per-screen `@state` variants (home@empty…): stitched from tier-0 renders
27
+ // under composeApp/build/previews, labelled `tier-0` — full four-arm
28
+ // coverage, honestly sourced (C8): the live walk shows the app's real
29
+ // state; contrived arms come from the renderer and say so.
30
+ //
31
+ // Requires: the debug app running with its inspector reachable (default
32
+ // http://127.0.0.1:9500 — `adb forward tcp:9500 tcp:9500`), adb on PATH
33
+ // (BACK key between route visits), Node 18+.
34
+
35
+ import { createHash } from "node:crypto";
36
+ import { execFileSync } from "node:child_process";
37
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
38
+ import path from "node:path";
39
+ import { fileURLToPath } from "node:url";
40
+
41
+ import { auditA11y } from "./lib/a11y.mjs";
42
+
43
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
44
+ const PREVIEWS_DIR = path.join(ROOT, "composeApp", "build", "previews");
45
+ const SPECS_DIR = path.join(ROOT, "specs");
46
+ const EVIDENCE_ROOT = path.join(ROOT, "qa", "evidence", "walkthrough");
47
+
48
+ const args = process.argv.slice(2);
49
+ const flag = (name, fallback) => {
50
+ const i = args.indexOf(`--${name}`);
51
+ return i >= 0 && args[i + 1] != null ? args[i + 1] : fallback;
52
+ };
53
+ const PORT = Number(flag("port", 9500));
54
+ const SETTLE_MS = Number(flag("settle", 1200));
55
+ const BASE = `http://127.0.0.1:${PORT}`;
56
+
57
+ const sha256 = (buf) => createHash("sha256").update(buf).digest("hex");
58
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
59
+
60
+ async function getJson(p) {
61
+ const res = await fetch(`${BASE}${p}`);
62
+ const body = await res.text();
63
+ if (!res.ok) throw new Error(`GET ${p} -> ${res.status}: ${body.slice(0, 200)}`);
64
+ return JSON.parse(body);
65
+ }
66
+ async function getBytes(p) {
67
+ const res = await fetch(`${BASE}${p}`);
68
+ if (!res.ok) throw new Error(`GET ${p} -> ${res.status}`);
69
+ return Buffer.from(await res.arrayBuffer());
70
+ }
71
+ async function postTap(x, y) {
72
+ const res = await fetch(`${BASE}/inspect/tap`, {
73
+ method: "POST",
74
+ headers: { "content-type": "application/json" },
75
+ body: JSON.stringify({ x, y }),
76
+ });
77
+ if (!res.ok) throw new Error(`POST /inspect/tap -> ${res.status}: ${await res.text()}`);
78
+ }
79
+
80
+ /** Same-frame capture: pixels → tree → pixels, accepted only when both pixel reads hash alike. */
81
+ async function captureStable({ maxAttempts = 4, settleMs = 400 } = {}) {
82
+ let last = null;
83
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
84
+ const a = await getBytes("/inspect/screenshot");
85
+ const tree = await getJson("/inspect/tree");
86
+ const b = await getBytes("/inspect/screenshot");
87
+ const ha = sha256(a);
88
+ if (ha === sha256(b)) {
89
+ let route = null;
90
+ try {
91
+ route = (await getJson("/inspect/nav"))?.currentRoute ?? null;
92
+ } catch {
93
+ /* older app without /inspect/nav — capture stands without the label */
94
+ }
95
+ return { png: a, sha256: ha, tree, route, attempts: attempt };
96
+ }
97
+ last = ha;
98
+ if (attempt < maxAttempts) await sleep(settleMs);
99
+ }
100
+ throw new Error(`frame never stabilised (${maxAttempts} attempts, last ${last?.slice(0, 12)}…) — UI still animating?`);
101
+ }
102
+
103
+ const walkTree = function* (node, p = "root") {
104
+ yield { node, path: p };
105
+ let i = 0;
106
+ for (const c of node.children || []) yield* walkTree(c, `${p}.${i++}`);
107
+ };
108
+
109
+ /**
110
+ * The settle rule, applied to the walk itself (the ledger's e2e lesson): a
111
+ * stable FRAME is not a settled SCREEN — a loading skeleton is perfectly
112
+ * stable. Poll the tree until it stops changing between polls AND no
113
+ * registry loading vocabulary (`*_loading`, skeleton) is on screen. On
114
+ * timeout the capture still happens — with `settled:false` recorded, because
115
+ * an honest "captured mid-load" beats a silent one.
116
+ */
117
+ async function waitForSettled({ timeoutMs = 8_000, pollMs = 500 } = {}) {
118
+ let prev = null;
119
+ const deadline = Date.now() + timeoutMs;
120
+ while (Date.now() < deadline) {
121
+ const t = await getJson("/inspect/tree");
122
+ const root = t.root ?? t;
123
+ const h = sha256(Buffer.from(JSON.stringify(t)));
124
+ const loading = tagsOf(root).some((tag) => tag.endsWith("_loading") || tag.includes("skeleton"));
125
+ if (!loading && h === prev) return true;
126
+ prev = h;
127
+ await sleep(pollMs);
128
+ }
129
+ return false;
130
+ }
131
+ const countNodes = (root) => [...walkTree(root)].length;
132
+ const tagsOf = (root) => [...walkTree(root)].map(({ node }) => node.testTag).filter(Boolean);
133
+ const findTag = (root, tag) => [...walkTree(root)].find(({ node }) => node.testTag === tag)?.node ?? null;
134
+
135
+ /** Parameterless routes from Screen.kt's Routes object; parameterized ones reported, not walked. */
136
+ function discoverRoutes() {
137
+ const navDir = readdirSync(path.join(ROOT, "composeApp", "src", "commonMain", "kotlin"), { recursive: true })
138
+ .map(String)
139
+ .find((f) => f.endsWith(path.join("presentation", "navigation", "Screen.kt")));
140
+ if (!navDir) return { jumpable: [], parameterized: [] };
141
+ const src = readFileSync(path.join(ROOT, "composeApp", "src", "commonMain", "kotlin", navDir), "utf8");
142
+ const routes = [...src.matchAll(/const\s+val\s+[A-Z_]+\s*=\s*"([^"]+)"/g)].map((m) => m[1]);
143
+ return {
144
+ jumpable: routes.filter((r) => r !== "shell" && !r.includes("{")),
145
+ parameterized: routes.filter((r) => r.includes("{")),
146
+ };
147
+ }
148
+
149
+ /** Spec deep-links: `<slug>_screen` root tag -> specs/<slug>.spec.md + its clause ids. */
150
+ function specFor(slug) {
151
+ const file = path.join(SPECS_DIR, `${slug}.spec.md`);
152
+ if (!existsSync(file)) return null;
153
+ const clauses = [...readFileSync(file, "utf8").matchAll(/\*\*([A-Z]+-\d+)\*\*/g)].map((m) => m[1]);
154
+ return { file: path.relative(ROOT, file), clauses: [...new Set(clauses)] };
155
+ }
156
+
157
+ /** C8 — tier-0 rendered `@state` variants for a screen, honestly labelled by source. */
158
+ function variantsFor(slug, outDir) {
159
+ if (!existsSync(PREVIEWS_DIR)) return [];
160
+ return readdirSync(PREVIEWS_DIR)
161
+ .filter((d) => d.startsWith(`${slug}@`))
162
+ .flatMap((d) => {
163
+ const png = path.join(PREVIEWS_DIR, d, "screen.png");
164
+ if (!existsSync(png)) return [];
165
+ const state = d.slice(slug.length + 1);
166
+ const dest = `variants-${slug}@${state}.png`;
167
+ writeFileSync(path.join(outDir, dest), readFileSync(png));
168
+ return [{ state, png: dest, source: "tier-0" }];
169
+ });
170
+ }
171
+
172
+ function designSystemColors(ds) {
173
+ const c = ds?.colors || {};
174
+ return {
175
+ bg: c.Background || "#0d0f0d",
176
+ surface: c.Surface || "#151815",
177
+ onSurface: c.OnSurface || "#f2f4f2",
178
+ onSurfaceVariant: c.OnSurfaceVariant || "#a9b0a9",
179
+ primary: c.Primary || "#b4f04a",
180
+ outline: c.OutlineVariant || c.Outline || "#2a2e2a",
181
+ error: c.Error || "#ff6b6b",
182
+ };
183
+ }
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // The walk
187
+ // ---------------------------------------------------------------------------
188
+
189
+ async function runWalk() {
190
+ let health;
191
+ try {
192
+ health = await getJson("/inspect/health");
193
+ } catch (err) {
194
+ console.error(
195
+ `✗ inspector unreachable at ${BASE} — is the DEBUG app running and forwarded ` +
196
+ `(adb forward tcp:${PORT} tcp:${PORT})? (${err.message})`
197
+ );
198
+ process.exit(1);
199
+ }
200
+
201
+ let ds = null;
202
+ let dsSource = "none";
203
+ try {
204
+ ds = await getJson("/inspect/design-system");
205
+ dsSource = "live";
206
+ } catch {
207
+ const f = path.join(PREVIEWS_DIR, "design-system.json");
208
+ if (existsSync(f)) {
209
+ ds = JSON.parse(readFileSync(f, "utf8"));
210
+ dsSource = "tier-0";
211
+ }
212
+ }
213
+
214
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
215
+ const outDir = path.resolve(flag("out", path.join(EVIDENCE_ROOT, stamp)));
216
+ mkdirSync(outDir, { recursive: true });
217
+
218
+ const screens = [];
219
+ const notWalked = [];
220
+
221
+ const record = async (id, kind, extra = {}) => {
222
+ const settled = await waitForSettled();
223
+ const cap = await captureStable();
224
+ const root = cap.tree.root ?? cap.tree;
225
+ writeFileSync(path.join(outDir, `${id}.png`), cap.png);
226
+ writeFileSync(path.join(outDir, `${id}.tree.json`), JSON.stringify(cap.tree, null, 2));
227
+ const slug = tagsOf(root).find((t) => t.endsWith("_screen"))?.replace(/_screen$/, "") ?? id;
228
+ const a11y = auditA11y(root);
229
+ screens.push({
230
+ id,
231
+ kind,
232
+ slug,
233
+ route: cap.route,
234
+ png: `${id}.png`,
235
+ treeJson: `${id}.tree.json`,
236
+ sha256: cap.sha256,
237
+ captureAttempts: cap.attempts,
238
+ settled,
239
+ nodes: countNodes(root),
240
+ tags: tagsOf(root),
241
+ a11y: { violations: a11y.violations, warnings: a11y.warnings, passCount: a11y.passCount },
242
+ spec: specFor(slug),
243
+ variants: variantsFor(slug, outDir),
244
+ ...extra,
245
+ });
246
+ console.log(` ✓ ${id} (${cap.route ?? "route unknown"}, ${countNodes(root)} nodes, a11y ${a11y.violations.length} violations)`);
247
+ return cap;
248
+ };
249
+
250
+ // 1. Shell tabs — discovered live, visited by tap (they are state, not routes).
251
+ console.log("walking shell tabs…");
252
+ const first = await captureStable();
253
+ const firstRoot = first.tree.root ?? first.tree;
254
+ const navTags = tagsOf(firstRoot).filter((t) => t.startsWith("nav_"));
255
+ if (navTags.length === 0) console.log(" (no nav_* tags found — single-screen app?)");
256
+ for (const tag of navTags) {
257
+ // Re-read the tree each round: bounds may shift with selection state.
258
+ const tree = await getJson("/inspect/tree");
259
+ const node = findTag(tree.root ?? tree, tag);
260
+ if (!node?.bounds) {
261
+ notWalked.push({ target: tag, reason: "nav tag present but no bounds — not tappable from here" });
262
+ continue;
263
+ }
264
+ await postTap(
265
+ Math.round(node.bounds.x + node.bounds.width / 2),
266
+ Math.round(node.bounds.y + node.bounds.height / 2)
267
+ );
268
+ await sleep(SETTLE_MS);
269
+ await record(tag.replace(/^nav_/, ""), "tab", { visitedVia: `tap ${tag}` });
270
+ }
271
+
272
+ // 2. Parameterless routes — mechanical coverage via /inspect/navigate.
273
+ const { jumpable, parameterized } = discoverRoutes();
274
+ if (jumpable.length) console.log("walking routes…");
275
+ for (const route of jumpable) {
276
+ try {
277
+ const res = await fetch(`${BASE}/inspect/navigate?route=${encodeURIComponent(route)}`);
278
+ if (!res.ok) {
279
+ notWalked.push({ target: route, reason: `navigate -> ${res.status}: ${(await res.text()).slice(0, 120)}` });
280
+ continue;
281
+ }
282
+ await sleep(SETTLE_MS);
283
+ await record(route.replace(/[^a-z0-9]+/gi, "-"), "route", { visitedVia: `/inspect/navigate?route=${route}` });
284
+ execFileSync("adb", ["shell", "input", "keyevent", "4"]); // BACK — return to shell for the next visit
285
+ await sleep(SETTLE_MS);
286
+ } catch (err) {
287
+ notWalked.push({ target: route, reason: err.message.slice(0, 160) });
288
+ }
289
+ }
290
+ for (const route of parameterized) {
291
+ notWalked.push({ target: route, reason: "parameterized — needs a behaviour flow (e2e), not blind coverage" });
292
+ }
293
+
294
+ // 3. C9 — the DB appendix, read AT CAPTURE TIME: rows are the persistence receipt.
295
+ let db = null;
296
+ try {
297
+ const schema = await getJson("/inspect/db");
298
+ const tables = [];
299
+ for (const t of schema.tables ?? []) {
300
+ const name = typeof t === "string" ? t : t.name;
301
+ try {
302
+ const q = await getJson(`/inspect/db?table=${encodeURIComponent(name)}&limit=5`);
303
+ tables.push({ name, rowCount: q.rowCount ?? (q.rows ? q.rows.length : null), sample: q.rows ?? [] });
304
+ } catch (err) {
305
+ tables.push({ name, error: err.message.slice(0, 120) });
306
+ }
307
+ }
308
+ db = { source: "GET /inspect/db at capture time", tables };
309
+ } catch {
310
+ db = null; // no Room / endpoint absent — the report states the absence honestly
311
+ }
312
+
313
+ const manifest = {
314
+ schemaVersion: 1,
315
+ generatedAt: new Date().toISOString(),
316
+ appId: health.appId,
317
+ processStartedAtMs: health.processStartedAtMs ?? null,
318
+ inspector: BASE,
319
+ designSystemSource: dsSource,
320
+ screens,
321
+ notWalked,
322
+ db,
323
+ };
324
+ writeFileSync(path.join(outDir, "manifest.json"), JSON.stringify(manifest, null, 2));
325
+ writeFileSync(path.join(outDir, "report.html"), reportHtml(manifest, ds));
326
+ console.log(`\n✅ walkthrough -> ${path.relative(ROOT, outDir)} (${screens.length} screens, ${notWalked.length} not walked)`);
327
+ console.log(` report: ${path.join(path.relative(ROOT, outDir), "report.html")}`);
328
+ }
329
+
330
+ // ---------------------------------------------------------------------------
331
+ // report.html — styled from the app's own tokens (that's the auto-branding)
332
+ // ---------------------------------------------------------------------------
333
+
334
+ const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
335
+
336
+ function reportHtml(m, ds) {
337
+ const c = designSystemColors(ds);
338
+ const cards = m.screens
339
+ .map((s) => {
340
+ const a11yLine =
341
+ s.a11y.violations.length === 0
342
+ ? `<span class="ok">a11y: 0 violations</span>`
343
+ : `<span class="bad">a11y: ${s.a11y.violations.length} violations</span>`;
344
+ const spec = s.spec
345
+ ? `<div class="meta">spec: ${esc(s.spec.file)} — ${s.spec.clauses.map(esc).join(", ") || "no clauses"}</div>`
346
+ : `<div class="meta dim">no per-feature spec file</div>`;
347
+ const variants = s.variants.length
348
+ ? `<div class="variants">${s.variants
349
+ .map((v) => `<figure><img src="${esc(v.png)}" loading="lazy"><figcaption>@${esc(v.state)} · ${esc(v.source)}</figcaption></figure>`)
350
+ .join("")}</div>`
351
+ : "";
352
+ return `<section class="card">
353
+ <h2>${esc(s.id)} <span class="chip">${esc(s.kind)}</span></h2>
354
+ <div class="meta">route: <code>${esc(s.route ?? "—")}</code> · via ${esc(s.visitedVia ?? "—")} · ${s.nodes} nodes · frame ${esc(s.sha256.slice(0, 12))}… · ${a11yLine}</div>
355
+ ${spec}
356
+ <img class="shot" src="${esc(s.png)}" loading="lazy">
357
+ ${variants}
358
+ </section>`;
359
+ })
360
+ .join("\n");
361
+
362
+ const notWalked = m.notWalked.length
363
+ ? `<section class="card"><h2>Not walked</h2><ul>${m.notWalked
364
+ .map((n) => `<li><code>${esc(n.target)}</code> — ${esc(n.reason)}</li>`)
365
+ .join("")}</ul></section>`
366
+ : "";
367
+
368
+ const db = m.db
369
+ ? `<section class="card"><h2>DB appendix <span class="chip">persistence receipt</span></h2>
370
+ <div class="meta">${esc(m.db.source)}</div>
371
+ ${m.db.tables
372
+ .map((t) =>
373
+ t.error
374
+ ? `<h3>${esc(t.name)}</h3><div class="meta bad">${esc(t.error)}</div>`
375
+ : `<h3>${esc(t.name)} <span class="dim">(${t.rowCount ?? "?"} rows)</span></h3><pre>${esc(
376
+ JSON.stringify(t.sample, null, 1).slice(0, 2000)
377
+ )}</pre>`
378
+ )
379
+ .join("")}</section>`
380
+ : `<section class="card"><h2>DB appendix</h2><div class="meta dim">no DB endpoint (Room off, or app predates /inspect/db)</div></section>`;
381
+
382
+ return `<!doctype html><meta charset="utf-8">
383
+ <title>${esc(m.appId)} — walkthrough ${esc(m.generatedAt)}</title>
384
+ <style>
385
+ :root { color-scheme: dark; }
386
+ body { background:${c.bg}; color:${c.onSurface}; font: 15px/1.5 system-ui, sans-serif; margin: 0 auto; max-width: 900px; padding: 24px; }
387
+ h1 { font-size: 22px; } h2 { font-size: 17px; margin: 0 0 6px; } h3 { font-size: 14px; margin: 14px 0 4px; }
388
+ .card { background:${c.surface}; border: 1px solid ${c.outline}; border-radius: 12px; padding: 16px 18px; margin: 14px 0; }
389
+ .meta { color:${c.onSurfaceVariant}; font-size: 13px; margin: 2px 0; }
390
+ .dim { opacity:.7 } .ok { color:${c.primary} } .bad { color:${c.error} }
391
+ .chip { background:${c.bg}; border:1px solid ${c.outline}; border-radius:999px; padding:1px 9px; font-size:11px; vertical-align:2px; color:${c.onSurfaceVariant} }
392
+ img.shot { width: 260px; border-radius: 10px; border:1px solid ${c.outline}; margin-top: 8px; }
393
+ .variants { display:flex; gap:10px; margin-top:10px; flex-wrap:wrap }
394
+ .variants img { width: 150px; border-radius:8px; border:1px solid ${c.outline} }
395
+ .variants figcaption { font-size:11px; color:${c.onSurfaceVariant}; text-align:center }
396
+ pre { background:${c.bg}; border-radius:8px; padding:10px; overflow-x:auto; font-size:12px }
397
+ code { color:${c.primary} }
398
+ </style>
399
+ <h1>${esc(m.appId)} — walkthrough</h1>
400
+ <div class="meta">${esc(m.generatedAt)} · inspector ${esc(m.inspector)} · process started ${esc(
401
+ m.processStartedAtMs ? new Date(m.processStartedAtMs).toISOString() : "unknown"
402
+ )} · design tokens: ${esc(m.designSystemSource)}</div>
403
+ <div class="meta">Evidence, not decoration — every card is pixels + tree + a11y from one proven frame; variants are tier-0 renders and say so; the DB appendix was read at capture time.</div>
404
+ ${cards}
405
+ ${notWalked}
406
+ ${db}`;
407
+ }
408
+
409
+ // ---------------------------------------------------------------------------
410
+ // --compare — A3: two runs, side by side, screen by screen
411
+ // ---------------------------------------------------------------------------
412
+
413
+ function runCompare(dirA, dirB) {
414
+ const load = (d) => {
415
+ const f = path.join(path.resolve(d), "manifest.json");
416
+ if (!existsSync(f)) {
417
+ console.error(`✗ no manifest.json in ${d} — is this a walkthrough run directory?`);
418
+ process.exit(1);
419
+ }
420
+ return JSON.parse(readFileSync(f, "utf8"));
421
+ };
422
+ const A = load(dirA);
423
+ const B = load(dirB);
424
+ const outDir = path.resolve(flag("out", path.join(EVIDENCE_ROOT, `diff-${Date.now()}`)));
425
+ mkdirSync(outDir, { recursive: true });
426
+ const relA = (p) => path.join(path.relative(outDir, path.resolve(dirA)), p);
427
+ const relB = (p) => path.join(path.relative(outDir, path.resolve(dirB)), p);
428
+
429
+ const ids = [...new Set([...A.screens.map((s) => s.id), ...B.screens.map((s) => s.id)])];
430
+ const rows = ids.map((id) => {
431
+ const a = A.screens.find((s) => s.id === id) ?? null;
432
+ const b = B.screens.find((s) => s.id === id) ?? null;
433
+ return {
434
+ id,
435
+ inA: !!a,
436
+ inB: !!b,
437
+ pixelsChanged: a && b ? a.sha256 !== b.sha256 : null,
438
+ nodesDelta: a && b ? b.nodes - a.nodes : null,
439
+ a11yDelta: a && b ? b.a11y.violations.length - a.a11y.violations.length : null,
440
+ tagsAdded: a && b ? b.tags.filter((t) => !a.tags.includes(t)) : [],
441
+ tagsRemoved: a && b ? a.tags.filter((t) => !b.tags.includes(t)) : [],
442
+ };
443
+ });
444
+
445
+ const diff = { schemaVersion: 1, runA: { dir: dirA, generatedAt: A.generatedAt }, runB: { dir: dirB, generatedAt: B.generatedAt }, rows };
446
+ writeFileSync(path.join(outDir, "diff.json"), JSON.stringify(diff, null, 2));
447
+
448
+ const cards = rows
449
+ .map((r) => {
450
+ const a = A.screens.find((s) => s.id === r.id);
451
+ const b = B.screens.find((s) => s.id === r.id);
452
+ const verdict = !r.inA
453
+ ? `<span class="chip">new in B</span>`
454
+ : !r.inB
455
+ ? `<span class="chip">removed in B</span>`
456
+ : r.pixelsChanged
457
+ ? `<span class="bad">pixels changed</span> · nodes ${r.nodesDelta >= 0 ? "+" : ""}${r.nodesDelta} · a11y ${r.a11yDelta >= 0 ? "+" : ""}${r.a11yDelta}`
458
+ : `<span class="ok">identical pixels</span>`;
459
+ const tagNotes =
460
+ r.tagsAdded.length || r.tagsRemoved.length
461
+ ? `<div class="meta">tags: ${r.tagsAdded.map((t) => `+${esc(t)}`).join(" ")} ${r.tagsRemoved.map((t) => `−${esc(t)}`).join(" ")}</div>`
462
+ : "";
463
+ return `<section class="card"><h2>${esc(r.id)}</h2><div class="meta">${verdict}</div>${tagNotes}
464
+ <div class="pair">${a ? `<figure><img src="${esc(relA(a.png))}" loading="lazy"><figcaption>A · ${esc(A.generatedAt)}</figcaption></figure>` : ""}
465
+ ${b ? `<figure><img src="${esc(relB(b.png))}" loading="lazy"><figcaption>B · ${esc(B.generatedAt)}</figcaption></figure>` : ""}</div></section>`;
466
+ })
467
+ .join("\n");
468
+
469
+ writeFileSync(
470
+ path.join(outDir, "diff.html"),
471
+ `<!doctype html><meta charset="utf-8"><title>walkthrough diff</title>
472
+ <style>
473
+ :root{color-scheme:dark} body{background:#0d0f0d;color:#f2f4f2;font:15px/1.5 system-ui;margin:0 auto;max-width:960px;padding:24px}
474
+ .card{background:#151815;border:1px solid #2a2e2a;border-radius:12px;padding:16px 18px;margin:14px 0}
475
+ .meta{color:#a9b0a9;font-size:13px}.ok{color:#b4f04a}.bad{color:#ff6b6b}
476
+ .chip{border:1px solid #2a2e2a;border-radius:999px;padding:1px 9px;font-size:11px;color:#a9b0a9}
477
+ .pair{display:flex;gap:14px;margin-top:10px}.pair img{width:240px;border-radius:10px;border:1px solid #2a2e2a}
478
+ figcaption{font-size:11px;color:#a9b0a9;text-align:center}h2{font-size:17px;margin:0 0 6px}
479
+ </style>
480
+ <h1>Walkthrough diff</h1><div class="meta">A: ${esc(dirA)} (${esc(A.generatedAt)})<br>B: ${esc(dirB)} (${esc(B.generatedAt)})</div>
481
+ ${cards}`
482
+ );
483
+ const changed = rows.filter((r) => r.pixelsChanged).length;
484
+ console.log(`✅ diff -> ${path.relative(ROOT, outDir)} (${rows.length} screens, ${changed} with pixel changes)`);
485
+ }
486
+
487
+ // ---------------------------------------------------------------------------
488
+
489
+ const compareIdx = args.indexOf("--compare");
490
+ if (compareIdx >= 0) {
491
+ const [a, b] = [args[compareIdx + 1], args[compareIdx + 2]];
492
+ if (!a || !b) {
493
+ console.error("usage: node qa/walkthrough.mjs --compare <runDirA> <runDirB> [--out <dir>]");
494
+ process.exit(1);
495
+ }
496
+ runCompare(a, b);
497
+ } else {
498
+ runWalk();
499
+ }