create-cmp-cli 0.5.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.
- package/README.md +25 -9
- package/llms.txt +16 -1
- package/package.json +8 -3
- package/src/lib/tabs.mjs +113 -0
- package/template/AGENTS.md +8 -0
- package/template/CLAUDE.md +32 -0
- package/template/README.md +5 -0
- package/template/composeApp/build.gradle.kts +53 -0
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewDaemon.kt +219 -0
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewHarness.kt +265 -0
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewRegistry.kt +61 -0
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewSemanticsJson.kt +83 -0
- package/template/manifest.json +4 -2
- package/template/qa/lib/a11y.mjs +104 -0
- package/template/qa/lib/render.mjs +254 -0
- package/template/qa/lib/tree.mjs +108 -0
- package/template/qa/preview-gallery.mjs +107 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// render.mjs — deterministic SVG wireframe of a CMP inspector tree.
|
|
2
|
+
//
|
|
3
|
+
// The "structural twin" of a pixel preview: every node with a non-zero visual
|
|
4
|
+
// footprint becomes a rect; token-annotated nodes are visually distinct and carry
|
|
5
|
+
// a small chip with their resolved values ("radius 16 · pad 16"); clickable nodes
|
|
6
|
+
// get a distinct outline; testTags render as small mono labels; text nodes show
|
|
7
|
+
// their text. An optional a11y audit result overlays violations in a danger style.
|
|
8
|
+
//
|
|
9
|
+
// SVG is structured TEXT, not pixels — safe for model context, works for ANY
|
|
10
|
+
// source (file / live / uiautomator). Output is fully deterministic: no dates,
|
|
11
|
+
// no randomness; the same tree + opts always yields byte-identical SVG.
|
|
12
|
+
//
|
|
13
|
+
// Pure logic only — no fs, no MCP imports; the server wires file I/O around it.
|
|
14
|
+
|
|
15
|
+
import { walk } from "./tree.mjs";
|
|
16
|
+
|
|
17
|
+
const FIT_WIDTH = 740; // target drawing width when no explicit scale is given
|
|
18
|
+
const MARGIN = 16;
|
|
19
|
+
const LEGEND_H = 30;
|
|
20
|
+
const FOOTER_H = 24;
|
|
21
|
+
|
|
22
|
+
const STYLE = {
|
|
23
|
+
plain: { fill: "none", stroke: "#9CA3AF", strokeWidth: 1, dash: null },
|
|
24
|
+
tokenized: { fill: "rgba(0,185,107,0.10)", stroke: "#00B96B", strokeWidth: 1.5, dash: null },
|
|
25
|
+
clickableStroke: "#2563EB",
|
|
26
|
+
dangerStroke: "#DC2626",
|
|
27
|
+
chipFill: "#0A2540",
|
|
28
|
+
chipText: "#FFFFFF",
|
|
29
|
+
tagText: "#6B7280",
|
|
30
|
+
nodeText: "#1A1A1A",
|
|
31
|
+
footerText: "#6B7280",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Render a tree (full {root} document or bare node) as an SVG wireframe string.
|
|
36
|
+
*
|
|
37
|
+
* @param {object} tree
|
|
38
|
+
* @param {object} [opts]
|
|
39
|
+
* @param {object} [opts.a11y] an auditA11y() result — its violations are overlaid
|
|
40
|
+
* in the danger style (matched to nodes by path).
|
|
41
|
+
* @param {number} [opts.maxDepth] only draw nodes up to this depth (root = 0).
|
|
42
|
+
* @param {number} [opts.scale] explicit px scale; default fits root width to ~740.
|
|
43
|
+
* @returns {string} the SVG document.
|
|
44
|
+
*/
|
|
45
|
+
export function renderTreeSvg(tree, opts = {}) {
|
|
46
|
+
const root = tree && tree.root ? tree.root : tree;
|
|
47
|
+
if (!root || typeof root !== "object") {
|
|
48
|
+
throw new Error("renderTreeSvg: tree has no root node.");
|
|
49
|
+
}
|
|
50
|
+
const schemaVersion = (tree && tree.schemaVersion) ?? 1;
|
|
51
|
+
const source = (tree && tree.source) ?? "unknown";
|
|
52
|
+
|
|
53
|
+
const rootW = boundsDim(root.bounds, "width") || 360;
|
|
54
|
+
const rootH = boundsDim(root.bounds, "height") || 640;
|
|
55
|
+
const scale = typeof opts.scale === "number" && opts.scale > 0 ? opts.scale : FIT_WIDTH / rootW;
|
|
56
|
+
const maxDepth =
|
|
57
|
+
typeof opts.maxDepth === "number" && opts.maxDepth >= 0 ? opts.maxDepth : Infinity;
|
|
58
|
+
|
|
59
|
+
// Violations by node path (danger overlay).
|
|
60
|
+
const violationsByPath = new Map();
|
|
61
|
+
if (opts.a11y && Array.isArray(opts.a11y.violations)) {
|
|
62
|
+
for (const v of opts.a11y.violations) {
|
|
63
|
+
if (!violationsByPath.has(v.path)) violationsByPath.set(v.path, []);
|
|
64
|
+
violationsByPath.get(v.path).push(v.rule);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const drawW = rootW * scale;
|
|
69
|
+
const drawH = rootH * scale;
|
|
70
|
+
const svgW = Math.ceil(drawW + MARGIN * 2);
|
|
71
|
+
const svgH = Math.ceil(LEGEND_H + drawH + FOOTER_H + MARGIN * 2);
|
|
72
|
+
const originX = MARGIN;
|
|
73
|
+
const originY = LEGEND_H + MARGIN / 2;
|
|
74
|
+
|
|
75
|
+
let nodeCount = 0;
|
|
76
|
+
const body = [];
|
|
77
|
+
|
|
78
|
+
for (const { node, path } of walk(root)) {
|
|
79
|
+
nodeCount++;
|
|
80
|
+
if (depthOf(path) > maxDepth) continue;
|
|
81
|
+
const b = node.bounds;
|
|
82
|
+
const w = boundsDim(b, "width");
|
|
83
|
+
const h = boundsDim(b, "height");
|
|
84
|
+
if (!(w > 0 && h > 0)) continue; // zero-footprint nodes have nothing to draw
|
|
85
|
+
|
|
86
|
+
const x = originX + (b.x || 0) * scale;
|
|
87
|
+
const y = originY + (b.y || 0) * scale;
|
|
88
|
+
const sw = w * scale;
|
|
89
|
+
const sh = h * scale;
|
|
90
|
+
const tokenized = node.designToken != null;
|
|
91
|
+
const clickable = node.clickable === true;
|
|
92
|
+
const rules = violationsByPath.get(path);
|
|
93
|
+
|
|
94
|
+
const base = tokenized ? STYLE.tokenized : STYLE.plain;
|
|
95
|
+
body.push(
|
|
96
|
+
`<rect x="${fmt(x)}" y="${fmt(y)}" width="${fmt(sw)}" height="${fmt(sh)}" ` +
|
|
97
|
+
`fill="${base.fill}" stroke="${base.stroke}" stroke-width="${base.strokeWidth}"` +
|
|
98
|
+
`${tokenized ? ` class="tokenized"` : ""} data-path="${esc(path)}"/>`
|
|
99
|
+
);
|
|
100
|
+
if (clickable) {
|
|
101
|
+
// Distinct clickable outline, drawn just inside the node rect.
|
|
102
|
+
body.push(
|
|
103
|
+
`<rect x="${fmt(x + 1.5)}" y="${fmt(y + 1.5)}" width="${fmt(Math.max(sw - 3, 1))}" ` +
|
|
104
|
+
`height="${fmt(Math.max(sh - 3, 1))}" fill="none" stroke="${STYLE.clickableStroke}" ` +
|
|
105
|
+
`stroke-width="2" stroke-dasharray="5 3" class="clickable"/>`
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
if (rules && rules.length > 0) {
|
|
109
|
+
// Danger overlay + rule label for a11y violations.
|
|
110
|
+
body.push(
|
|
111
|
+
`<rect x="${fmt(x - 2)}" y="${fmt(y - 2)}" width="${fmt(sw + 4)}" height="${fmt(sh + 4)}" ` +
|
|
112
|
+
`fill="rgba(220,38,38,0.08)" stroke="${STYLE.dangerStroke}" stroke-width="2" class="a11y-violation"/>`
|
|
113
|
+
);
|
|
114
|
+
body.push(
|
|
115
|
+
`<text x="${fmt(x)}" y="${fmt(y - 4)}" font-family="monospace" font-size="8" ` +
|
|
116
|
+
`fill="${STYLE.dangerStroke}" class="a11y-label">! ${esc([...rules].sort().join(", "))}</text>`
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
if (node.testTag) {
|
|
120
|
+
body.push(
|
|
121
|
+
`<text x="${fmt(x + 3)}" y="${fmt(y + 9)}" font-family="monospace" font-size="8" ` +
|
|
122
|
+
`fill="${STYLE.tagText}" class="test-tag">${esc(node.testTag)}</text>`
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
if (node.text) {
|
|
126
|
+
body.push(
|
|
127
|
+
`<text x="${fmt(x + 3)}" y="${fmt(y + sh / 2 + 3)}" font-family="sans-serif" font-size="10" ` +
|
|
128
|
+
`fill="${STYLE.nodeText}" class="node-text">${esc(truncate(node.text, 48))}</text>`
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
if (tokenized) {
|
|
132
|
+
const chip = tokenChip(node.designToken);
|
|
133
|
+
if (chip) {
|
|
134
|
+
const chipW = chip.length * 4.6 + 8;
|
|
135
|
+
const chipY = y + sh - 12;
|
|
136
|
+
body.push(
|
|
137
|
+
`<rect x="${fmt(x + 2)}" y="${fmt(chipY)}" width="${fmt(chipW)}" height="11" rx="5" ` +
|
|
138
|
+
`fill="${STYLE.chipFill}" opacity="0.85" class="token-chip"/>`
|
|
139
|
+
);
|
|
140
|
+
body.push(
|
|
141
|
+
`<text x="${fmt(x + 6)}" y="${fmt(chipY + 8.5)}" font-family="monospace" font-size="7.5" ` +
|
|
142
|
+
`fill="${STYLE.chipText}" class="token-chip-text">${esc(chip)}</text>`
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const legend = legendRow(opts.a11y != null);
|
|
149
|
+
const footer =
|
|
150
|
+
`<text x="${MARGIN}" y="${svgH - 8}" font-family="monospace" font-size="10" ` +
|
|
151
|
+
`fill="${STYLE.footerText}" class="footer">${nodeCount} nodes · ${esc(source)} · schemaVersion ${schemaVersion}</text>`;
|
|
152
|
+
|
|
153
|
+
return [
|
|
154
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${svgW}" height="${svgH}" viewBox="0 0 ${svgW} ${svgH}">`,
|
|
155
|
+
`<rect x="0" y="0" width="${svgW}" height="${svgH}" fill="#F7F9FC"/>`,
|
|
156
|
+
legend,
|
|
157
|
+
...body,
|
|
158
|
+
footer,
|
|
159
|
+
`</svg>`,
|
|
160
|
+
``,
|
|
161
|
+
].join("\n");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// --- helpers ----------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
// Legend row across the top: what each visual style means.
|
|
167
|
+
function legendRow(withA11y) {
|
|
168
|
+
const items = [];
|
|
169
|
+
let x = MARGIN;
|
|
170
|
+
const y = 8;
|
|
171
|
+
const swatch = (fill, stroke, dash, label, cls) => {
|
|
172
|
+
const parts = [
|
|
173
|
+
`<rect x="${fmt(x)}" y="${y}" width="14" height="10" fill="${fill}" stroke="${stroke}" ` +
|
|
174
|
+
`stroke-width="1.5"${dash ? ` stroke-dasharray="${dash}"` : ""} class="legend-${cls}"/>`,
|
|
175
|
+
`<text x="${fmt(x + 18)}" y="${y + 9}" font-family="sans-serif" font-size="9" fill="#1A1A1A">${label}</text>`,
|
|
176
|
+
];
|
|
177
|
+
x += 18 + label.length * 5.2 + 14;
|
|
178
|
+
items.push(...parts);
|
|
179
|
+
};
|
|
180
|
+
swatch("none", STYLE.plain.stroke, null, "node", "node");
|
|
181
|
+
swatch(STYLE.tokenized.fill, STYLE.tokenized.stroke, null, "tokenized", "tokenized");
|
|
182
|
+
swatch("none", STYLE.clickableStroke, "5 3", "clickable", "clickable");
|
|
183
|
+
if (withA11y) swatch("rgba(220,38,38,0.08)", STYLE.dangerStroke, null, "a11y violation", "a11y");
|
|
184
|
+
return `<g class="legend">${items.join("")}</g>`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// "radius 16 · pad 16" — compact resolved-values chip, sorted keys for determinism.
|
|
188
|
+
function tokenChip(dt) {
|
|
189
|
+
if (!dt || !dt.resolved || typeof dt.resolved !== "object") return null;
|
|
190
|
+
const keys = Object.keys(dt.resolved).sort();
|
|
191
|
+
if (keys.length === 0) return null;
|
|
192
|
+
const parts = keys.map((k) => {
|
|
193
|
+
const v = String(dt.resolved[k]).replace(/(dp|sp)$/i, "");
|
|
194
|
+
return `${abbrev(k)} ${v}`.trim();
|
|
195
|
+
});
|
|
196
|
+
return truncate(parts.join(" · "), 64);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const ABBREV = {
|
|
200
|
+
padding: "pad",
|
|
201
|
+
elevation: "elev",
|
|
202
|
+
fontSize: "font",
|
|
203
|
+
height: "h",
|
|
204
|
+
width: "w",
|
|
205
|
+
statusBarPadding: "statusBar",
|
|
206
|
+
navBarPadding: "navBar",
|
|
207
|
+
};
|
|
208
|
+
function abbrev(key) {
|
|
209
|
+
return ABBREV[key] ?? key;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function depthOf(path) {
|
|
213
|
+
return (path.match(/\.children\[/g) || []).length;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function boundsDim(b, key) {
|
|
217
|
+
return b && typeof b[key] === "number" ? b[key] : 0;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function fmt(n) {
|
|
221
|
+
// Fixed one-decimal formatting: deterministic and diff-friendly.
|
|
222
|
+
return (Math.round(n * 10) / 10).toString();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function truncate(s, max) {
|
|
226
|
+
const str = String(s);
|
|
227
|
+
return str.length <= max ? str : str.slice(0, max - 1) + "…";
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function esc(s) {
|
|
231
|
+
return String(s)
|
|
232
|
+
.replace(/&/g, "&")
|
|
233
|
+
.replace(/</g, "<")
|
|
234
|
+
.replace(/>/g, ">")
|
|
235
|
+
.replace(/"/g, """);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Count the nodes renderTreeSvg would draw as rects (non-zero footprint within
|
|
240
|
+
* maxDepth) plus the total node count — used by the tool result.
|
|
241
|
+
*/
|
|
242
|
+
export function countRenderable(tree, opts = {}) {
|
|
243
|
+
const root = tree && tree.root ? tree.root : tree;
|
|
244
|
+
const maxDepth =
|
|
245
|
+
typeof opts.maxDepth === "number" && opts.maxDepth >= 0 ? opts.maxDepth : Infinity;
|
|
246
|
+
let total = 0;
|
|
247
|
+
let drawn = 0;
|
|
248
|
+
for (const { node, path } of walk(root)) {
|
|
249
|
+
total++;
|
|
250
|
+
if (depthOf(path) > maxDepth) continue;
|
|
251
|
+
if (boundsDim(node.bounds, "width") > 0 && boundsDim(node.bounds, "height") > 0) drawn++;
|
|
252
|
+
}
|
|
253
|
+
return { total, drawn };
|
|
254
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// tree.mjs — pure helpers for loading and walking a CMP inspector tree.
|
|
2
|
+
// No MCP imports here: everything is unit-testable in isolation.
|
|
3
|
+
//
|
|
4
|
+
// The JSON tree contract (schemaVersion 1):
|
|
5
|
+
// { schemaVersion, source, root: <Node> }
|
|
6
|
+
// Node = { testTag, text, contentDescription, bounds:{x,y,width,height},
|
|
7
|
+
// designToken: { tokens:string[], resolved:{[k]:string} } | null,
|
|
8
|
+
// children: Node[] }
|
|
9
|
+
//
|
|
10
|
+
// Additive optional fields (still schemaVersion 1 — absent on old trees, so every
|
|
11
|
+
// consumer must treat them as optional):
|
|
12
|
+
// role: string|null — semantics Role (e.g. "Button", "Checkbox")
|
|
13
|
+
// clickable: boolean — presence of the OnClick semantics action
|
|
14
|
+
// disabled: boolean — presence of the Disabled semantics property
|
|
15
|
+
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Load a tree from a filesystem path, a JSON string, or an already-parsed object.
|
|
20
|
+
* Validates the minimal shape (schemaVersion + root) and throws a clear,
|
|
21
|
+
* caller-facing Error (never a raw fs/JSON stack) on failure.
|
|
22
|
+
*
|
|
23
|
+
* @param {string|object} pathOrObj
|
|
24
|
+
* @returns {object} the parsed tree ({ schemaVersion, source, root })
|
|
25
|
+
*/
|
|
26
|
+
export function loadTree(pathOrObj) {
|
|
27
|
+
if (pathOrObj == null) {
|
|
28
|
+
throw new Error("loadTree: no tree provided (path or object is null/undefined).");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let tree;
|
|
32
|
+
if (typeof pathOrObj === "object") {
|
|
33
|
+
tree = pathOrObj;
|
|
34
|
+
} else if (typeof pathOrObj === "string") {
|
|
35
|
+
const raw = readOrParse(pathOrObj);
|
|
36
|
+
tree = raw;
|
|
37
|
+
} else {
|
|
38
|
+
throw new Error(`loadTree: unsupported input type '${typeof pathOrObj}'.`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!tree || typeof tree !== "object") {
|
|
42
|
+
throw new Error("loadTree: tree is not an object.");
|
|
43
|
+
}
|
|
44
|
+
if (!tree.root || typeof tree.root !== "object") {
|
|
45
|
+
throw new Error("loadTree: tree has no 'root' node (expected { schemaVersion, source, root }).");
|
|
46
|
+
}
|
|
47
|
+
return tree;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// If the string looks like a JSON document, parse it directly; otherwise treat
|
|
51
|
+
// it as a filesystem path and read+parse. This lets callers pass either.
|
|
52
|
+
function readOrParse(str) {
|
|
53
|
+
const trimmed = str.trim();
|
|
54
|
+
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(trimmed);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
throw new Error(`loadTree: input looked like JSON but failed to parse: ${err.message}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
let contents;
|
|
62
|
+
try {
|
|
63
|
+
contents = readFileSync(str, "utf8");
|
|
64
|
+
} catch (err) {
|
|
65
|
+
if (err.code === "ENOENT") {
|
|
66
|
+
throw new Error(`loadTree: tree file not found: ${str}`);
|
|
67
|
+
}
|
|
68
|
+
throw new Error(`loadTree: could not read tree file '${str}': ${err.message}`);
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
return JSON.parse(contents);
|
|
72
|
+
} catch (err) {
|
|
73
|
+
throw new Error(`loadTree: tree file '${str}' is not valid JSON: ${err.message}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Depth-first walk yielding every node with a stable, dotted path.
|
|
79
|
+
* Root's path is "root"; children are "root.children[0]", etc.
|
|
80
|
+
*
|
|
81
|
+
* @param {object} tree a full tree ({root}) OR a bare node.
|
|
82
|
+
* @yields {{ node: object, path: string }}
|
|
83
|
+
*/
|
|
84
|
+
export function* walk(tree) {
|
|
85
|
+
const root = tree && tree.root ? tree.root : tree;
|
|
86
|
+
if (!root || typeof root !== "object") return;
|
|
87
|
+
yield* walkNode(root, "root");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function* walkNode(node, path) {
|
|
91
|
+
yield { node, path };
|
|
92
|
+
const children = Array.isArray(node.children) ? node.children : [];
|
|
93
|
+
for (let i = 0; i < children.length; i++) {
|
|
94
|
+
yield* walkNode(children[i], `${path}.children[${i}]`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Find the first node with the given testTag. Returns { node, path } or null.
|
|
100
|
+
* @param {object} tree
|
|
101
|
+
* @param {string} tag
|
|
102
|
+
*/
|
|
103
|
+
export function findByTestTag(tree, tag) {
|
|
104
|
+
for (const entry of walk(tree)) {
|
|
105
|
+
if (entry.node.testTag === tag) return entry;
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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}`);
|