create-cmp-cli 0.9.0 → 0.10.1
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.
- package/README.md +16 -0
- package/package.json +1 -1
- package/src/lib/tabs.mjs +6 -0
- package/template/CLAUDE.md +24 -6
- package/template/README.md +9 -0
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorCatalog.kt +19 -0
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorHttpServer.kt +108 -19
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/LiveSemanticsJson.kt +10 -0
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/NavInspector.kt +31 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/brand/BrandMark.kt +75 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +3 -3
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppHeader.kt +11 -2
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppIconButton.kt +48 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +14 -5
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/NavInspectionHook.kt +10 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/theme/Typography.kt +70 -6
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/ComponentStories.kt +33 -2
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewDaemon.kt +5 -0
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewHarness.kt +91 -1
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewSemanticsJson.kt +14 -1
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +43 -1
- package/template/docs/ARCHITECTURE.md +55 -0
- package/template/docs/TESTING.md +7 -0
- package/template/qa/e2e/smoke.yaml +6 -0
- package/template/qa/lib/a11y.mjs +17 -8
- package/template/qa/lib/approvals.mjs +44 -28
- package/template/qa/verify.mjs +63 -6
- package/template/qa/walkthrough.mjs +499 -0
- package/template/specs/app-base.spec.md +5 -0
package/template/qa/verify.mjs
CHANGED
|
@@ -49,6 +49,30 @@ function sh(cmd, opts = {}) {
|
|
|
49
49
|
return { ok, status: res.status, error: res.error?.message, out: `${res.stdout ?? ""}${res.stderr ?? ""}`, durationMs: Date.now() - started };
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
// ── Preview-daemon coexistence ──────────────────────────────────────────────
|
|
53
|
+
// The preview daemon (the eyes) and this lane both spawn Gradle against this
|
|
54
|
+
// project and share composeApp/build/kspCaches, whose KSP incremental storage
|
|
55
|
+
// is single-owner — two concurrent builds throw "Storage for [...] is already
|
|
56
|
+
// registered" and one side dies. Two defenses, both automatic:
|
|
57
|
+
// 1. COORDINATE: this lane stamps a marker file for its duration; the preview
|
|
58
|
+
// service defers renders while it exists (mtime-bounded, so a crashed lane
|
|
59
|
+
// never wedges the eyes for long).
|
|
60
|
+
// 2. SELF-HEAL: a Gradle step that still hits the collision clears kspCaches
|
|
61
|
+
// and retries once — the manual recovery that always worked, automated.
|
|
62
|
+
const LANE_MARKER = path.join(ROOT, "composeApp", "build", ".cmp-lane-in-progress");
|
|
63
|
+
const KSP_COLLISION_RE = /Storage for \[[^\]]*\] is already registered/;
|
|
64
|
+
|
|
65
|
+
function shGradle(cmd, opts = {}) {
|
|
66
|
+
const first = sh(cmd, opts);
|
|
67
|
+
if (first.ok || !KSP_COLLISION_RE.test(first.out)) return first;
|
|
68
|
+
console.error("· KSP cache collision (concurrent Gradle — the preview daemon?) — clearing kspCaches, retrying once");
|
|
69
|
+
fs.rmSync(path.join(ROOT, "composeApp", "build", "kspCaches"), { recursive: true, force: true });
|
|
70
|
+
const retry = sh(cmd, opts);
|
|
71
|
+
retry.durationMs += first.durationMs;
|
|
72
|
+
retry.selfHealed = "ksp-cache-collision";
|
|
73
|
+
return retry;
|
|
74
|
+
}
|
|
75
|
+
|
|
52
76
|
function tryGit(cmd) {
|
|
53
77
|
try {
|
|
54
78
|
return execSync(`git ${cmd}`, { cwd: ROOT, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
@@ -57,6 +81,22 @@ function tryGit(cmd) {
|
|
|
57
81
|
}
|
|
58
82
|
}
|
|
59
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Line-oriented git output, WITHOUT [tryGit]'s trim. `git status --porcelain`
|
|
86
|
+
* has significant leading whitespace: an unstaged modification is `" M path"`,
|
|
87
|
+
* so trimming the whole blob eats the first line's leading space — and a fixed
|
|
88
|
+
* `slice(3)` then swallows that path's first character. The receipt would name
|
|
89
|
+
* a file that does not exist. Only trailing newlines are dropped here.
|
|
90
|
+
*/
|
|
91
|
+
function tryGitLines(cmd) {
|
|
92
|
+
try {
|
|
93
|
+
const out = execSync(`git ${cmd}`, { cwd: ROOT, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
94
|
+
return out.replace(/\n+$/, "").split("\n").filter(Boolean);
|
|
95
|
+
} catch {
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
60
100
|
function junitSummary(dir) {
|
|
61
101
|
if (!fs.existsSync(dir)) return null;
|
|
62
102
|
let tests = 0, failures = 0, errors = 0, skipped = 0;
|
|
@@ -251,7 +291,7 @@ function stepArchDoc() {
|
|
|
251
291
|
}
|
|
252
292
|
|
|
253
293
|
function stepBuild() {
|
|
254
|
-
const res =
|
|
294
|
+
const res = shGradle(`${GRADLEW} :composeApp:assembleDebug --console=plain`);
|
|
255
295
|
return {
|
|
256
296
|
name: "build",
|
|
257
297
|
verdict: res.ok ? "PASS" : "FAIL",
|
|
@@ -265,7 +305,7 @@ function stepBuild() {
|
|
|
265
305
|
// cached) while `--rerun` forces the tests themselves to EXECUTE — see stepUnitTests.
|
|
266
306
|
function gradleTestStep(name, testsFilter, failHint) {
|
|
267
307
|
return () => {
|
|
268
|
-
const res =
|
|
308
|
+
const res = shGradle(`${GRADLEW} :composeApp:desktopTest --rerun --tests "${testsFilter}" --console=plain`);
|
|
269
309
|
return {
|
|
270
310
|
name,
|
|
271
311
|
verdict: res.ok ? "PASS" : "FAIL",
|
|
@@ -283,7 +323,7 @@ function stepUnitTests() {
|
|
|
283
323
|
// produce byte-identical sources, and golden baselines aren't compile inputs), so the
|
|
284
324
|
// receipt would attest tests that never executed. Compilation stays cached — only the
|
|
285
325
|
// test execution is forced.
|
|
286
|
-
const res =
|
|
326
|
+
const res = shGradle(`${GRADLEW} :composeApp:desktopTest --rerun --console=plain`);
|
|
287
327
|
const summary = junitSummary(path.join(ROOT, "composeApp/build/test-results/desktopTest"));
|
|
288
328
|
return {
|
|
289
329
|
name: "unitTests",
|
|
@@ -436,7 +476,7 @@ function stepE2eSmoke() {
|
|
|
436
476
|
if (!maestroAvailable()) {
|
|
437
477
|
return { name: "e2eSmoke", verdict: "SKIP", reason: "maestro CLI not installed — curl -fsSL https://get.maestro.mobile.dev | bash", durationMs: 0 };
|
|
438
478
|
}
|
|
439
|
-
const install =
|
|
479
|
+
const install = shGradle(`${GRADLEW} :composeApp:installDebug --console=plain`);
|
|
440
480
|
if (!install.ok) {
|
|
441
481
|
return { name: "e2eSmoke", verdict: "FAIL", reason: "installDebug failed — the APK could not be installed on the attached device", durationMs: install.durationMs };
|
|
442
482
|
}
|
|
@@ -513,7 +553,12 @@ if (!stepsForProfile[profile]) {
|
|
|
513
553
|
process.exit(2);
|
|
514
554
|
}
|
|
515
555
|
|
|
556
|
+
// Stamp the lane marker for the run's duration (coexistence defense 1 above);
|
|
557
|
+
// always removed, even on a failing step, so the eyes only ever defer briefly.
|
|
558
|
+
fs.mkdirSync(path.dirname(LANE_MARKER), { recursive: true });
|
|
559
|
+
fs.writeFileSync(LANE_MARKER, `${process.pid} ${new Date().toISOString()}\n`);
|
|
516
560
|
const steps = [];
|
|
561
|
+
try {
|
|
517
562
|
for (const step of stepsForProfile[profile]) {
|
|
518
563
|
const result = step();
|
|
519
564
|
steps.push(result);
|
|
@@ -523,9 +568,20 @@ for (const step of stepsForProfile[profile]) {
|
|
|
523
568
|
}
|
|
524
569
|
if (result.name === "build" && result.verdict === "FAIL") break; // nothing downstream is meaningful
|
|
525
570
|
}
|
|
571
|
+
} finally {
|
|
572
|
+
fs.rmSync(LANE_MARKER, { force: true });
|
|
573
|
+
}
|
|
526
574
|
|
|
527
575
|
const verdict = steps.some((s) => s.verdict === "FAIL") ? "FAIL" : "PASS";
|
|
528
576
|
|
|
577
|
+
// Receipt STRENGTH — a desktop-only green and an on-device green are different
|
|
578
|
+
// claims, and the difference should never live only in the SKIP lines. Device-
|
|
579
|
+
// dependent steps that actually RAN (PASSed) are named on the receipt and in the
|
|
580
|
+
// verdict line: "PASS (on-device: e2eSmoke)" vs "PASS (desktop-only)".
|
|
581
|
+
const DEVICE_STEPS = ["e2eSmoke", "tokenDrift"];
|
|
582
|
+
const onDeviceSteps = steps.filter((s) => DEVICE_STEPS.includes(s.name) && s.verdict === "PASS").map((s) => s.name);
|
|
583
|
+
const strengthLabel = onDeviceSteps.length ? `on-device: ${onDeviceSteps.join("+")}` : "desktop-only";
|
|
584
|
+
|
|
529
585
|
// Artifacts: hash whatever the run left under qa-artifacts/ (never committed).
|
|
530
586
|
const artifacts = [];
|
|
531
587
|
if (fs.existsSync(ARTIFACTS_DIR)) {
|
|
@@ -554,13 +610,14 @@ const receipt = {
|
|
|
554
610
|
verdict,
|
|
555
611
|
commit: {
|
|
556
612
|
sha: tryGit("rev-parse HEAD"),
|
|
557
|
-
dirty: (
|
|
613
|
+
dirty: tryGitLines("status --porcelain").map((l) => l.slice(3)).sort(),
|
|
558
614
|
},
|
|
559
615
|
inputs: {
|
|
560
616
|
hash: inputs.hash,
|
|
561
617
|
fileCount: inputs.fileCount,
|
|
562
618
|
},
|
|
563
619
|
steps,
|
|
620
|
+
strength: { onDeviceSteps },
|
|
564
621
|
artifacts,
|
|
565
622
|
toolVersions: {
|
|
566
623
|
node: process.version,
|
|
@@ -576,6 +633,6 @@ fs.writeFileSync(path.join(EVIDENCE_DIR, "latest.json"), `${JSON.stringify(recei
|
|
|
576
633
|
// git log of this file — every commit is one verified, attributed state.
|
|
577
634
|
|
|
578
635
|
if (asJson) console.log(JSON.stringify(receipt, null, 2));
|
|
579
|
-
else console.log(`\n${verdict === "PASS" ? "✅" : "❌"} verify lane: ${verdict} — receipt written to qa/evidence/latest.json (commit it with your change)`);
|
|
636
|
+
else console.log(`\n${verdict === "PASS" ? "✅" : "❌"} verify lane: ${verdict} (${strengthLabel}) — receipt written to qa/evidence/latest.json (commit it with your change)`);
|
|
580
637
|
|
|
581
638
|
process.exit(verdict === "PASS" ? 0 : 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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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
|
+
}
|
|
@@ -46,6 +46,11 @@
|
|
|
46
46
|
When its source is inspected, Then it references neither `CircularProgressIndicator` nor
|
|
47
47
|
`LinearProgressIndicator` directly — loading is presented through the components
|
|
48
48
|
registry (`ContentStateContainer`/`ContentStateDefaults`), never hand-rolled per screen.
|
|
49
|
+
- **ARCH-12** — Given a `sample*` preview fixture declared in a `commonMain` presentation
|
|
50
|
+
file, When any OTHER `commonMain` file references it, Then the conformance gate fails —
|
|
51
|
+
a sample is the UI-first preview seam (the stateless screen's own default parameter,
|
|
52
|
+
plus the preview registry/stories and tests), never production wiring. Fake data
|
|
53
|
+
resolving a nav route or seeding a repository is exactly the drift this stops.
|
|
49
54
|
|
|
50
55
|
## App shell
|
|
51
56
|
|