create-cmp-cli 0.8.0 → 0.10.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.
- package/README.md +83 -9
- package/llms.txt +5 -1
- package/package.json +1 -1
- package/src/lib/adr-seed.mjs +178 -0
- package/src/lib/tabs.mjs +97 -4
- package/src/scaffold.mjs +52 -1
- package/template/.claude/skills/add-feature/SKILL.md +35 -10
- package/template/.claude/skills/add-repository/SKILL.md +1 -1
- package/template/.claude/skills/add-screen/SKILL.md +13 -7
- package/template/.githooks/pre-push +24 -0
- package/template/CLAUDE.md +213 -47
- package/template/README.md +32 -27
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/CrashRecorder.kt +99 -0
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/DbInspector.kt +144 -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 +177 -21
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorInit.kt +8 -4
- 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 +62 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/data/AppResultCatching.kt +32 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/data/remote/ItemRepositoryImpl.kt +9 -2
- package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/model/DomainError.kt +21 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/repository/ItemRepository.kt +4 -1
- package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/result/AppResult.kt +23 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/usecase/GetItemsUseCase.kt +4 -1
- 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/AppBottomBar.kt +138 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +56 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppHeader.kt +63 -0
- 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/components/BaseScreen.kt +16 -8
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ContentStateContainer.kt +105 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ContentUiState.kt +18 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/EmptyState.kt +58 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ErrorState.kt +52 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ListItemCard.kt +77 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ScreenColumn.kt +47 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/Shimmer.kt +90 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/TestTagAutomation.kt +9 -9
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/DetailScreen.kt +5 -27
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeScreen.kt +14 -70
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeViewModel.kt +33 -13
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +22 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppShell.kt +7 -109
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/NavInspectionHook.kt +31 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/profile/ProfileScreen.kt +4 -27
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/theme/Typography.kt +70 -6
- package/template/composeApp/src/commonTest/kotlin/com/example/app/data/AppResultCatchingTest.kt +52 -0
- package/template/composeApp/src/commonTest/kotlin/com/example/app/data/remote/ItemRepositoryImplTest.kt +29 -4
- package/template/composeApp/src/commonTest/kotlin/com/example/app/domain/usecase/GetItemsUseCaseTest.kt +8 -6
- package/template/composeApp/src/commonTest/kotlin/com/example/app/presentation/home/HomeViewModelTest.kt +39 -27
- package/template/composeApp/src/commonTest/kotlin/com/example/app/testing/fakes/FakeItemRepository.kt +10 -6
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/ComponentStories.kt +300 -0
- 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/PreviewRegistry.kt +37 -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 +250 -16
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ComponentConformanceTest.kt +84 -0
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/home/HomeScreenTest.kt +36 -4
- package/template/docs/ARCHITECTURE.md +372 -34
- package/template/docs/TESTING.md +13 -5
- package/template/docs/adr/0002-maestro-over-appium-for-e2e.md +39 -0
- package/template/docs/adr/0003-jvm-desktop-target-is-harness-infrastructure.md +39 -0
- package/template/docs/adr/0004-fakes-not-mocks-for-unit-tests.md +48 -0
- package/template/qa/approvals.json +42 -0
- package/template/qa/approve.mjs +139 -0
- package/template/qa/arch-doc.mjs +69 -0
- package/template/qa/comment.mjs +76 -0
- package/template/qa/comments.json +4 -0
- package/template/qa/e2e/smoke.yaml +6 -0
- package/template/qa/golden/home.json +3 -3
- package/template/qa/lib/a11y.mjs +17 -8
- package/template/qa/lib/approvals.mjs +822 -0
- package/template/qa/lib/arch-doc.mjs +451 -0
- package/template/qa/lib/comments.mjs +252 -0
- package/template/qa/lib/component-stories.mjs +183 -0
- package/template/qa/lib/inputs-hash.mjs +5 -1
- package/template/qa/scaffold-feature.mjs +184 -67
- package/template/qa/setup-hooks.mjs +33 -0
- package/template/qa/verify.mjs +181 -15
- package/template/qa/walkthrough.mjs +499 -0
- package/template/specs/app-base.spec.md +49 -7
- package/template/specs/home.spec.md +7 -4
- package/template/specs/intent.md +50 -0
|
@@ -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
|
+
}
|
|
@@ -7,8 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
- **ARCH-01** — Given any file in `presentation`, When its imports **and fully-qualified
|
|
9
9
|
inline references** are inspected, Then none resolve into the `data` layer (presentation
|
|
10
|
-
depends on domain only
|
|
11
|
-
violation).
|
|
10
|
+
depends on domain only).
|
|
12
11
|
- **ARCH-02** — Given any file in `domain`, When its imports **and fully-qualified inline
|
|
13
12
|
references** are inspected, Then none resolve into `presentation`, `data`, or `di`, and
|
|
14
13
|
none reference Compose, Koin, or platform types (domain is pure Kotlin).
|
|
@@ -16,11 +15,42 @@
|
|
|
16
15
|
corresponding `*ViewModelTest` exists (no untested presentation state).
|
|
17
16
|
- **ARCH-04** — Given any file in a `presentation` feature package that contains a
|
|
18
17
|
`@Composable` function, When its source is inspected, Then it declares at least one
|
|
19
|
-
`testTag`
|
|
20
|
-
|
|
18
|
+
literal `testTag` **or** passes a `screenTag =` argument to a component imported from
|
|
19
|
+
`presentation.components` (scoped by content, not `*Screen.kt` filename — split
|
|
20
|
+
`Content.kt` UI files are covered, ViewModel-only files are exempt). Component-derived
|
|
21
|
+
tags count as tag provenance: a screen built entirely from `ScreenColumn`/`AppHeader`/
|
|
22
|
+
`ContentStateContainer`/etc. is automation-reachable through the tags those components
|
|
23
|
+
emit (`<screenTag>_screen`, `<screenTag>_title`, `<screenTag>_loading`, …) even without a
|
|
24
|
+
literal `testTag` of its own.
|
|
21
25
|
- **ARCH-05** — Given any file outside `presentation/theme`, When its source is inspected,
|
|
22
26
|
Then it constructs no literal `Color(0x…)` values (design colors come from the token
|
|
23
27
|
catalog).
|
|
28
|
+
- **ARCH-06** — Given any repository interface in `domain/repository`, When its one-shot
|
|
29
|
+
operations (`suspend fun`s) are inspected, Then each declares an `AppResult<…>` return
|
|
30
|
+
type — raw exceptions never cross the data → domain boundary; failures travel as typed
|
|
31
|
+
`DomainError` values assigned inside the data implementation.
|
|
32
|
+
- **ARCH-07** — Given any ViewModel in `presentation`, When its source is inspected, Then it
|
|
33
|
+
contains no `try`/`catch`/`runCatching` — ViewModels fold over `AppResult` and map
|
|
34
|
+
`DomainError` kinds to user-facing copy; a raw exception message is never shown to a user.
|
|
35
|
+
- **ARCH-08** — Given any file in the `data` layer, When its source is inspected, Then the
|
|
36
|
+
only exception-catching mechanism is the shared `suspendRunCatching` helper
|
|
37
|
+
(`data/AppResultCatching.kt`), and the helper always rethrows `CancellationException` —
|
|
38
|
+
cancellation is never swallowed into a failure state.
|
|
39
|
+
- **ARCH-09** — Given any file in `data`, When its imports and fully-qualified inline
|
|
40
|
+
references are inspected, Then none resolve into `presentation` or `di` (data serves
|
|
41
|
+
domain contracts; it never reaches upward).
|
|
42
|
+
- **ARCH-10** — Given any file in `core`, When its imports and fully-qualified inline
|
|
43
|
+
references are inspected, Then none resolve into `presentation`, `data`, or `di` (core
|
|
44
|
+
is leaf utility code; `domain` at most).
|
|
45
|
+
- **ARCH-11** — Given any file in a presentation feature package (`components/` excluded),
|
|
46
|
+
When its source is inspected, Then it references neither `CircularProgressIndicator` nor
|
|
47
|
+
`LinearProgressIndicator` directly — loading is presented through the components
|
|
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.
|
|
24
54
|
|
|
25
55
|
## App shell
|
|
26
56
|
|
|
@@ -33,6 +63,18 @@
|
|
|
33
63
|
- **SHELL-04** — Given the app renders any screen, When interactive elements are present,
|
|
34
64
|
Then each is perceivable by automation: it exposes a testTag, text, or content description.
|
|
35
65
|
- **SHELL-05** — Given any screen registered directly on the NavHost (not a shell tab), When
|
|
36
|
-
it renders, Then its content is composed inside `BaseScreen
|
|
37
|
-
|
|
38
|
-
|
|
66
|
+
it renders, Then its content is composed inside `BaseScreen`.
|
|
67
|
+
|
|
68
|
+
## Component vocabulary
|
|
69
|
+
|
|
70
|
+
> Component *contracts* — the shared state/a11y behavior every screen inherits from
|
|
71
|
+
> `presentation/components/*.kt` (the governed `components` artifact). Feature clauses
|
|
72
|
+
> (e.g. `HOME-NN`) keep citing feature behavior; these clauses are covered once, here.
|
|
73
|
+
|
|
74
|
+
- **COMP-01** — Given any screen with a data-backed state, When it renders, Then
|
|
75
|
+
loading/error/empty are presented by `ContentStateContainer` with tags
|
|
76
|
+
`<screen>_loading` / `<screen>_error` / `<screen>_empty`.
|
|
77
|
+
- **COMP-02** — Given a recoverable load failure and a retry handler, When the error state
|
|
78
|
+
renders, Then a `<screen>_retry` control of at least 48 dp is present.
|
|
79
|
+
- **COMP-03** — Given any interactive registry component, When it renders, Then its
|
|
80
|
+
pointer target is at least 48×48 dp.
|
|
@@ -8,11 +8,14 @@
|
|
|
8
8
|
- **HOME-02** — Given the repository returns items, When loading completes, Then the items are
|
|
9
9
|
listed with their title and subtitle, and no error is shown.
|
|
10
10
|
- **HOME-03** — Given the repository fails, When loading completes, Then a human-readable
|
|
11
|
-
error message is shown (`home_error`) and no items are visible
|
|
11
|
+
error message is shown (`home_error`) and no items are visible — the copy is mapped in
|
|
12
|
+
presentation from the failure's `DomainError` kind, never a raw exception message.
|
|
12
13
|
- **HOME-04** — Given a load has failed, When the data source recovers and the user triggers a
|
|
13
|
-
reload, Then the error clears and the items render.
|
|
14
|
+
reload (`home_retry`), Then the error clears and the items render.
|
|
14
15
|
- **HOME-05** — Given items are listed, When the user taps an item, Then the app navigates to
|
|
15
16
|
that item's detail.
|
|
16
17
|
- **HOME-06** — Given the Home screen renders, When its structure is inspected, Then the
|
|
17
|
-
screen matches its committed golden tree (`qa/golden/home.json`) — structural
|
|
18
|
-
|
|
18
|
+
screen matches its committed golden tree (`qa/golden/home.json`) — structural change must
|
|
19
|
+
be intentional and declared.
|
|
20
|
+
- **HOME-07** — Given the repository succeeds with zero items, When loading completes, Then
|
|
21
|
+
the empty state is shown (`home_empty`) and neither items nor an error are visible.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Intent brief
|
|
2
|
+
|
|
3
|
+
> The root artifact everything else traces to (GENESIS-FLOW-DESIGN.md §0, conversation 0 of
|
|
4
|
+
> the genesis walk). This is prose, not a clause spec — no `// SPEC:` tags apply here, and
|
|
5
|
+
> `specCoverage` never scans this file (it only scans `specs/*.spec.md`). The `cmp-new`
|
|
6
|
+
> interview fills in the sections below before the design-language, architecture, components,
|
|
7
|
+
> and exemplar-feature conversations begin; each of those is expressed in the vocabulary this
|
|
8
|
+
> brief establishes.
|
|
9
|
+
|
|
10
|
+
## Purpose
|
|
11
|
+
|
|
12
|
+
_not yet captured — filled by the cmp-new interview._ What is this app, in one or two
|
|
13
|
+
sentences? What problem does it solve, and for whom?
|
|
14
|
+
|
|
15
|
+
## Audience
|
|
16
|
+
|
|
17
|
+
_not yet captured — filled by the cmp-new interview._ Who uses this app? One primary
|
|
18
|
+
audience beats a vague list — name them.
|
|
19
|
+
|
|
20
|
+
## Platforms
|
|
21
|
+
|
|
22
|
+
_not yet captured — filled by the cmp-new interview._ Android, iOS, or both? Any platform-
|
|
23
|
+
specific constraints (e.g. iOS-only feature, Android-first launch)?
|
|
24
|
+
|
|
25
|
+
## Brand feel
|
|
26
|
+
|
|
27
|
+
_not yet captured — filled by the cmp-new interview._ Two or three adjectives for how this
|
|
28
|
+
app should feel (e.g. "calm, trustworthy, minimal" vs. "playful, bold, energetic"). This
|
|
29
|
+
seeds conversation 1 (design language) — the candidates it proposes react to what's written
|
|
30
|
+
here.
|
|
31
|
+
|
|
32
|
+
## Reference apps
|
|
33
|
+
|
|
34
|
+
_not yet captured — filled by the cmp-new interview._ One to three apps whose look, feel, or
|
|
35
|
+
interaction patterns this app should be judged against ("closer to X than Y").
|
|
36
|
+
|
|
37
|
+
## First screens
|
|
38
|
+
|
|
39
|
+
_not yet captured — filled by the cmp-new interview._ What are the first 2–4 screens you see
|
|
40
|
+
in your head? This grounds conversation 4 — the exemplar feature is the user's *actual* first
|
|
41
|
+
feature, not a generic placeholder.
|
|
42
|
+
|
|
43
|
+
## Glossary
|
|
44
|
+
|
|
45
|
+
_not yet captured — filled by the cmp-new interview._ The domain nouns specific to this app —
|
|
46
|
+
one line each, in the app's own vocabulary, not generic template terms (e.g. "**Trip** — a
|
|
47
|
+
single planned outing with an itinerary and companions", not "**Item**"). Usually falls out of
|
|
48
|
+
the Purpose/First screens answers above; confirm the list with the human rather than guessing.
|
|
49
|
+
This section is lifted verbatim into `docs/ARCHITECTURE.md` §8 — write it in the form you want
|
|
50
|
+
published: a Markdown bullet list, `**Term** — definition` per line.
|