alanbox 0.1.2 → 0.1.4
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/0boxer/AGENTS.md +26 -0
- package/0boxer/src/AGENTS.md +16 -0
- package/0boxer/src/cli.js +53 -0
- package/0boxer/src/commands/AGENTS.md +16 -0
- package/{0commondflowv1 → 0boxer}/src/commands/install.js +56 -0
- package/{0commondflowv1 → 1swarmer}/AGENTS.md +14 -12
- package/1swarmer/src/AGENTS.md +28 -0
- package/{0commondflowv1 → 1swarmer}/src/args.js +8 -1
- package/{0commondflowv1 → 1swarmer}/src/cli.js +27 -17
- package/1swarmer/src/commands/AGENTS.md +31 -0
- package/{0commondflowv1 → 1swarmer}/src/commands/doctor.js +2 -2
- package/1swarmer/src/commands/review-file.js +997 -0
- package/{0commondflowv1 → 1swarmer}/src/core/AGENTS.md +2 -2
- package/{0commondflowv1 → 1swarmer}/src/core/prompt-templates.js +1 -1
- package/{0commondflowv1 → 1swarmer}/src/core/storage.js +1 -1
- package/{0commondflowv1 → 1swarmer}/src/prompt/AGENTS.md +1 -1
- package/{0commondflowv1 → 1swarmer}/src/prompt/default.md +1 -1
- package/{0commondflowv1 → 1swarmer}/src/prompt/synthesizer.md +1 -1
- package/{0commondflowv1 → 1swarmer}/src/prompt/verifier.md +1 -1
- package/{0commondflowv1 → 1swarmer}/src/runner/AGENTS.md +4 -3
- package/{0commondflowv1 → 1swarmer}/src/runner/codex-runner.js +23 -3
- package/2designer/README.md +42 -0
- package/2designer/dist/cdp-engine-4AIWSWXO.js +314 -0
- package/2designer/dist/cdp-engine-4AIWSWXO.js.map +1 -0
- package/2designer/dist/cdp-engine-SG4K2BCX.js +10 -0
- package/2designer/dist/cdp-engine-SG4K2BCX.js.map +1 -0
- package/2designer/dist/chunk-7X7PTLZH.js +185 -0
- package/2designer/dist/chunk-7X7PTLZH.js.map +1 -0
- package/2designer/dist/chunk-DPOWNFOH.js +313 -0
- package/2designer/dist/chunk-DPOWNFOH.js.map +1 -0
- package/2designer/dist/chunk-ISUUIOO7.js +58 -0
- package/2designer/dist/chunk-ISUUIOO7.js.map +1 -0
- package/2designer/dist/chunk-NLYFLQ3C.js +74 -0
- package/2designer/dist/chunk-NLYFLQ3C.js.map +1 -0
- package/2designer/dist/chunk-UVKSRKXR.js +71 -0
- package/2designer/dist/chunk-UVKSRKXR.js.map +1 -0
- package/2designer/dist/cli.js +748 -0
- package/2designer/dist/cli.js.map +1 -0
- package/2designer/dist/index.d.ts +118 -0
- package/2designer/dist/index.js +37 -0
- package/2designer/dist/index.js.map +1 -0
- package/2designer/dist/playwright-engine-YXBY3KEN.js +186 -0
- package/2designer/dist/playwright-engine-YXBY3KEN.js.map +1 -0
- package/2designer/dist/playwright-engine-YXGDTSZ5.js +8 -0
- package/2designer/dist/playwright-engine-YXGDTSZ5.js.map +1 -0
- package/2designer/dist/tint-UD4CJ7S2.js +7 -0
- package/2designer/dist/tint-UD4CJ7S2.js.map +1 -0
- package/2designer/dist/tint-YN63MLVN.js +60 -0
- package/2designer/dist/tint-YN63MLVN.js.map +1 -0
- package/2designer/package.json +56 -0
- package/4reporter/README.md +24 -0
- package/4reporter/dist/cli.js +464 -0
- package/4reporter/dist/cli.js.map +1 -0
- package/4reporter/dist/index.d.ts +108 -0
- package/4reporter/dist/index.js +445 -0
- package/4reporter/dist/index.js.map +1 -0
- package/4reporter/package.json +39 -0
- package/README.md +20 -9
- package/bin/alanbox.js +11 -0
- package/bin/designer.js +10 -0
- package/bin/reporter.js +11 -0
- package/bin/swarmer.js +11 -0
- package/cli.js +178 -0
- package/hooks/hooks.json +1 -1
- package/mcp/README.md +7 -1
- package/mcp/config.toml +4 -0
- package/package.json +28 -11
- package/plugin/AGENTS.md +2 -2
- package/plugin/plugin.json +7 -7
- package/shared/AGENTS.md +15 -0
- package/shared/package-args.js +68 -0
- package/skills/AGENTS.md +9 -5
- package/skills/aitool/SKILL.md +36 -0
- package/skills/desginer/SKILL.md +142 -0
- package/skills/swarmer/SKILL.md +146 -0
- package/0commondflowv1/src/AGENTS.md +0 -26
- package/0commondflowv1/src/commands/AGENTS.md +0 -29
- package/bin/multirunagent.js +0 -15
- package/skills/aibox-swam/SKILL.md +0 -77
- package/skills/sub-codex-doctor/SKILL.md +0 -27
- package/skills/sub-codex-swarm/SKILL.md +0 -56
- /package/{0commondflowv1 → 1swarmer}/res/three-lens-review.js +0 -0
- /package/{0commondflowv1 → 1swarmer}/src/commands/info.js +0 -0
- /package/{0commondflowv1 → 1swarmer}/src/commands/swarm/auto.js +0 -0
- /package/{0commondflowv1 → 1swarmer}/src/commands/swarm/custom.js +0 -0
- /package/{0commondflowv1 → 1swarmer}/src/commands/swarm/index.js +0 -0
- /package/{0commondflowv1 → 1swarmer}/src/core/handoff.js +0 -0
- /package/{0commondflowv1 → 1swarmer}/src/core/prompt-builder.js +0 -0
- /package/{0commondflowv1 → 1swarmer}/src/core/swarm-executor.js +0 -0
- /package/{0commondflowv1 → 1swarmer}/src/core/workers.js +0 -0
- /package/{0commondflowv1 → 1swarmer}/src/core/workflow-planner.js +0 -0
- /package/{0commondflowv1 → 1swarmer}/src/core/workflow-storage.js +0 -0
- /package/{0commondflowv1 → 1swarmer}/src/prompt/reviewer.md +0 -0
- /package/{0commondflowv1 → 1swarmer}/src/runner/config.json +0 -0
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
// src/cli.ts
|
|
5
|
+
import { Command } from "commander";
|
|
6
|
+
|
|
7
|
+
// src/engine/create-engine.ts
|
|
8
|
+
function resolveEngineType(options) {
|
|
9
|
+
return options.cdp ? "cdp" : "playwright";
|
|
10
|
+
}
|
|
11
|
+
async function createEngine(options) {
|
|
12
|
+
const type = resolveEngineType(options);
|
|
13
|
+
if (type === "cdp") {
|
|
14
|
+
const { CdpEngine } = await import("./cdp-engine-4AIWSWXO.js");
|
|
15
|
+
const [host, portStr] = options.cdp.split(":");
|
|
16
|
+
const port = parseInt(portStr, 10);
|
|
17
|
+
return CdpEngine.create(host, port, options.url);
|
|
18
|
+
}
|
|
19
|
+
const { PlaywrightEngine } = await import("./playwright-engine-YXBY3KEN.js");
|
|
20
|
+
return PlaywrightEngine.create(options.url, {
|
|
21
|
+
headless: options.headless ?? true,
|
|
22
|
+
viewport: options.viewport
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// src/engine/selector.ts
|
|
27
|
+
function resolveSelector(input) {
|
|
28
|
+
if (input.startsWith("~")) {
|
|
29
|
+
const keyword = input.slice(1).trim();
|
|
30
|
+
return `[class*="${keyword}"]`;
|
|
31
|
+
}
|
|
32
|
+
return input;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// src/commands/measure.ts
|
|
36
|
+
function buildMeasureOptions(raw) {
|
|
37
|
+
if (!raw.url) throw new Error("--url is required");
|
|
38
|
+
if (!raw.selector) throw new Error("--selector is required");
|
|
39
|
+
return {
|
|
40
|
+
url: raw.url,
|
|
41
|
+
selector: resolveSelector(raw.selector),
|
|
42
|
+
frame: raw.frame ? resolveSelector(raw.frame) : void 0,
|
|
43
|
+
depth: raw.depth != null ? parseInt(raw.depth, 10) : 1,
|
|
44
|
+
cdp: raw.cdp,
|
|
45
|
+
format: raw.format ?? "json",
|
|
46
|
+
pick: raw.pick
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function formatMeasureResult(result, format) {
|
|
50
|
+
if (format === "table") {
|
|
51
|
+
const lines = [];
|
|
52
|
+
lines.push(`Selector: ${result.selector}`);
|
|
53
|
+
lines.push(`BBox: x=${result.bbox.x} y=${result.bbox.y} w=${result.bbox.width} h=${result.bbox.height}`);
|
|
54
|
+
lines.push("");
|
|
55
|
+
lines.push("Property".padEnd(30) + "Value");
|
|
56
|
+
lines.push("-".repeat(60));
|
|
57
|
+
for (const [key, val] of Object.entries(result.computedStyle)) {
|
|
58
|
+
lines.push(key.padEnd(30) + val);
|
|
59
|
+
}
|
|
60
|
+
if (result.children?.length) {
|
|
61
|
+
let printChildren2 = function(children, indent) {
|
|
62
|
+
for (const c of children) {
|
|
63
|
+
const pad = " ".repeat(indent);
|
|
64
|
+
lines.push(`${pad}<${c.tag}> .${c.className} [${c.bbox.width}x${c.bbox.height}] ${c.text ?? ""}`);
|
|
65
|
+
if (c.children?.length) {
|
|
66
|
+
printChildren2(c.children, indent + 2);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
var printChildren = printChildren2;
|
|
71
|
+
lines.push("");
|
|
72
|
+
lines.push(`Children (${result.children.length}):`);
|
|
73
|
+
printChildren2(result.children, 2);
|
|
74
|
+
}
|
|
75
|
+
return lines.join("\n");
|
|
76
|
+
}
|
|
77
|
+
return JSON.stringify(result, null, 2);
|
|
78
|
+
}
|
|
79
|
+
function pickFields(result, pick) {
|
|
80
|
+
if (pick === "bbox") return JSON.stringify(result.bbox);
|
|
81
|
+
if (pick === "children") return JSON.stringify(result.children ?? []);
|
|
82
|
+
const props = pick.split(",").map((p) => p.trim());
|
|
83
|
+
const picked = {};
|
|
84
|
+
for (const p of props) {
|
|
85
|
+
if (result.computedStyle[p] != null) {
|
|
86
|
+
picked[p] = result.computedStyle[p];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return JSON.stringify(picked);
|
|
90
|
+
}
|
|
91
|
+
async function measure(raw) {
|
|
92
|
+
const opts = buildMeasureOptions(raw);
|
|
93
|
+
const engine = await createEngine({ url: opts.url, cdp: opts.cdp });
|
|
94
|
+
try {
|
|
95
|
+
const result = await engine.measure(opts.selector, opts.depth, opts.frame);
|
|
96
|
+
if (opts.pick) {
|
|
97
|
+
console.log(pickFields(result, opts.pick));
|
|
98
|
+
} else {
|
|
99
|
+
console.log(formatMeasureResult(result, opts.format ?? "json"));
|
|
100
|
+
}
|
|
101
|
+
} finally {
|
|
102
|
+
await engine.close();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// src/commands/screenshot.ts
|
|
107
|
+
import { writeFile, mkdir } from "fs/promises";
|
|
108
|
+
import { dirname } from "path";
|
|
109
|
+
function buildScreenshotOptions(raw) {
|
|
110
|
+
if (!raw.url) throw new Error("--url is required");
|
|
111
|
+
return {
|
|
112
|
+
url: raw.url,
|
|
113
|
+
selector: raw.selector ? resolveSelector(raw.selector) : void 0,
|
|
114
|
+
output: raw.output ?? `screenshot-${Date.now()}.png`,
|
|
115
|
+
fullPage: raw.fullPage ?? false,
|
|
116
|
+
cdp: raw.cdp
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
async function screenshot(raw) {
|
|
120
|
+
const opts = buildScreenshotOptions(raw);
|
|
121
|
+
const engine = await createEngine({ url: opts.url, cdp: opts.cdp });
|
|
122
|
+
try {
|
|
123
|
+
const buf = await engine.screenshot({
|
|
124
|
+
selector: opts.selector,
|
|
125
|
+
fullPage: opts.fullPage
|
|
126
|
+
});
|
|
127
|
+
await mkdir(dirname(opts.output), { recursive: true }).catch(() => {
|
|
128
|
+
});
|
|
129
|
+
await writeFile(opts.output, buf);
|
|
130
|
+
console.log(JSON.stringify({ output: opts.output, bytes: buf.length }));
|
|
131
|
+
} finally {
|
|
132
|
+
await engine.close();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// src/commands/overlay.ts
|
|
137
|
+
import { writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
|
|
138
|
+
import { resolve, dirname as dirname2 } from "path";
|
|
139
|
+
function buildOverlayOptions(raw) {
|
|
140
|
+
if (!raw.design) throw new Error("--design is required (path to design screenshot)");
|
|
141
|
+
if (!raw.url) throw new Error("--url is required (target page URL)");
|
|
142
|
+
return {
|
|
143
|
+
designImagePath: resolve(raw.design),
|
|
144
|
+
targetUrl: raw.url,
|
|
145
|
+
cdp: raw.cdp,
|
|
146
|
+
output: raw.output,
|
|
147
|
+
selector: raw.selector ? resolveSelector(raw.selector) : void 0,
|
|
148
|
+
offsetX: raw.offsetX != null ? parseFloat(raw.offsetX) : void 0,
|
|
149
|
+
offsetY: raw.offsetY != null ? parseFloat(raw.offsetY) : void 0,
|
|
150
|
+
scale: raw.scale != null ? parseFloat(raw.scale) : void 0,
|
|
151
|
+
opacity: raw.opacity != null ? parseFloat(raw.opacity) : void 0,
|
|
152
|
+
fullPage: raw.fullPage ?? false
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
async function captureGhost(opts, params) {
|
|
156
|
+
const engine = await createEngine({ url: opts.targetUrl, cdp: opts.cdp });
|
|
157
|
+
try {
|
|
158
|
+
await engine.injectOverlay({
|
|
159
|
+
designImagePath: opts.designImagePath,
|
|
160
|
+
targetUrl: opts.targetUrl,
|
|
161
|
+
offsetX: params.offsetX,
|
|
162
|
+
offsetY: params.offsetY,
|
|
163
|
+
scale: params.scale,
|
|
164
|
+
opacity: params.opacity
|
|
165
|
+
});
|
|
166
|
+
const buf = await engine.captureOverlay({ fullPage: opts.fullPage });
|
|
167
|
+
const outputPath = opts.output ?? `overlay-${Date.now()}.png`;
|
|
168
|
+
await mkdir2(dirname2(resolve(outputPath)), { recursive: true }).catch(() => {
|
|
169
|
+
});
|
|
170
|
+
await writeFile2(outputPath, buf);
|
|
171
|
+
console.log(JSON.stringify({ output: outputPath, bytes: buf.length }));
|
|
172
|
+
} finally {
|
|
173
|
+
await engine.close();
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
async function compositeGhost(opts) {
|
|
177
|
+
const sharp2 = (await import("sharp")).default;
|
|
178
|
+
const { tintDesignImage } = await import("./tint-YN63MLVN.js");
|
|
179
|
+
const engine = await createEngine({ url: opts.targetUrl, cdp: opts.cdp });
|
|
180
|
+
let elementBuf;
|
|
181
|
+
try {
|
|
182
|
+
elementBuf = await engine.screenshot({ selector: opts.selector, fullPage: opts.fullPage });
|
|
183
|
+
} finally {
|
|
184
|
+
await engine.close();
|
|
185
|
+
}
|
|
186
|
+
const elementMeta = await sharp2(elementBuf).metadata();
|
|
187
|
+
const ew = elementMeta.width;
|
|
188
|
+
const eh = elementMeta.height;
|
|
189
|
+
const tintedBuf = await tintDesignImage(opts.designImagePath);
|
|
190
|
+
const tintedResized = await sharp2(tintedBuf).resize(ew, eh, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } }).toBuffer();
|
|
191
|
+
const ghostBuf = await sharp2(elementBuf).composite([{ input: tintedResized, blend: "over" }]).png().toBuffer();
|
|
192
|
+
const outputPath = opts.output ?? `overlay-${Date.now()}.png`;
|
|
193
|
+
await mkdir2(dirname2(resolve(outputPath)), { recursive: true }).catch(() => {
|
|
194
|
+
});
|
|
195
|
+
await writeFile2(outputPath, ghostBuf);
|
|
196
|
+
console.log(JSON.stringify({ output: outputPath, bytes: ghostBuf.length, selector: opts.selector, elementSize: { width: ew, height: eh } }));
|
|
197
|
+
}
|
|
198
|
+
async function overlay(raw) {
|
|
199
|
+
const opts = buildOverlayOptions(raw);
|
|
200
|
+
if (opts.selector) {
|
|
201
|
+
await compositeGhost(opts);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (opts.offsetX != null || opts.offsetY != null) {
|
|
205
|
+
await captureGhost(opts, {
|
|
206
|
+
offsetX: opts.offsetX ?? 0,
|
|
207
|
+
offsetY: opts.offsetY ?? 0,
|
|
208
|
+
scale: opts.scale ?? 1,
|
|
209
|
+
opacity: opts.opacity ?? 1
|
|
210
|
+
});
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
throw new Error("overlay requires --selector for component comparison, or --offset-x/--offset-y for full-page mode");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// src/commands/changelist.ts
|
|
217
|
+
import { readFile, writeFile as writeFile3, mkdir as mkdir3 } from "fs/promises";
|
|
218
|
+
import { basename, dirname as dirname3, extname, join, resolve as resolve2 } from "path";
|
|
219
|
+
import sharp from "sharp";
|
|
220
|
+
var COMPARISON_GAP = 32;
|
|
221
|
+
var DEFAULT_SCANS = [
|
|
222
|
+
{
|
|
223
|
+
id: 1,
|
|
224
|
+
mode: "word-sentence",
|
|
225
|
+
label: "word / sentence",
|
|
226
|
+
hiddenSlider: true,
|
|
227
|
+
threshold: 20,
|
|
228
|
+
group: 12,
|
|
229
|
+
minArea: 114,
|
|
230
|
+
maxAreaPercent: 15
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
id: 2,
|
|
234
|
+
mode: "graphic-large",
|
|
235
|
+
label: "graphic / large region",
|
|
236
|
+
hiddenSlider: true,
|
|
237
|
+
threshold: 25,
|
|
238
|
+
group: 25,
|
|
239
|
+
minArea: 761,
|
|
240
|
+
maxAreaPercent: 50
|
|
241
|
+
}
|
|
242
|
+
];
|
|
243
|
+
function buildChangelistOptions(raw) {
|
|
244
|
+
if (!raw.design) throw new Error("--design is required (path to design screenshot)");
|
|
245
|
+
if (raw.runtime && raw.url) throw new Error("Use either --runtime or --url, not both");
|
|
246
|
+
if (!raw.runtime && !raw.url) throw new Error("--runtime or --url is required");
|
|
247
|
+
return {
|
|
248
|
+
designImagePath: resolve2(raw.design),
|
|
249
|
+
runtimeImagePath: raw.runtime ? resolve2(raw.runtime) : void 0,
|
|
250
|
+
runtimeUrl: raw.url,
|
|
251
|
+
selector: raw.selector ? resolveSelector(raw.selector) : void 0,
|
|
252
|
+
fullPage: raw.fullPage ?? false,
|
|
253
|
+
cdp: raw.cdp,
|
|
254
|
+
output: raw.output,
|
|
255
|
+
annotated: raw.annotated,
|
|
256
|
+
regionsDir: raw.regionsDir,
|
|
257
|
+
mode: parseMode(raw.mode ?? "both"),
|
|
258
|
+
threshold: raw.threshold != null ? parseNumber(raw.threshold, "--threshold") : void 0,
|
|
259
|
+
group: raw.group != null ? parseNumber(raw.group, "--group") : void 0,
|
|
260
|
+
minArea: raw.minArea != null ? parseNumber(raw.minArea, "--min-area") : void 0,
|
|
261
|
+
maxAreaPercent: raw.maxAreaPercent != null ? parsePercent(raw.maxAreaPercent, "--max-area-percent") : void 0
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
function resolveScanConfigs(opts) {
|
|
265
|
+
const scans = DEFAULT_SCANS.filter((scan) => opts.mode === "both" || scan.mode === opts.mode).map((scan) => ({
|
|
266
|
+
...scan,
|
|
267
|
+
threshold: opts.threshold ?? scan.threshold,
|
|
268
|
+
group: opts.group ?? scan.group,
|
|
269
|
+
minArea: opts.minArea ?? scan.minArea,
|
|
270
|
+
maxAreaPercent: opts.maxAreaPercent ?? scan.maxAreaPercent
|
|
271
|
+
}));
|
|
272
|
+
if (scans.length === 0) throw new Error(`Unsupported mode: ${opts.mode}`);
|
|
273
|
+
return scans;
|
|
274
|
+
}
|
|
275
|
+
async function detectChangelist(designImagePath, runtimeImage, scans = DEFAULT_SCANS) {
|
|
276
|
+
const design = await loadImage(designImagePath);
|
|
277
|
+
const runtime = await loadImage(runtimeImage);
|
|
278
|
+
if (design.width !== runtime.width || design.height !== runtime.height) {
|
|
279
|
+
throw new Error(`Image sizes differ: design ${design.width}x${design.height}, runtime ${runtime.width}x${runtime.height}`);
|
|
280
|
+
}
|
|
281
|
+
const scanResults = scans.map((scan) => {
|
|
282
|
+
const mask = buildDiffMask(design, runtime, scan.threshold);
|
|
283
|
+
const changedPixels = countMask(mask);
|
|
284
|
+
const components = findComponents(mask, design.width, design.height);
|
|
285
|
+
const grouped = mergeComponents(components, scan.group, maxRegionArea(scan, design.width, design.height));
|
|
286
|
+
const regions2 = filterRegions(grouped, scan, design.width, design.height);
|
|
287
|
+
return {
|
|
288
|
+
id: scan.id,
|
|
289
|
+
mode: scan.mode,
|
|
290
|
+
label: scan.label,
|
|
291
|
+
params: {
|
|
292
|
+
hiddenSlider: scan.hiddenSlider,
|
|
293
|
+
threshold: scan.threshold,
|
|
294
|
+
group: scan.group,
|
|
295
|
+
minArea: scan.minArea,
|
|
296
|
+
maxAreaPercent: scan.maxAreaPercent
|
|
297
|
+
},
|
|
298
|
+
regions: regions2,
|
|
299
|
+
changedPixels,
|
|
300
|
+
changedPercent: roundPercent(changedPixels / (design.width * design.height))
|
|
301
|
+
};
|
|
302
|
+
});
|
|
303
|
+
let nextId = 1;
|
|
304
|
+
const regions = scanResults.flatMap((scan) => scan.regions.map((region) => ({ ...region, id: nextId++ }))).sort((a, b) => b.area - a.area || a.y - b.y || a.x - b.x).map((region, index) => ({ ...region, id: index + 1 }));
|
|
305
|
+
return {
|
|
306
|
+
design: designImagePath,
|
|
307
|
+
runtime: typeof runtimeImage === "string" ? runtimeImage : "<captured>",
|
|
308
|
+
size: { width: design.width, height: design.height },
|
|
309
|
+
scans: scanResults,
|
|
310
|
+
regions
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
async function changelist(raw) {
|
|
314
|
+
const opts = buildChangelistOptions(raw);
|
|
315
|
+
const scans = resolveScanConfigs(opts);
|
|
316
|
+
const runtime = opts.runtimeImagePath ?? await captureRuntime(opts);
|
|
317
|
+
const result = await detectChangelist(opts.designImagePath, runtime, scans);
|
|
318
|
+
let annotatedOutputs;
|
|
319
|
+
let regionOutputs;
|
|
320
|
+
if (opts.annotated) {
|
|
321
|
+
annotatedOutputs = await writeAnnotatedImages(opts.annotated, opts.designImagePath, runtime, scans, result);
|
|
322
|
+
}
|
|
323
|
+
if (opts.regionsDir) {
|
|
324
|
+
regionOutputs = await writeRegionExports(opts.regionsDir, opts.designImagePath, runtime, scans, result);
|
|
325
|
+
}
|
|
326
|
+
if (opts.output) {
|
|
327
|
+
await writeOutputFile(opts.output, Buffer.from(`${JSON.stringify(result, null, 2)}
|
|
328
|
+
`, "utf8"));
|
|
329
|
+
console.log(JSON.stringify({
|
|
330
|
+
output: opts.output,
|
|
331
|
+
annotated: annotatedOutputs ?? opts.annotated,
|
|
332
|
+
regionsDir: regionOutputs ?? opts.regionsDir,
|
|
333
|
+
regions: result.regions.length,
|
|
334
|
+
scans: result.scans.map((scan) => ({ id: scan.id, mode: scan.mode, regions: scan.regions.length }))
|
|
335
|
+
}));
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
console.log(JSON.stringify(result, null, 2));
|
|
339
|
+
}
|
|
340
|
+
async function writeAnnotatedImages(annotatedPath, designImagePath, runtimeImage, scans, result) {
|
|
341
|
+
const outputs = [];
|
|
342
|
+
if (scans.length > 1) {
|
|
343
|
+
const combined = await renderComparisonImage(designImagePath, runtimeImage, scans, result.regions);
|
|
344
|
+
await writeOutputFile(annotatedPath, combined);
|
|
345
|
+
outputs.push({ path: annotatedPath, mode: "combined", regions: result.regions.length });
|
|
346
|
+
}
|
|
347
|
+
for (const scan of scans) {
|
|
348
|
+
const scanResult = result.scans.find((item) => item.id === scan.id);
|
|
349
|
+
const outputPath = scans.length === 1 ? annotatedPath : appendModeSuffix(annotatedPath, scan.mode);
|
|
350
|
+
const image = await renderComparisonImage(designImagePath, runtimeImage, [scan], scanResult?.regions ?? []);
|
|
351
|
+
await writeOutputFile(outputPath, image);
|
|
352
|
+
outputs.push({ path: outputPath, mode: scan.mode, regions: scanResult?.regions.length ?? 0 });
|
|
353
|
+
}
|
|
354
|
+
return outputs;
|
|
355
|
+
}
|
|
356
|
+
async function writeRegionExports(outputRoot, designImagePath, runtimeImage, scans, result) {
|
|
357
|
+
const designBuffer = await readFile(designImagePath);
|
|
358
|
+
const runtimeBuffer = typeof runtimeImage === "string" ? await readFile(runtimeImage) : runtimeImage;
|
|
359
|
+
const outputs = [];
|
|
360
|
+
for (const scan of scans) {
|
|
361
|
+
const scanResult = result.scans.find((item) => item.id === scan.id);
|
|
362
|
+
const regions = scanResult?.regions ?? [];
|
|
363
|
+
const modeDir = join(outputRoot, regionDirName(scan.mode));
|
|
364
|
+
for (const region of regions) {
|
|
365
|
+
const regionDir = join(modeDir, String(region.id));
|
|
366
|
+
const designCrop = await cropRegion(designBuffer, region);
|
|
367
|
+
const runtimeCrop = await cropRegion(runtimeBuffer, region);
|
|
368
|
+
const compare = await renderRegionPairImage(designCrop, runtimeCrop, region);
|
|
369
|
+
const regionJson = {
|
|
370
|
+
...region,
|
|
371
|
+
source: {
|
|
372
|
+
design: designImagePath,
|
|
373
|
+
runtime: typeof runtimeImage === "string" ? runtimeImage : "<captured>",
|
|
374
|
+
size: result.size
|
|
375
|
+
},
|
|
376
|
+
scan: {
|
|
377
|
+
id: scan.id,
|
|
378
|
+
mode: scan.mode,
|
|
379
|
+
label: scan.label,
|
|
380
|
+
params: scanResult?.params
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
await writeOutputFile(join(regionDir, "design.png"), designCrop);
|
|
384
|
+
await writeOutputFile(join(regionDir, "runtime.png"), runtimeCrop);
|
|
385
|
+
await writeOutputFile(join(regionDir, "compare.png"), compare);
|
|
386
|
+
await writeOutputFile(join(regionDir, "region.json"), Buffer.from(`${JSON.stringify(regionJson, null, 2)}
|
|
387
|
+
`, "utf8"));
|
|
388
|
+
}
|
|
389
|
+
outputs.push({ dir: modeDir, mode: scan.mode, regions: regions.length });
|
|
390
|
+
}
|
|
391
|
+
return outputs;
|
|
392
|
+
}
|
|
393
|
+
async function captureRuntime(opts) {
|
|
394
|
+
if (!opts.runtimeUrl) throw new Error("--url is required when --runtime is not provided");
|
|
395
|
+
const engine = await createEngine({ url: opts.runtimeUrl, cdp: opts.cdp });
|
|
396
|
+
try {
|
|
397
|
+
return await engine.screenshot({
|
|
398
|
+
selector: opts.selector,
|
|
399
|
+
fullPage: opts.fullPage
|
|
400
|
+
});
|
|
401
|
+
} finally {
|
|
402
|
+
await engine.close();
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
async function loadImage(input) {
|
|
406
|
+
const source = typeof input === "string" ? await readFile(input) : input;
|
|
407
|
+
const { data, info } = await sharp(source).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
408
|
+
return {
|
|
409
|
+
data,
|
|
410
|
+
width: info.width,
|
|
411
|
+
height: info.height
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
function buildDiffMask(design, runtime, threshold) {
|
|
415
|
+
const total = design.width * design.height;
|
|
416
|
+
const mask = new Uint8Array(total);
|
|
417
|
+
for (let pixel = 0, offset = 0; pixel < total; pixel++, offset += 4) {
|
|
418
|
+
const diff = Math.max(
|
|
419
|
+
Math.abs(design.data[offset] - runtime.data[offset]),
|
|
420
|
+
Math.abs(design.data[offset + 1] - runtime.data[offset + 1]),
|
|
421
|
+
Math.abs(design.data[offset + 2] - runtime.data[offset + 2]),
|
|
422
|
+
Math.abs(design.data[offset + 3] - runtime.data[offset + 3])
|
|
423
|
+
);
|
|
424
|
+
if (diff >= threshold) mask[pixel] = 1;
|
|
425
|
+
}
|
|
426
|
+
return mask;
|
|
427
|
+
}
|
|
428
|
+
function buildUnionDiffMask(design, runtime, scans) {
|
|
429
|
+
const total = design.width * design.height;
|
|
430
|
+
const mask = new Uint8Array(total);
|
|
431
|
+
for (const scan of scans) {
|
|
432
|
+
const scanMask = buildDiffMask(design, runtime, scan.threshold);
|
|
433
|
+
for (let i = 0; i < total; i++) {
|
|
434
|
+
if (scanMask[i]) mask[i] = 1;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return mask;
|
|
438
|
+
}
|
|
439
|
+
function countMask(mask) {
|
|
440
|
+
let count = 0;
|
|
441
|
+
for (const value of mask) count += value;
|
|
442
|
+
return count;
|
|
443
|
+
}
|
|
444
|
+
function findComponents(mask, width, height) {
|
|
445
|
+
const visited = new Uint8Array(mask.length);
|
|
446
|
+
const components = [];
|
|
447
|
+
const queue = [];
|
|
448
|
+
for (let start = 0; start < mask.length; start++) {
|
|
449
|
+
if (!mask[start] || visited[start]) continue;
|
|
450
|
+
visited[start] = 1;
|
|
451
|
+
queue.length = 0;
|
|
452
|
+
queue.push(start);
|
|
453
|
+
let head = 0;
|
|
454
|
+
let changedPixels = 0;
|
|
455
|
+
let minX = width;
|
|
456
|
+
let minY = height;
|
|
457
|
+
let maxX = 0;
|
|
458
|
+
let maxY = 0;
|
|
459
|
+
while (head < queue.length) {
|
|
460
|
+
const idx = queue[head++];
|
|
461
|
+
const x = idx % width;
|
|
462
|
+
const y = Math.floor(idx / width);
|
|
463
|
+
changedPixels++;
|
|
464
|
+
if (x < minX) minX = x;
|
|
465
|
+
if (y < minY) minY = y;
|
|
466
|
+
if (x > maxX) maxX = x;
|
|
467
|
+
if (y > maxY) maxY = y;
|
|
468
|
+
for (let dy = -1; dy <= 1; dy++) {
|
|
469
|
+
const ny = y + dy;
|
|
470
|
+
if (ny < 0 || ny >= height) continue;
|
|
471
|
+
for (let dx = -1; dx <= 1; dx++) {
|
|
472
|
+
if (dx === 0 && dy === 0) continue;
|
|
473
|
+
const nx = x + dx;
|
|
474
|
+
if (nx < 0 || nx >= width) continue;
|
|
475
|
+
const next = ny * width + nx;
|
|
476
|
+
if (!mask[next] || visited[next]) continue;
|
|
477
|
+
visited[next] = 1;
|
|
478
|
+
queue.push(next);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
components.push({
|
|
483
|
+
x: minX,
|
|
484
|
+
y: minY,
|
|
485
|
+
right: maxX + 1,
|
|
486
|
+
bottom: maxY + 1,
|
|
487
|
+
changedPixels
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
return components;
|
|
491
|
+
}
|
|
492
|
+
function mergeComponents(components, group, maxArea) {
|
|
493
|
+
const regions = components.map((component) => ({ ...component }));
|
|
494
|
+
let changed = true;
|
|
495
|
+
while (changed) {
|
|
496
|
+
changed = false;
|
|
497
|
+
for (let i = 0; i < regions.length; i++) {
|
|
498
|
+
for (let j = i + 1; j < regions.length; j++) {
|
|
499
|
+
if (!shouldMerge(regions[i], regions[j], group)) continue;
|
|
500
|
+
const merged = mergeRegion(regions[i], regions[j]);
|
|
501
|
+
if (componentArea(merged) > maxArea) continue;
|
|
502
|
+
regions[i] = merged;
|
|
503
|
+
regions.splice(j, 1);
|
|
504
|
+
changed = true;
|
|
505
|
+
j--;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
return regions;
|
|
510
|
+
}
|
|
511
|
+
function maxRegionArea(scan, width, height) {
|
|
512
|
+
return width * height * (scan.maxAreaPercent / 100);
|
|
513
|
+
}
|
|
514
|
+
function shouldMerge(a, b, gap) {
|
|
515
|
+
return !(a.right + gap < b.x || b.right + gap < a.x || a.bottom + gap < b.y || b.bottom + gap < a.y);
|
|
516
|
+
}
|
|
517
|
+
function componentArea(component) {
|
|
518
|
+
return (component.right - component.x) * (component.bottom - component.y);
|
|
519
|
+
}
|
|
520
|
+
function mergeRegion(a, b) {
|
|
521
|
+
return {
|
|
522
|
+
x: Math.min(a.x, b.x),
|
|
523
|
+
y: Math.min(a.y, b.y),
|
|
524
|
+
right: Math.max(a.right, b.right),
|
|
525
|
+
bottom: Math.max(a.bottom, b.bottom),
|
|
526
|
+
changedPixels: a.changedPixels + b.changedPixels
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
function filterRegions(components, scan, width, height) {
|
|
530
|
+
const imageArea = width * height;
|
|
531
|
+
return components.map(componentToRegion(scan, imageArea)).filter((region) => region.area >= scan.minArea).filter((region) => region.changedPercent <= scan.maxAreaPercent).sort((a, b) => b.area - a.area || a.y - b.y || a.x - b.x).map((region, index) => ({ ...region, id: index + 1 }));
|
|
532
|
+
}
|
|
533
|
+
function componentToRegion(scan, imageArea) {
|
|
534
|
+
return (component) => {
|
|
535
|
+
const width = component.right - component.x;
|
|
536
|
+
const height = component.bottom - component.y;
|
|
537
|
+
const area = width * height;
|
|
538
|
+
return {
|
|
539
|
+
id: 0,
|
|
540
|
+
scanId: scan.id,
|
|
541
|
+
mode: scan.mode,
|
|
542
|
+
x: component.x,
|
|
543
|
+
y: component.y,
|
|
544
|
+
width,
|
|
545
|
+
height,
|
|
546
|
+
area,
|
|
547
|
+
changedPixels: component.changedPixels,
|
|
548
|
+
changedPercent: roundPercent(area / imageArea)
|
|
549
|
+
};
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
async function cropRegion(image, region) {
|
|
553
|
+
return sharp(image).extract({
|
|
554
|
+
left: region.x,
|
|
555
|
+
top: region.y,
|
|
556
|
+
width: region.width,
|
|
557
|
+
height: region.height
|
|
558
|
+
}).png().toBuffer();
|
|
559
|
+
}
|
|
560
|
+
async function renderRegionPairImage(designCrop, runtimeCrop, region) {
|
|
561
|
+
const designMeta = await sharp(designCrop).metadata();
|
|
562
|
+
const runtimeMeta = await sharp(runtimeCrop).metadata();
|
|
563
|
+
const width = designMeta.width ?? region.width;
|
|
564
|
+
const height = designMeta.height ?? region.height;
|
|
565
|
+
if (width !== runtimeMeta.width || height !== runtimeMeta.height) {
|
|
566
|
+
throw new Error(`Region crop sizes differ for region ${region.id}: design ${width}x${height}, runtime ${runtimeMeta.width}x${runtimeMeta.height}`);
|
|
567
|
+
}
|
|
568
|
+
const runtimeOffsetX = width + COMPARISON_GAP;
|
|
569
|
+
const outputWidth = width * 2 + COMPARISON_GAP;
|
|
570
|
+
return sharp({
|
|
571
|
+
create: {
|
|
572
|
+
width: outputWidth,
|
|
573
|
+
height,
|
|
574
|
+
channels: 4,
|
|
575
|
+
background: { r: 248, g: 250, b: 252, alpha: 1 }
|
|
576
|
+
}
|
|
577
|
+
}).composite([
|
|
578
|
+
{ input: designCrop, left: 0, top: 0 },
|
|
579
|
+
{ input: runtimeCrop, left: runtimeOffsetX, top: 0 },
|
|
580
|
+
{ input: Buffer.from(buildDividerSvg(outputWidth, height, runtimeOffsetX)), blend: "over" }
|
|
581
|
+
]).png().toBuffer();
|
|
582
|
+
}
|
|
583
|
+
async function renderComparisonImage(designImagePath, runtimeImage, scans, regions) {
|
|
584
|
+
const design = await loadImage(designImagePath);
|
|
585
|
+
const runtime = await loadImage(runtimeImage);
|
|
586
|
+
if (design.width !== runtime.width || design.height !== runtime.height) {
|
|
587
|
+
throw new Error(`Image sizes differ: design ${design.width}x${design.height}, runtime ${runtime.width}x${runtime.height}`);
|
|
588
|
+
}
|
|
589
|
+
const mask = buildUnionDiffMask(design, runtime, scans);
|
|
590
|
+
const highlightedRuntime = Buffer.from(runtime.data);
|
|
591
|
+
for (let pixel = 0, offset = 0; pixel < mask.length; pixel++, offset += 4) {
|
|
592
|
+
if (!mask[pixel]) continue;
|
|
593
|
+
highlightedRuntime[offset] = 255;
|
|
594
|
+
highlightedRuntime[offset + 1] = Math.round(highlightedRuntime[offset + 1] * 0.35);
|
|
595
|
+
highlightedRuntime[offset + 2] = Math.round(highlightedRuntime[offset + 2] * 0.35);
|
|
596
|
+
highlightedRuntime[offset + 3] = 255;
|
|
597
|
+
}
|
|
598
|
+
const runtimeOffsetX = design.width + COMPARISON_GAP;
|
|
599
|
+
const outputWidth = design.width * 2 + COMPARISON_GAP;
|
|
600
|
+
const designPanel = await imageDataToPng(design);
|
|
601
|
+
const runtimePanel = await imageDataToPng({ ...runtime, data: highlightedRuntime });
|
|
602
|
+
const svg = buildSideBySideRegionSvg(outputWidth, design.height, runtimeOffsetX, regions);
|
|
603
|
+
return sharp({
|
|
604
|
+
create: {
|
|
605
|
+
width: outputWidth,
|
|
606
|
+
height: design.height,
|
|
607
|
+
channels: 4,
|
|
608
|
+
background: { r: 248, g: 250, b: 252, alpha: 1 }
|
|
609
|
+
}
|
|
610
|
+
}).composite([
|
|
611
|
+
{ input: designPanel, left: 0, top: 0 },
|
|
612
|
+
{ input: runtimePanel, left: runtimeOffsetX, top: 0 },
|
|
613
|
+
{ input: Buffer.from(svg), blend: "over" }
|
|
614
|
+
]).png().toBuffer();
|
|
615
|
+
}
|
|
616
|
+
function imageDataToPng(image) {
|
|
617
|
+
return sharp(image.data, {
|
|
618
|
+
raw: {
|
|
619
|
+
width: image.width,
|
|
620
|
+
height: image.height,
|
|
621
|
+
channels: 4
|
|
622
|
+
}
|
|
623
|
+
}).png().toBuffer();
|
|
624
|
+
}
|
|
625
|
+
function buildSideBySideRegionSvg(width, height, runtimeOffsetX, regions) {
|
|
626
|
+
const svg = [
|
|
627
|
+
`<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg">`,
|
|
628
|
+
dividerSvgContent(height, runtimeOffsetX),
|
|
629
|
+
...regions.flatMap((region) => [
|
|
630
|
+
regionToSvg(region, 0),
|
|
631
|
+
regionToSvg(region, runtimeOffsetX)
|
|
632
|
+
]),
|
|
633
|
+
"</svg>"
|
|
634
|
+
].join("");
|
|
635
|
+
return svg;
|
|
636
|
+
}
|
|
637
|
+
function buildDividerSvg(width, height, runtimeOffsetX) {
|
|
638
|
+
return [
|
|
639
|
+
`<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg">`,
|
|
640
|
+
dividerSvgContent(height, runtimeOffsetX),
|
|
641
|
+
"</svg>"
|
|
642
|
+
].join("");
|
|
643
|
+
}
|
|
644
|
+
function dividerSvgContent(height, runtimeOffsetX) {
|
|
645
|
+
return [
|
|
646
|
+
`<rect x="${runtimeOffsetX - COMPARISON_GAP}" y="0" width="${COMPARISON_GAP}" height="${height}" fill="#f8fafc"/>`,
|
|
647
|
+
`<line x1="${runtimeOffsetX - COMPARISON_GAP / 2}" y1="0" x2="${runtimeOffsetX - COMPARISON_GAP / 2}" y2="${height}" stroke="#d9e2ec" stroke-width="1"/>`
|
|
648
|
+
].join("");
|
|
649
|
+
}
|
|
650
|
+
function regionToSvg(region, offsetX) {
|
|
651
|
+
const labelWidth = Math.max(14, String(region.id).length * 8 + 8);
|
|
652
|
+
const labelX = Math.max(offsetX, offsetX + region.x + region.width - labelWidth);
|
|
653
|
+
const labelY = Math.max(0, region.y - 12);
|
|
654
|
+
return [
|
|
655
|
+
`<rect x="${offsetX + region.x + 0.5}" y="${region.y + 0.5}" width="${Math.max(1, region.width - 1)}" height="${Math.max(1, region.height - 1)}" fill="none" stroke="#ef3b2d" stroke-width="1"/>`,
|
|
656
|
+
`<rect x="${labelX}" y="${labelY}" width="${labelWidth}" height="14" fill="#ef3b2d"/>`,
|
|
657
|
+
`<text x="${labelX + labelWidth / 2}" y="${labelY + 10}" text-anchor="middle" font-family="Arial, sans-serif" font-size="10" font-weight="700" fill="#fff">${region.id}</text>`
|
|
658
|
+
].join("");
|
|
659
|
+
}
|
|
660
|
+
async function writeOutputFile(filePath, data) {
|
|
661
|
+
await mkdir3(dirname3(resolve2(filePath)), { recursive: true }).catch(() => {
|
|
662
|
+
});
|
|
663
|
+
await writeFile3(filePath, data);
|
|
664
|
+
}
|
|
665
|
+
function appendModeSuffix(filePath, mode) {
|
|
666
|
+
const ext = extname(filePath) || ".png";
|
|
667
|
+
const name = extname(filePath) ? basename(filePath, extname(filePath)) : basename(filePath);
|
|
668
|
+
return join(dirname3(filePath), `${name}-${mode}${ext}`);
|
|
669
|
+
}
|
|
670
|
+
function regionDirName(mode) {
|
|
671
|
+
return mode === "word-sentence" ? "word" : "graphic";
|
|
672
|
+
}
|
|
673
|
+
function parseMode(value) {
|
|
674
|
+
const normalized = String(value).trim().toLowerCase();
|
|
675
|
+
if (["both", "all"].includes(normalized)) return "both";
|
|
676
|
+
if (["word", "words", "sentence", "sentences", "word-sentence", "word_sentence", "text"].includes(normalized)) {
|
|
677
|
+
return "word-sentence";
|
|
678
|
+
}
|
|
679
|
+
if (["graphic", "graphics", "large", "large-region", "graphic-large", "graphic_large"].includes(normalized)) {
|
|
680
|
+
return "graphic-large";
|
|
681
|
+
}
|
|
682
|
+
throw new Error(`invalid mode: ${value}. Use both, word-sentence, or graphic-large.`);
|
|
683
|
+
}
|
|
684
|
+
function parseNumber(value, flag) {
|
|
685
|
+
const n = Number(value);
|
|
686
|
+
if (!Number.isFinite(n) || n < 0) throw new Error(`${flag} must be a non-negative number`);
|
|
687
|
+
return n;
|
|
688
|
+
}
|
|
689
|
+
function parsePercent(value, flag) {
|
|
690
|
+
const raw = String(value).trim().replace(/%$/, "");
|
|
691
|
+
const n = Number(raw);
|
|
692
|
+
if (!Number.isFinite(n) || n < 0 || n > 100) {
|
|
693
|
+
throw new Error(`${flag} must be a number between 0 and 100`);
|
|
694
|
+
}
|
|
695
|
+
return n;
|
|
696
|
+
}
|
|
697
|
+
function roundPercent(value) {
|
|
698
|
+
return Math.round(value * 1e4) / 100;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// src/cli.ts
|
|
702
|
+
var program = new Command();
|
|
703
|
+
program.name("designer").description(
|
|
704
|
+
"Runtime UI measurement CLI for AI agents: measure CSS/layout, capture screenshots, build overlays, and list visual changes."
|
|
705
|
+
).version("0.1.0");
|
|
706
|
+
program.addHelpText("after", `
|
|
707
|
+
Workflow (agent):
|
|
708
|
+
1. measure Read element bbox and computed CSS as JSON
|
|
709
|
+
2. screenshot Capture design/runtime component images
|
|
710
|
+
3. overlay Compare design image against runtime page
|
|
711
|
+
4. changelist List changed regions between design/runtime screenshots
|
|
712
|
+
|
|
713
|
+
Engine selection:
|
|
714
|
+
Default: Playwright (launches headless Chromium)
|
|
715
|
+
--cdp <host:port>: Connect to an existing Chrome/WebView
|
|
716
|
+
|
|
717
|
+
Examples:
|
|
718
|
+
$ designer measure --url http://localhost:3000 --selector ".dialog"
|
|
719
|
+
$ designer measure --url http://127.0.0.1:32767/start.html --frame "#mainFrame" --selector "#u0"
|
|
720
|
+
$ designer screenshot --url http://localhost:3000 --selector ".dialog" --output runtime.png
|
|
721
|
+
$ designer overlay --design design.png --url http://localhost:3000 --selector ".dialog" --output overlay.png
|
|
722
|
+
$ designer changelist --design design.png --runtime runtime.png --annotated changes.png
|
|
723
|
+
`);
|
|
724
|
+
var measureCmd = program.command("measure").description("Measure an element bbox, computed style, and optional child tree.").requiredOption("--url <url>", "Target page URL").requiredOption("--selector <selector>", "CSS selector to measure; prefix ~ for fuzzy class match").option("--frame <selector>", "Iframe CSS selector; measure --selector inside this frame document").option("--depth <n>", "Child element depth (0=no children)", "1").option("--cdp <host:port>", "CDP endpoint").option("--pick <fields>", "Pick bbox, children, or CSS property names (comma-separated)").option("--format <format>", "Output format: json | table", "json").action(measure);
|
|
725
|
+
measureCmd.addHelpText("after", `
|
|
726
|
+
Output (json):
|
|
727
|
+
{
|
|
728
|
+
"selector": ".dialog",
|
|
729
|
+
"bbox": { "x": 100, "y": 200, "width": 400, "height": 300 },
|
|
730
|
+
"computedStyle": { "border-radius": "12px", "padding": "24px" },
|
|
731
|
+
"children": []
|
|
732
|
+
}
|
|
733
|
+
`);
|
|
734
|
+
program.command("screenshot").description("Capture a PNG screenshot of the full page or a specific element.").requiredOption("--url <url>", "Target page URL").option("--selector <selector>", "CSS selector; captures element only, prefix ~ for fuzzy class match").option("--output <path>", "Output file path (default: screenshot-<timestamp>.png)").option("--full-page", "Capture full scrollable page").option("--cdp <host:port>", "CDP endpoint").action(screenshot);
|
|
735
|
+
var overlayCmd = program.command("overlay").description("Generate a magenta design ghost overlay on top of a live page screenshot.").requiredOption("--design <path>", "Path to design screenshot (PNG/JPG)").requiredOption("--url <url>", "Target page URL").option("--selector <selector>", "CSS selector; composite ghost on element, prefix ~ for fuzzy class match").option("--full-page", "Capture full scrollable page").option("--output <path>", "Output file path (default: overlay-<timestamp>.png)").option("--offset-x <px>", "Horizontal offset of design overlay (full-page mode)").option("--offset-y <px>", "Vertical offset of design overlay (full-page mode)").option("--scale <ratio>", "Scale factor for design overlay (1 = 100%)").option("--opacity <0-1>", "Opacity of design overlay").option("--cdp <host:port>", "CDP endpoint").action(overlay);
|
|
736
|
+
overlayCmd.addHelpText("after", `
|
|
737
|
+
Modes:
|
|
738
|
+
Selector:
|
|
739
|
+
$ designer overlay --design design.png --url http://localhost:3000 --selector ".dialog" --output overlay.png
|
|
740
|
+
|
|
741
|
+
Direct full-page:
|
|
742
|
+
$ designer overlay --design spec.png --url http://localhost:3000 --offset-x 0 --offset-y 0 --output overlay.png
|
|
743
|
+
|
|
744
|
+
Without --selector, provide at least --offset-x or --offset-y for direct full-page mode.
|
|
745
|
+
`);
|
|
746
|
+
program.command("changelist").description("Detect changed regions between a design screenshot and a runtime screenshot.").requiredOption("--design <path>", "Path to design screenshot (PNG/JPG)").option("--runtime <path>", "Path to runtime screenshot; use this or --url").option("--url <url>", "Target page URL to capture as runtime screenshot; use this or --runtime").option("--selector <selector>", "CSS selector when capturing --url; prefix ~ for fuzzy class match").option("--full-page", "Capture full scrollable page when using --url").option("--cdp <host:port>", "CDP endpoint when using --url").option("--output <path>", "Write changelist JSON to file; default prints JSON").option("--annotated <path>", "Write side-by-side comparison PNG; both mode also writes per-mode PNG files").option("--regions-dir <dir>", "Write per-region design/runtime/compare PNGs and region JSON by scan mode").option("--mode <mode>", "both | word-sentence | graphic-large", "both").option("--threshold <n>", "Override difference threshold for selected scans").option("--group <px>", "Override grouping distance for selected scans").option("--min-area <px>", "Override minimum region area for selected scans").option("--max-area-percent <pct>", "Override max region area percent for selected scans").action(changelist);
|
|
747
|
+
program.parse();
|
|
748
|
+
//# sourceMappingURL=cli.js.map
|