alanbox 0.1.3 → 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.
Files changed (56) hide show
  1. package/0boxer/AGENTS.md +3 -2
  2. package/0boxer/src/commands/AGENTS.md +2 -1
  3. package/0boxer/src/commands/install.js +47 -0
  4. package/1swarmer/AGENTS.md +5 -2
  5. package/1swarmer/src/AGENTS.md +4 -3
  6. package/1swarmer/src/args.js +7 -0
  7. package/1swarmer/src/cli.js +26 -0
  8. package/1swarmer/src/commands/AGENTS.md +3 -0
  9. package/1swarmer/src/commands/review-file.js +997 -0
  10. package/1swarmer/src/runner/AGENTS.md +1 -0
  11. package/1swarmer/src/runner/codex-runner.js +23 -3
  12. package/2designer/README.md +3 -0
  13. package/2designer/dist/{cdp-engine-JK2XVDHK.js → cdp-engine-4AIWSWXO.js} +2 -2
  14. package/2designer/dist/{cdp-engine-A5WTMTVF.js → cdp-engine-SG4K2BCX.js} +2 -2
  15. package/2designer/dist/{chunk-NQ3ASZUE.js → chunk-7X7PTLZH.js} +2 -2
  16. package/2designer/dist/{chunk-JVF26NXD.js → chunk-DPOWNFOH.js} +2 -2
  17. package/2designer/dist/{chunk-SKEIVBOU.js → chunk-ISUUIOO7.js} +1 -1
  18. package/2designer/dist/chunk-ISUUIOO7.js.map +1 -0
  19. package/2designer/dist/cli.js +494 -244
  20. package/2designer/dist/cli.js.map +1 -1
  21. package/2designer/dist/index.d.ts +7 -18
  22. package/2designer/dist/index.js +5 -198
  23. package/2designer/dist/index.js.map +1 -1
  24. package/2designer/dist/{playwright-engine-YBRDIUHF.js → playwright-engine-YXBY3KEN.js} +2 -2
  25. package/2designer/dist/{playwright-engine-3YKJOUNU.js → playwright-engine-YXGDTSZ5.js} +2 -2
  26. package/2designer/dist/tint-UD4CJ7S2.js +7 -0
  27. package/2designer/dist/{tint-I3FTT23O.js → tint-YN63MLVN.js} +1 -1
  28. package/2designer/dist/tint-YN63MLVN.js.map +1 -0
  29. package/4reporter/README.md +24 -0
  30. package/4reporter/dist/cli.js +464 -0
  31. package/4reporter/dist/cli.js.map +1 -0
  32. package/4reporter/dist/index.d.ts +108 -0
  33. package/4reporter/dist/index.js +445 -0
  34. package/4reporter/dist/index.js.map +1 -0
  35. package/4reporter/package.json +39 -0
  36. package/README.md +13 -5
  37. package/bin/reporter.js +11 -0
  38. package/cli.js +31 -6
  39. package/mcp/README.md +7 -1
  40. package/mcp/config.toml +4 -0
  41. package/package.json +8 -4
  42. package/skills/AGENTS.md +3 -3
  43. package/skills/aitool/SKILL.md +1 -1
  44. package/skills/desginer/SKILL.md +65 -45
  45. package/skills/swarmer/SKILL.md +37 -0
  46. package/2designer/LICENSE +0 -21
  47. package/2designer/dist/chunk-SKEIVBOU.js.map +0 -1
  48. package/2designer/dist/tint-I3FTT23O.js.map +0 -1
  49. package/2designer/dist/tint-RUSSUAWA.js +0 -7
  50. /package/2designer/dist/{cdp-engine-JK2XVDHK.js.map → cdp-engine-4AIWSWXO.js.map} +0 -0
  51. /package/2designer/dist/{cdp-engine-A5WTMTVF.js.map → cdp-engine-SG4K2BCX.js.map} +0 -0
  52. /package/2designer/dist/{chunk-NQ3ASZUE.js.map → chunk-7X7PTLZH.js.map} +0 -0
  53. /package/2designer/dist/{chunk-JVF26NXD.js.map → chunk-DPOWNFOH.js.map} +0 -0
  54. /package/2designer/dist/{playwright-engine-YBRDIUHF.js.map → playwright-engine-YXBY3KEN.js.map} +0 -0
  55. /package/2designer/dist/{playwright-engine-3YKJOUNU.js.map → playwright-engine-YXGDTSZ5.js.map} +0 -0
  56. /package/2designer/dist/{tint-RUSSUAWA.js.map → tint-UD4CJ7S2.js.map} +0 -0
@@ -11,12 +11,12 @@ function resolveEngineType(options) {
11
11
  async function createEngine(options) {
12
12
  const type = resolveEngineType(options);
13
13
  if (type === "cdp") {
14
- const { CdpEngine } = await import("./cdp-engine-JK2XVDHK.js");
14
+ const { CdpEngine } = await import("./cdp-engine-4AIWSWXO.js");
15
15
  const [host, portStr] = options.cdp.split(":");
16
16
  const port = parseInt(portStr, 10);
17
17
  return CdpEngine.create(host, port, options.url);
18
18
  }
19
- const { PlaywrightEngine } = await import("./playwright-engine-YBRDIUHF.js");
19
+ const { PlaywrightEngine } = await import("./playwright-engine-YXBY3KEN.js");
20
20
  return PlaywrightEngine.create(options.url, {
21
21
  headless: options.headless ?? true,
22
22
  viewport: options.viewport
@@ -134,211 +134,14 @@ async function screenshot(raw) {
134
134
  }
135
135
 
136
136
  // src/commands/overlay.ts
137
- import { createServer } from "http";
138
137
  import { writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
139
138
  import { resolve, dirname as dirname2 } from "path";
140
- import { WebSocketServer } from "ws";
141
-
142
- // src/overlay/ui.ts
143
- function generateOverlayHtml(options) {
144
- const {
145
- targetUrl,
146
- designImageBase64,
147
- wsPort,
148
- initialOpacity = 50,
149
- initialScale = 100,
150
- initialOffsetX = 0,
151
- initialOffsetY = 0
152
- } = options;
153
- return `<!DOCTYPE html>
154
- <html lang="en">
155
- <head>
156
- <meta charset="UTF-8">
157
- <title>designer overlay</title>
158
- <style>
159
- * { margin: 0; padding: 0; box-sizing: border-box; }
160
- body { overflow: hidden; background: #1a1a1a; font-family: system-ui, sans-serif; }
161
-
162
- #toolbar {
163
- position: fixed; top: 0; left: 0; right: 0; z-index: 100000;
164
- background: rgba(0,0,0,0.85); color: #fff; padding: 8px 16px;
165
- display: flex; align-items: center; gap: 16px; font-size: 13px;
166
- backdrop-filter: blur(8px);
167
- }
168
- #toolbar label { display: flex; align-items: center; gap: 6px; }
169
- #toolbar input[type=range] { width: 120px; }
170
- #toolbar .value { min-width: 40px; text-align: right; font-variant-numeric: tabular-nums; }
171
- #confirm-btn {
172
- margin-left: auto; padding: 6px 20px; background: #22c55e; color: #fff;
173
- border: none; border-radius: 6px; font-size: 14px; font-weight: 600;
174
- cursor: pointer;
175
- }
176
- #confirm-btn:hover { background: #16a34a; }
177
-
178
- #viewport {
179
- position: fixed; top: 40px; left: 0; right: 0; bottom: 0;
180
- }
181
- #target-frame {
182
- width: 100%; height: 100%; border: none;
183
- }
184
-
185
- #overlay-img {
186
- position: fixed; top: 40px; left: 0;
187
- width: 100vw; height: auto;
188
- pointer-events: none; z-index: 99999;
189
- transform-origin: top left;
190
- }
191
- #overlay-img.draggable { pointer-events: auto; cursor: grab; }
192
- #overlay-img.dragging { cursor: grabbing; }
193
-
194
- #status {
195
- position: fixed; bottom: 12px; right: 12px; z-index: 100001;
196
- background: rgba(0,0,0,0.7); color: #aaa; padding: 4px 10px;
197
- border-radius: 4px; font-size: 12px;
198
- }
199
- </style>
200
- </head>
201
- <body>
202
-
203
- <div id="toolbar">
204
- <span style="font-weight:600">designer</span>
205
- <label>
206
- opacity
207
- <input type="range" id="opacity-slider" min="0" max="100" value="${initialOpacity}">
208
- <span class="value" id="opacity-val">${initialOpacity}%</span>
209
- </label>
210
- <label>
211
- scale
212
- <input type="range" id="scale-slider" min="10" max="300" value="${initialScale}">
213
- <span class="value" id="scale-val">${initialScale}%</span>
214
- </label>
215
- <label>
216
- <input type="checkbox" id="lock-cb" checked> lock
217
- </label>
218
- <button id="reset-btn" style="padding:4px 12px;background:#555;color:#fff;border:none;border-radius:4px;cursor:pointer">reset</button>
219
- <button id="confirm-btn">confirm</button>
220
- </div>
221
-
222
- <div id="viewport">
223
- <iframe id="target-frame" src="${targetUrl}"></iframe>
224
- </div>
225
-
226
- <img id="overlay-img" src="data:image/png;base64,${designImageBase64}">
227
-
228
- <div id="status">connecting...</div>
229
-
230
- <script>
231
- (() => {
232
- const img = document.getElementById('overlay-img');
233
- const opacitySlider = document.getElementById('opacity-slider');
234
- const opacityVal = document.getElementById('opacity-val');
235
- const scaleSlider = document.getElementById('scale-slider');
236
- const scaleVal = document.getElementById('scale-val');
237
- const lockCb = document.getElementById('lock-cb');
238
- const resetBtn = document.getElementById('reset-btn');
239
- const confirmBtn = document.getElementById('confirm-btn');
240
- const status = document.getElementById('status');
241
-
242
- let offsetX = ${initialOffsetX}, offsetY = ${initialOffsetY};
243
- let scale = ${initialScale} / 100;
244
- let opacity = ${initialOpacity} / 100;
245
- let isDragging = false, dragStartX = 0, dragStartY = 0, dragStartOX = 0, dragStartOY = 0;
246
-
247
- function updateTransform() {
248
- img.style.opacity = String(opacity);
249
- img.style.transform = \`translate(\${offsetX}px, \${offsetY}px) scale(\${scale})\`;
250
- }
251
-
252
- opacitySlider.addEventListener('input', () => {
253
- opacity = opacitySlider.value / 100;
254
- opacityVal.textContent = opacitySlider.value + '%';
255
- updateTransform();
256
- });
257
-
258
- scaleSlider.addEventListener('input', () => {
259
- scale = scaleSlider.value / 100;
260
- scaleVal.textContent = scaleSlider.value + '%';
261
- updateTransform();
262
- });
263
-
264
- lockCb.addEventListener('change', () => {
265
- img.classList.toggle('draggable', !lockCb.checked);
266
- });
267
-
268
- resetBtn.addEventListener('click', () => {
269
- offsetX = 0; offsetY = 0; scale = 1; opacity = 0.5;
270
- opacitySlider.value = '50'; opacityVal.textContent = '50%';
271
- scaleSlider.value = '100'; scaleVal.textContent = '100%';
272
- updateTransform();
273
- });
274
-
275
- // Drag
276
- img.addEventListener('mousedown', (e) => {
277
- if (lockCb.checked) return;
278
- isDragging = true;
279
- dragStartX = e.clientX; dragStartY = e.clientY;
280
- dragStartOX = offsetX; dragStartOY = offsetY;
281
- img.classList.add('dragging');
282
- e.preventDefault();
283
- });
284
-
285
- document.addEventListener('mousemove', (e) => {
286
- if (!isDragging) return;
287
- offsetX = dragStartOX + (e.clientX - dragStartX);
288
- offsetY = dragStartOY + (e.clientY - dragStartY);
289
- updateTransform();
290
- });
291
-
292
- document.addEventListener('mouseup', () => {
293
- isDragging = false;
294
- img.classList.remove('dragging');
295
- });
296
-
297
- // Scroll zoom
298
- document.addEventListener('wheel', (e) => {
299
- if (lockCb.checked) return;
300
- e.preventDefault();
301
- const delta = e.deltaY > 0 ? -2 : 2;
302
- const newVal = Math.max(10, Math.min(300, parseInt(scaleSlider.value) + delta));
303
- scaleSlider.value = String(newVal);
304
- scale = newVal / 100;
305
- scaleVal.textContent = newVal + '%';
306
- updateTransform();
307
- }, { passive: false });
308
-
309
- // WebSocket to CLI
310
- const ws = new WebSocket('ws://127.0.0.1:${wsPort}');
311
- ws.onopen = () => { status.textContent = 'connected'; };
312
- ws.onclose = () => { status.textContent = 'disconnected'; };
313
-
314
- confirmBtn.addEventListener('click', () => {
315
- const params = { offsetX, offsetY, scale, opacity, scrollY: 0 };
316
- // Try to get iframe scroll position
317
- try {
318
- const frame = document.getElementById('target-frame');
319
- params.scrollY = frame.contentWindow.scrollY || 0;
320
- } catch(e) {}
321
- ws.send(JSON.stringify({ type: 'confirm', params }));
322
- status.textContent = 'saved!';
323
- confirmBtn.textContent = 'saved!';
324
- confirmBtn.style.background = '#666';
325
- });
326
-
327
- updateTransform();
328
- })();
329
- </script>
330
- </body>
331
- </html>`;
332
- }
333
-
334
- // src/commands/overlay.ts
335
139
  function buildOverlayOptions(raw) {
336
140
  if (!raw.design) throw new Error("--design is required (path to design screenshot)");
337
141
  if (!raw.url) throw new Error("--url is required (target page URL)");
338
142
  return {
339
143
  designImagePath: resolve(raw.design),
340
144
  targetUrl: raw.url,
341
- port: raw.port ? parseInt(raw.port, 10) : 9876,
342
145
  cdp: raw.cdp,
343
146
  output: raw.output,
344
147
  selector: raw.selector ? resolveSelector(raw.selector) : void 0,
@@ -371,8 +174,8 @@ async function captureGhost(opts, params) {
371
174
  }
372
175
  }
373
176
  async function compositeGhost(opts) {
374
- const sharp = (await import("sharp")).default;
375
- const { tintDesignImage } = await import("./tint-I3FTT23O.js");
177
+ const sharp2 = (await import("sharp")).default;
178
+ const { tintDesignImage } = await import("./tint-YN63MLVN.js");
376
179
  const engine = await createEngine({ url: opts.targetUrl, cdp: opts.cdp });
377
180
  let elementBuf;
378
181
  try {
@@ -380,12 +183,12 @@ async function compositeGhost(opts) {
380
183
  } finally {
381
184
  await engine.close();
382
185
  }
383
- const elementMeta = await sharp(elementBuf).metadata();
186
+ const elementMeta = await sharp2(elementBuf).metadata();
384
187
  const ew = elementMeta.width;
385
188
  const eh = elementMeta.height;
386
189
  const tintedBuf = await tintDesignImage(opts.designImagePath);
387
- const tintedResized = await sharp(tintedBuf).resize(ew, eh, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } }).toBuffer();
388
- const ghostBuf = await sharp(elementBuf).composite([{ input: tintedResized, blend: "over" }]).png().toBuffer();
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();
389
192
  const outputPath = opts.output ?? `overlay-${Date.now()}.png`;
390
193
  await mkdir2(dirname2(resolve(outputPath)), { recursive: true }).catch(() => {
391
194
  });
@@ -407,59 +210,505 @@ async function overlay(raw) {
407
210
  });
408
211
  return;
409
212
  }
410
- const { tintDesignImage } = await import("./tint-I3FTT23O.js");
411
- const tintedBuf = await tintDesignImage(opts.designImagePath);
412
- const imageBase64 = tintedBuf.toString("base64");
413
- const wsPort = opts.port + 1;
414
- const html = generateOverlayHtml({
415
- targetUrl: opts.targetUrl,
416
- designImageBase64: imageBase64,
417
- wsPort
418
- });
419
- const server = createServer((req, res) => {
420
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
421
- res.end(html);
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
+ };
422
302
  });
423
- const wss = new WebSocketServer({ port: wsPort });
424
- await new Promise((resolvePromise) => {
425
- wss.on("connection", (ws) => {
426
- ws.on("message", async (data) => {
427
- try {
428
- const msg = JSON.parse(data.toString());
429
- if (msg.type === "confirm") {
430
- console.log(JSON.stringify({ confirmed: true, params: msg.params }));
431
- ws.send(JSON.stringify({ type: "saved" }));
432
- wss.close();
433
- server.close();
434
- resolvePromise();
435
- }
436
- } catch (e) {
437
- console.error("WebSocket error:", e);
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
438
381
  }
439
- });
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
440
400
  });
441
- server.listen(opts.port, () => {
442
- const url = `http://127.0.0.1:${opts.port}`;
443
- console.error(`Overlay UI: ${url}`);
444
- console.error("Adjust the overlay, then click confirm.");
445
- import("child_process").then(({ exec }) => {
446
- const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
447
- exec(`${cmd} ${url}`);
448
- });
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
449
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(() => {
450
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;
451
699
  }
452
700
 
453
701
  // src/cli.ts
454
702
  var program = new Command();
455
703
  program.name("designer").description(
456
- "Runtime UI measurement CLI for AI agents: measure CSS/layout, capture screenshots, and build design overlays."
704
+ "Runtime UI measurement CLI for AI agents: measure CSS/layout, capture screenshots, build overlays, and list visual changes."
457
705
  ).version("0.1.0");
458
706
  program.addHelpText("after", `
459
707
  Workflow (agent):
460
708
  1. measure Read element bbox and computed CSS as JSON
461
709
  2. screenshot Capture design/runtime component images
462
710
  3. overlay Compare design image against runtime page
711
+ 4. changelist List changed regions between design/runtime screenshots
463
712
 
464
713
  Engine selection:
465
714
  Default: Playwright (launches headless Chromium)
@@ -470,6 +719,7 @@ Examples:
470
719
  $ designer measure --url http://127.0.0.1:32767/start.html --frame "#mainFrame" --selector "#u0"
471
720
  $ designer screenshot --url http://localhost:3000 --selector ".dialog" --output runtime.png
472
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
473
723
  `);
474
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);
475
725
  measureCmd.addHelpText("after", `
@@ -482,7 +732,7 @@ Output (json):
482
732
  }
483
733
  `);
484
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);
485
- 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").option("--port <port>", "Local server port for interactive UI", "9876").action(overlay);
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);
486
736
  overlayCmd.addHelpText("after", `
487
737
  Modes:
488
738
  Selector:
@@ -491,8 +741,8 @@ Modes:
491
741
  Direct full-page:
492
742
  $ designer overlay --design spec.png --url http://localhost:3000 --offset-x 0 --offset-y 0 --output overlay.png
493
743
 
494
- Interactive:
495
- $ designer overlay --design spec.png --url http://localhost:3000
744
+ Without --selector, provide at least --offset-x or --offset-y for direct full-page mode.
496
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);
497
747
  program.parse();
498
748
  //# sourceMappingURL=cli.js.map