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.
Files changed (85) hide show
  1. package/README.md +83 -9
  2. package/llms.txt +5 -1
  3. package/package.json +1 -1
  4. package/src/lib/adr-seed.mjs +178 -0
  5. package/src/lib/tabs.mjs +97 -4
  6. package/src/scaffold.mjs +52 -1
  7. package/template/.claude/skills/add-feature/SKILL.md +35 -10
  8. package/template/.claude/skills/add-repository/SKILL.md +1 -1
  9. package/template/.claude/skills/add-screen/SKILL.md +13 -7
  10. package/template/.githooks/pre-push +24 -0
  11. package/template/CLAUDE.md +213 -47
  12. package/template/README.md +32 -27
  13. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/CrashRecorder.kt +99 -0
  14. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/DbInspector.kt +144 -0
  15. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorCatalog.kt +19 -0
  16. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorHttpServer.kt +177 -21
  17. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorInit.kt +8 -4
  18. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/LiveSemanticsJson.kt +10 -0
  19. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/NavInspector.kt +62 -0
  20. package/template/composeApp/src/commonMain/kotlin/com/example/app/data/AppResultCatching.kt +32 -0
  21. package/template/composeApp/src/commonMain/kotlin/com/example/app/data/remote/ItemRepositoryImpl.kt +9 -2
  22. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/model/DomainError.kt +21 -0
  23. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/repository/ItemRepository.kt +4 -1
  24. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/result/AppResult.kt +23 -0
  25. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/usecase/GetItemsUseCase.kt +4 -1
  26. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/brand/BrandMark.kt +75 -0
  27. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppBottomBar.kt +138 -0
  28. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +56 -0
  29. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppHeader.kt +63 -0
  30. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppIconButton.kt +48 -0
  31. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/BaseScreen.kt +16 -8
  32. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ContentStateContainer.kt +105 -0
  33. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ContentUiState.kt +18 -0
  34. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/EmptyState.kt +58 -0
  35. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ErrorState.kt +52 -0
  36. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ListItemCard.kt +77 -0
  37. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ScreenColumn.kt +47 -0
  38. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/Shimmer.kt +90 -0
  39. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/TestTagAutomation.kt +9 -9
  40. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/DetailScreen.kt +5 -27
  41. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeScreen.kt +14 -70
  42. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeViewModel.kt +33 -13
  43. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +22 -0
  44. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppShell.kt +7 -109
  45. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/NavInspectionHook.kt +31 -0
  46. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/profile/ProfileScreen.kt +4 -27
  47. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/theme/Typography.kt +70 -6
  48. package/template/composeApp/src/commonTest/kotlin/com/example/app/data/AppResultCatchingTest.kt +52 -0
  49. package/template/composeApp/src/commonTest/kotlin/com/example/app/data/remote/ItemRepositoryImplTest.kt +29 -4
  50. package/template/composeApp/src/commonTest/kotlin/com/example/app/domain/usecase/GetItemsUseCaseTest.kt +8 -6
  51. package/template/composeApp/src/commonTest/kotlin/com/example/app/presentation/home/HomeViewModelTest.kt +39 -27
  52. package/template/composeApp/src/commonTest/kotlin/com/example/app/testing/fakes/FakeItemRepository.kt +10 -6
  53. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/ComponentStories.kt +300 -0
  54. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewDaemon.kt +5 -0
  55. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewHarness.kt +91 -1
  56. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewRegistry.kt +37 -1
  57. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewSemanticsJson.kt +14 -1
  58. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +250 -16
  59. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ComponentConformanceTest.kt +84 -0
  60. package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/home/HomeScreenTest.kt +36 -4
  61. package/template/docs/ARCHITECTURE.md +372 -34
  62. package/template/docs/TESTING.md +13 -5
  63. package/template/docs/adr/0002-maestro-over-appium-for-e2e.md +39 -0
  64. package/template/docs/adr/0003-jvm-desktop-target-is-harness-infrastructure.md +39 -0
  65. package/template/docs/adr/0004-fakes-not-mocks-for-unit-tests.md +48 -0
  66. package/template/qa/approvals.json +42 -0
  67. package/template/qa/approve.mjs +139 -0
  68. package/template/qa/arch-doc.mjs +69 -0
  69. package/template/qa/comment.mjs +76 -0
  70. package/template/qa/comments.json +4 -0
  71. package/template/qa/e2e/smoke.yaml +6 -0
  72. package/template/qa/golden/home.json +3 -3
  73. package/template/qa/lib/a11y.mjs +17 -8
  74. package/template/qa/lib/approvals.mjs +822 -0
  75. package/template/qa/lib/arch-doc.mjs +451 -0
  76. package/template/qa/lib/comments.mjs +252 -0
  77. package/template/qa/lib/component-stories.mjs +183 -0
  78. package/template/qa/lib/inputs-hash.mjs +5 -1
  79. package/template/qa/scaffold-feature.mjs +184 -67
  80. package/template/qa/setup-hooks.mjs +33 -0
  81. package/template/qa/verify.mjs +181 -15
  82. package/template/qa/walkthrough.mjs +499 -0
  83. package/template/specs/app-base.spec.md +49 -7
  84. package/template/specs/home.spec.md +7 -4
  85. package/template/specs/intent.md +50 -0
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env node
2
+ // The approvals CLI — thin shell over qa/lib/approvals.mjs.
3
+ //
4
+ // node qa/approve.mjs <artifact> records approval (recomputes the artifact's
5
+ // hash now, stamps the time, writes qa/approvals.json)
6
+ // node qa/approve.mjs --status lists every governed artifact + live state
7
+ // (unreviewed / approved / changed-since-approval /
8
+ // reopened, + mode when set) + short hash
9
+ // node qa/approve.mjs --accept-defaults express lane (GENESIS-FLOW-DESIGN.md §2): approves
10
+ // every currently-resolvable, not-yet-approved
11
+ // artifact, each stamped "defaults-accepted"
12
+ // node qa/approve.mjs --reopen <artifact> moves an approved artifact back to "reopened" for
13
+ // redesign (refuses anything not currently approved)
14
+ //
15
+ // This file has NO logic of its own — every decision (the registry, hashing,
16
+ // state, the transitions) lives in qa/lib/approvals.mjs. That's deliberate: the
17
+ // console (VERIFICATION-LAYER-DESIGN.md §4, `POST /api/approve`; GENESIS-FLOW-DESIGN.md
18
+ // §2, `POST /api/reopen`) calls the SAME library this CLI calls, so this file is the
19
+ // API surface, kept intentionally thin and easy to keep in lockstep.
20
+
21
+ import path from "node:path";
22
+ import { fileURLToPath } from "node:url";
23
+
24
+ import {
25
+ approveAllDefaults,
26
+ approveArtifact,
27
+ getApprovalStatuses,
28
+ isPackageResolvable,
29
+ listGovernedArtifacts,
30
+ reopenArtifact,
31
+ } from "./lib/approvals.mjs";
32
+
33
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
34
+ const args = process.argv.slice(2);
35
+
36
+ function shortHash(hash) {
37
+ return hash ? hash.slice(0, 8) : "none";
38
+ }
39
+
40
+ function printStatus() {
41
+ const statuses = getApprovalStatuses(ROOT);
42
+ if (statuses.length === 0) {
43
+ console.log("No governed artifacts resolved in this project (no specs/, or the package could not be resolved).");
44
+ return;
45
+ }
46
+ console.log("Approval status:\n");
47
+ for (const s of statuses) {
48
+ const mark =
49
+ s.status === "approved" ? "✓" : s.status === "changed-since-approval" ? "✗" : s.status === "reopened" ? "↺" : "→";
50
+ // An unresolvable artifact (0 files, or a partial kotlin set in a
51
+ // pre-stamp tree) must never display its degraded hash as if it were
52
+ // approvable — approval over an unresolved file set is refused.
53
+ const hashInfo =
54
+ s.status === "reopened"
55
+ ? `reopened at ${s.reopenedAt} (was approved ${shortHash(s.storedHash)})`
56
+ : s.status === "changed-since-approval"
57
+ ? s.resolvable
58
+ ? `approved ${shortHash(s.storedHash)} -> now ${shortHash(s.hash)}`
59
+ : `approved ${shortHash(s.storedHash)} -> unresolvable (${s.fileCount} of expected files resolved)`
60
+ : s.status === "approved"
61
+ ? shortHash(s.hash)
62
+ : s.resolvable
63
+ ? `would approve at ${shortHash(s.hash)}`
64
+ : `unresolvable (${s.fileCount} of expected files resolved) — not approvable`;
65
+ const modeInfo = s.mode ? ` [${s.mode}]` : "";
66
+ console.log(`${mark} ${s.id}: ${s.status} (${hashInfo})${modeInfo} — ${s.label}`);
67
+ if (s.missing.length > 0) {
68
+ console.log(` missing: ${s.missing.join(", ")}`);
69
+ }
70
+ }
71
+ }
72
+
73
+ if (args.includes("--status")) {
74
+ printStatus();
75
+ process.exit(0);
76
+ }
77
+
78
+ // Write guard: refuse to RECORD approvals in a tree whose package is not
79
+ // resolvable (the raw template / a pre-stamp tree). Approvals belong to a
80
+ // generated project; writing qa/approvals.json into the template pollutes the
81
+ // template itself. Read-only --status (above) stays available anywhere. Applies
82
+ // to every write operation below (single approve, express lane, reopen).
83
+ function refuseIfUnresolvable() {
84
+ if (isPackageResolvable(ROOT)) return;
85
+ console.error(
86
+ "error: this tree's package is not resolvable (composeApp/build.gradle.kts namespace is missing or still a placeholder) — " +
87
+ "this looks like the raw template or a pre-stamp tree. Approvals are recorded in a generated project; refusing to write qa/approvals.json here.",
88
+ );
89
+ process.exit(1);
90
+ }
91
+
92
+ if (args.includes("--accept-defaults")) {
93
+ refuseIfUnresolvable();
94
+ const { approved, skipped } = approveAllDefaults(ROOT);
95
+ for (const id of approved) {
96
+ console.log(`✓ approved ${id} [defaults-accepted]`);
97
+ }
98
+ for (const s of skipped) {
99
+ console.log(`→ skipped ${s.id}: ${s.reason}`);
100
+ }
101
+ console.log(`\n${approved.length} approved (defaults-accepted), ${skipped.length} skipped (unresolvable).`);
102
+ process.exit(0);
103
+ }
104
+
105
+ const reopenFlagIdx = args.indexOf("--reopen");
106
+ if (reopenFlagIdx !== -1) {
107
+ refuseIfUnresolvable();
108
+ const artifactId = args[reopenFlagIdx + 1];
109
+ if (!artifactId) {
110
+ console.error("usage: node qa/approve.mjs --reopen <artifact>");
111
+ process.exit(1);
112
+ }
113
+ const result = reopenArtifact(ROOT, artifactId);
114
+ if (!result.ok) {
115
+ console.error(`error: ${result.reason}`);
116
+ process.exit(1);
117
+ }
118
+ console.log(`↺ reopened ${result.artifact} for redesign — at ${result.reopenedAt}`);
119
+ process.exit(0);
120
+ }
121
+
122
+ if (args.length === 0) {
123
+ const ids = listGovernedArtifacts(ROOT).map((a) => a.id);
124
+ console.error(
125
+ "usage: node qa/approve.mjs <artifact> | --status | --accept-defaults | --reopen <artifact>\n" +
126
+ ` valid artifacts: ${ids.length > 0 ? ids.join(", ") : "(none resolved in this project)"}`,
127
+ );
128
+ process.exit(1);
129
+ }
130
+
131
+ refuseIfUnresolvable();
132
+
133
+ const artifactId = args[0];
134
+ const result = approveArtifact(ROOT, artifactId);
135
+ if (!result.ok) {
136
+ console.error(`error: ${result.reason}`);
137
+ process.exit(1);
138
+ }
139
+ console.log(`✓ approved ${result.artifact} — hash ${shortHash(result.hash)}, at ${result.approvedAt}`);
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env node
2
+ // The architecture-doc generator/checker — thin shell over qa/lib/arch-doc.mjs,
3
+ // mirroring qa/approve.mjs's CLI-over-library split.
4
+ //
5
+ // node qa/arch-doc.mjs regenerate docs/ARCHITECTURE.md's
6
+ // `cmp:generated` sections in place from a
7
+ // real tree walk — touches NOTHING outside
8
+ // the markers
9
+ // node qa/arch-doc.mjs --check exit nonzero (naming every stale/missing
10
+ // section) if regenerating would change the
11
+ // file; never writes
12
+ //
13
+ // This file has no logic of its own — every decision (what each section
14
+ // derives from, the marker grammar) lives in qa/lib/arch-doc.mjs. The verify
15
+ // lane's `archDoc` step (qa/verify.mjs) calls the SAME library in --check mode.
16
+
17
+ import path from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+
20
+ import { ARCH_DOC_REL_PATH, regenerateArchDoc, writeArchDoc } from "./lib/arch-doc.mjs";
21
+
22
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
23
+ const checkOnly = process.argv.includes("--check");
24
+
25
+ function reportUnknown(result) {
26
+ if (result.unknownSections.length === 0) return false;
27
+ console.error(
28
+ `error: ${ARCH_DOC_REL_PATH} has cmp:generated marker(s) with no registered generator: ${result.unknownSections.join(", ")}`,
29
+ );
30
+ return true;
31
+ }
32
+
33
+ if (checkOnly) {
34
+ const result = regenerateArchDoc(ROOT);
35
+ if (!result.ok) {
36
+ console.error(`error: ${result.reason}`);
37
+ process.exit(1);
38
+ }
39
+ if (reportUnknown(result)) process.exit(1);
40
+
41
+ const stale = result.changed || result.missingSections.length > 0;
42
+ if (!stale) {
43
+ console.log(`✓ ${ARCH_DOC_REL_PATH} is fresh — every generated section matches the tree.`);
44
+ process.exit(0);
45
+ }
46
+
47
+ console.error(`✗ ${ARCH_DOC_REL_PATH} is stale:`);
48
+ for (const id of result.changedSections) {
49
+ console.error(` [${id}] regenerating would change this section — the tree no longer matches the doc`);
50
+ }
51
+ for (const id of result.missingSections) {
52
+ console.error(` [${id}] marker missing from the doc entirely — never generated`);
53
+ }
54
+ console.error("Run: node qa/arch-doc.mjs");
55
+ process.exit(1);
56
+ }
57
+
58
+ const result = writeArchDoc(ROOT);
59
+ if (!result.ok) {
60
+ console.error(`error: ${result.reason}`);
61
+ process.exit(1);
62
+ }
63
+ if (reportUnknown(result)) process.exit(1);
64
+
65
+ if (!result.wrote) {
66
+ console.log(`✓ ${ARCH_DOC_REL_PATH} already fresh — nothing to regenerate.`);
67
+ process.exit(0);
68
+ }
69
+ console.log(`✓ regenerated ${ARCH_DOC_REL_PATH} — updated section(s): ${result.changedSections.join(", ")}`);
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env node
2
+ // The comments CLI — thin shell over qa/lib/comments.mjs (mirrors qa/approve.mjs).
3
+ //
4
+ // node qa/comment.mjs --list [--open] readable table of the ledger,
5
+ // including resolution notes
6
+ // node qa/comment.mjs --resolve <id> --note "..." resolves a comment as "agent-cli",
7
+ // recording the note explaining
8
+ // what changed as a result
9
+ //
10
+ // This file has NO logic of its own — every decision (validation, ids, the ledger)
11
+ // lives in qa/lib/comments.mjs. Adding a comment is a console/human action (the
12
+ // console's `POST /api/comment` calls the same library through a bridge); this CLI
13
+ // covers the agent's side of the loop of record: observe, act, resolve.
14
+
15
+ import path from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+
18
+ import { listComments, resolveComment } from "./lib/comments.mjs";
19
+
20
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
21
+ const args = process.argv.slice(2);
22
+
23
+ function argValue(flag) {
24
+ const idx = args.indexOf(flag);
25
+ if (idx === -1 || idx === args.length - 1) return undefined;
26
+ return args[idx + 1];
27
+ }
28
+
29
+ function printList(openOnly) {
30
+ let result;
31
+ try {
32
+ result = listComments(ROOT, openOnly ? { status: "open" } : {});
33
+ } catch (err) {
34
+ console.error(`error: ${err.message}`);
35
+ process.exit(1);
36
+ }
37
+ if (result.comments.length === 0) {
38
+ console.log(openOnly ? "No open comments." : "No comments recorded yet.");
39
+ return;
40
+ }
41
+ console.log(`Comments${openOnly ? " (open only)" : ""}:\n`);
42
+ for (const c of result.comments) {
43
+ const mark = c.status === "resolved" ? "✓" : "→";
44
+ console.log(`${mark} ${c.id} [${c.target?.type ?? "unknown"}] ${c.status} — by ${c.author} at ${c.createdAt}`);
45
+ console.log(` ${c.text}`);
46
+ if (c.status === "resolved") {
47
+ const note = c.resolutionNote ? `: ${c.resolutionNote}` : " (no note)";
48
+ console.log(` resolved by ${c.resolvedBy} at ${c.resolvedAt}${note}`);
49
+ }
50
+ }
51
+ }
52
+
53
+ if (args.includes("--list")) {
54
+ printList(args.includes("--open"));
55
+ process.exit(0);
56
+ }
57
+
58
+ if (args.includes("--resolve")) {
59
+ const id = argValue("--resolve");
60
+ if (!id) {
61
+ console.error('usage: node qa/comment.mjs --resolve <id> --note "..."');
62
+ process.exit(1);
63
+ }
64
+ const note = argValue("--note");
65
+ const result = resolveComment(ROOT, id, { note, author: "agent-cli" });
66
+ if (!result.ok) {
67
+ console.error(`error: ${result.reason}`);
68
+ process.exit(1);
69
+ }
70
+ const noteSuffix = result.comment.resolutionNote ? ` — ${result.comment.resolutionNote}` : "";
71
+ console.log(`✓ resolved ${result.comment.id}${noteSuffix}`);
72
+ process.exit(0);
73
+ }
74
+
75
+ console.error('usage: node qa/comment.mjs --list [--open] | --resolve <id> --note "..."');
76
+ process.exit(1);
@@ -0,0 +1,4 @@
1
+ {
2
+ "schema": "cmp-comments/1",
3
+ "comments": []
4
+ }
@@ -6,6 +6,12 @@
6
6
  # AppShell.kt's navItemTag (lowercase, non-[a-z0-9] runs collapsed to "_", trimmed);
7
7
  # keep these ids in sync with it if the configured tabs change.
8
8
  #
9
+ # SETTLE RULE for the behaviour steps you add: any assertion that follows an interaction
10
+ # triggering an async state change (search input, a toggle that persists, a load) must use
11
+ # extendedWaitUntil — the ViewModel round-trip passes through a brief Loading arm, and a
12
+ # lane-loaded emulator stretches that window past a bare assert's patience (a real false
13
+ # red from the field). Bare assertVisible is for static post-navigation elements only.
14
+ #
9
15
  # Run: maestro test qa/e2e/smoke.yaml (device/emulator attached)
10
16
  # The verify lane's e2eSmoke step runs this automatically when maestro + a device are present.
11
17
  appId: __PACKAGE__
@@ -1,12 +1,12 @@
1
1
  {"children": [
2
- {"children": [
2
+ {"tag": "home_screen", "children": [
3
3
  {"tag": "home_title", "text": "Home"},
4
4
  {"children": [
5
- {"children": [
5
+ {"tag": "home_item_1", "children": [
6
6
  {"text": "Golden first"},
7
7
  {"text": "Structural baseline row one"}
8
8
  ]},
9
- {"children": [
9
+ {"tag": "home_item_2", "children": [
10
10
  {"text": "Golden second"},
11
11
  {"text": "Structural baseline row two"}
12
12
  ]}
@@ -5,7 +5,13 @@
5
5
  // touch-target-too-small — a clickable node whose width or height is below the
6
6
  // minimum touch target (default 48px; the harness dumps
7
7
  // at density 1 so px == dp there — pass a different
8
- // minTouchTargetPx for device-density trees).
8
+ // minTouchTargetPx for device-density trees). Judged on the
9
+ // FULL composed size (the tree's additive `size` field) when
10
+ // present: `bounds` is the visible slice after ancestor
11
+ // clipping, so a list row bisected by a scroll fold reports
12
+ // e.g. 371x36 while measuring 371x88 — a scroll-position
13
+ // artifact, never an a11y defect. Trees without `size`
14
+ // (older dumps) are judged on bounds, as before.
9
15
  // missing-label — a clickable node with no text, no contentDescription,
10
16
  // and no descendant text: nothing for a screen reader.
11
17
  // Rules (warnings):
@@ -61,17 +67,20 @@ export function auditA11y(tree, opts = {}) {
61
67
 
62
68
  let violated = false;
63
69
 
70
+ // Judge the FULL composed size when the dump carries it (`size` is unclipped; `bounds`
71
+ // is the visible slice after ancestor clipping — see the rule doc above). max() keeps
72
+ // the check honest either way: a genuinely small target is small in both.
64
73
  const b = node.bounds;
65
- if (
66
- b &&
67
- typeof b.width === "number" &&
68
- typeof b.height === "number" &&
69
- (b.width < minTouchTargetPx || b.height < minTouchTargetPx)
70
- ) {
74
+ const s = node.size;
75
+ const dim = (bv, sv) =>
76
+ Math.max(typeof bv === "number" ? bv : -1, typeof sv === "number" ? sv : -1);
77
+ const width = dim(b && b.width, s && s.width);
78
+ const height = dim(b && b.height, s && s.height);
79
+ if (width >= 0 && height >= 0 && (width < minTouchTargetPx || height < minTouchTargetPx)) {
71
80
  violations.push({
72
81
  ...entryBase,
73
82
  rule: "touch-target-too-small",
74
- detail: `clickable node is ${b.width}x${b.height}px; minimum touch target is ${minTouchTargetPx}x${minTouchTargetPx}px`,
83
+ detail: `clickable node is ${width}x${height}px; minimum touch target is ${minTouchTargetPx}x${minTouchTargetPx}px`,
75
84
  });
76
85
  violated = true;
77
86
  }