create-cmp-cli 0.4.0 → 0.6.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.
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env node
2
+ // preview-gallery.mjs — build a self-contained HTML gallery from renderScreens output.
3
+ //
4
+ // node qa/preview-gallery.mjs [previewsDir]
5
+ //
6
+ // Reads <previewsDir>/manifest.json (default composeApp/build/previews — the output of
7
+ // `./gradlew :composeApp:renderScreens`), renders each screen's tree.json to a wireframe
8
+ // SVG with the vendored inspector render lib (structure for the AI), and embeds the
9
+ // harness PNGs (pixels for the human) into ONE index.html — no server, no device, open
10
+ // the file. Also drops <id>/wireframe.svg next to each tree.
11
+ //
12
+ // Zero dependencies beyond qa/lib (vendored, pure logic) — works without the create-cmp
13
+ // plugin installed, like every other qa/ script.
14
+ import { readFileSync, writeFileSync } from "node:fs";
15
+ import { join, resolve, dirname } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+
18
+ const HERE = dirname(fileURLToPath(import.meta.url));
19
+ const previewsDir = resolve(
20
+ process.argv[2] || join(HERE, "..", "composeApp", "build", "previews"),
21
+ );
22
+
23
+ const { renderTreeSvg } = await import(new URL("./lib/render.mjs", import.meta.url));
24
+ const { auditA11y } = await import(new URL("./lib/a11y.mjs", import.meta.url));
25
+
26
+ const manifest = JSON.parse(readFileSync(join(previewsDir, "manifest.json"), "utf8"));
27
+ const { width, height, pngScale } = manifest.viewport;
28
+
29
+ const cards = [];
30
+ for (const screen of manifest.screens) {
31
+ const tree = JSON.parse(readFileSync(join(previewsDir, screen.tree), "utf8"));
32
+ const audit = auditA11y(tree);
33
+ const svg = renderTreeSvg(tree, { a11y: audit });
34
+ writeFileSync(join(previewsDir, screen.id, "wireframe.svg"), svg);
35
+
36
+ const png = readFileSync(join(previewsDir, screen.png));
37
+ const summary = summarize(tree);
38
+ cards.push({ screen, svg, pngB64: png.toString("base64"), audit, summary });
39
+ console.log(
40
+ `${screen.id}: ${summary.nodes} nodes, ${summary.tokenized} tokenized, ` +
41
+ `${summary.tagged} tagged, a11y ${audit.pass ? "PASS" : audit.violations.length + " violation(s)"}`,
42
+ );
43
+ }
44
+
45
+ function summarize(tree) {
46
+ let nodes = 0, tokenized = 0, tagged = 0;
47
+ (function walk(n) {
48
+ nodes++;
49
+ if (n.designToken) tokenized++;
50
+ if (n.testTag) tagged++;
51
+ (n.children || []).forEach(walk);
52
+ })(tree.root);
53
+ return { nodes, tokenized, tagged };
54
+ }
55
+
56
+ const esc = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
57
+
58
+ const html = `<!doctype html>
59
+ <meta charset="utf-8">
60
+ <title>__APP_NAME__ — screen previews (headless)</title>
61
+ <style>
62
+ :root { color-scheme: light; }
63
+ body { font-family: -apple-system, system-ui, sans-serif; margin: 0; background: #F7F9FC; color: #1A1A1A; }
64
+ header { padding: 20px 28px 8px; }
65
+ header h1 { margin: 0 0 4px; font-size: 20px; }
66
+ header p { margin: 0; color: #6B7280; font-size: 13px; }
67
+ .grid { display: flex; flex-wrap: wrap; gap: 24px; padding: 20px 28px 40px; }
68
+ .card { background: #fff; border: 1px solid #E5E7EB; border-radius: 16px; padding: 16px; }
69
+ .card h2 { margin: 0 0 2px; font-size: 15px; }
70
+ .meta { color: #6B7280; font-size: 12px; margin: 0 0 10px; }
71
+ .meta .fail { color: #DC2626; font-weight: 600; }
72
+ .meta .pass { color: #16A34A; font-weight: 600; }
73
+ .panes { display: flex; gap: 12px; align-items: flex-start; }
74
+ .panes img { width: ${Math.round(width * 0.62)}px; border: 1px solid #E5E7EB; border-radius: 12px; display: block; }
75
+ .panes .wire svg { width: ${Math.round(width * 0.78)}px; height: auto; display: block; }
76
+ .wire { border: 1px dashed #C8D0DA; border-radius: 12px; overflow: hidden; }
77
+ .lbl { font-size: 10px; letter-spacing: .06em; text-transform: uppercase; color: #9CA3AF; margin: 0 0 4px; }
78
+ </style>
79
+ <header>
80
+ <h1>__APP_NAME__ — screen previews</h1>
81
+ <p>Rendered headlessly (no device/emulator) by <code>:composeApp:renderScreens</code> —
82
+ ${width}×${height}dp, PNG @${pngScale}x · pixels for humans, wireframe+tree for the AI ·
83
+ regenerate: <code>./gradlew :composeApp:renderScreens && node qa/preview-gallery.mjs</code></p>
84
+ </header>
85
+ <div class="grid">
86
+ ${cards
87
+ .map(
88
+ ({ screen, svg, pngB64, audit, summary }) => ` <div class="card">
89
+ <h2>${esc(screen.title)}</h2>
90
+ <p class="meta">id <code>${esc(screen.id)}</code> · ${summary.nodes} nodes ·
91
+ ${summary.tokenized} tokenized · ${summary.tagged} tagged ·
92
+ a11y <span class="${audit.pass ? "pass" : "fail"}">${
93
+ audit.pass ? "PASS" : esc(audit.violations.length + " violation(s)")
94
+ }</span></p>
95
+ <div class="panes">
96
+ <div><p class="lbl">pixels</p><img alt="${esc(screen.id)} pixels" src="data:image/png;base64,${pngB64}"></div>
97
+ <div><p class="lbl">structure</p><div class="wire">${svg}</div></div>
98
+ </div>
99
+ </div>`,
100
+ )
101
+ .join("\n")}
102
+ </div>
103
+ `;
104
+
105
+ const outFile = join(previewsDir, "index.html");
106
+ writeFileSync(outFile, html);
107
+ console.log(`gallery -> ${outFile}`);
@@ -5,15 +5,19 @@
5
5
 
6
6
  ## Architecture invariants
7
7
 
8
- - **ARCH-01** — Given any file in `presentation`, When its imports are inspected, Then none
9
- resolve into the `data` layer (presentation depends on domain only).
10
- - **ARCH-02** Given any file in `domain`, When its imports are inspected, Then none resolve
11
- into `presentation`, `data`, or `di`, and none import Compose, Koin, or platform types
12
- (domain is pure Kotlin).
8
+ - **ARCH-01** — Given any file in `presentation`, When its imports **and fully-qualified
9
+ inline references** are inspected, Then none resolve into the `data` layer (presentation
10
+ depends on domain only; qualifying the name inline instead of importing is the same
11
+ violation).
12
+ - **ARCH-02** — Given any file in `domain`, When its imports **and fully-qualified inline
13
+ references** are inspected, Then none resolve into `presentation`, `data`, or `di`, and
14
+ none reference Compose, Koin, or platform types (domain is pure Kotlin).
13
15
  - **ARCH-03** — Given any ViewModel class, When the test sources are inspected, Then a
14
16
  corresponding `*ViewModelTest` exists (no untested presentation state).
15
- - **ARCH-04** — Given any file containing a `*Screen` composable, When its source is
16
- inspected, Then it declares at least one `testTag` (every screen is automation-reachable).
17
+ - **ARCH-04** — Given any file in a `presentation` feature package that contains a
18
+ `@Composable` function, When its source is inspected, Then it declares at least one
19
+ `testTag` (scoped by content, not `*Screen.kt` filename — split `Content.kt` UI files are
20
+ covered, ViewModel-only files are exempt).
17
21
  - **ARCH-05** — Given any file outside `presentation/theme`, When its source is inspected,
18
22
  Then it constructs no literal `Color(0x…)` values (design colors come from the token
19
23
  catalog).
@@ -28,3 +32,7 @@
28
32
  the safe-area insets owned by `BaseScreen` (edge-to-edge without overlap).
29
33
  - **SHELL-04** — Given the app renders any screen, When interactive elements are present,
30
34
  Then each is perceivable by automation: it exposes a testTag, text, or content description.
35
+ - **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` — a bare destination that
37
+ never touches inset APIs still renders under the status bar, which SHELL-03 alone cannot
38
+ catch.