@americano98/peye 0.1.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/dist/bin.js ADDED
@@ -0,0 +1,3219 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli/bin.ts
4
+ import { Command } from "commander";
5
+
6
+ // package.json
7
+ var package_default = {
8
+ name: "@americano98/peye",
9
+ version: "0.1.0",
10
+ description: "Standalone CLI for visual diffing UI screenshots against Figma references.",
11
+ type: "module",
12
+ packageManager: "pnpm@10.11.0",
13
+ bin: {
14
+ peye: "dist/bin.js"
15
+ },
16
+ publishConfig: {
17
+ access: "public"
18
+ },
19
+ files: [
20
+ "dist"
21
+ ],
22
+ engines: {
23
+ node: ">=22"
24
+ },
25
+ scripts: {
26
+ build: "tsup",
27
+ clean: "rm -rf coverage dist test-output peye-output",
28
+ dev: "tsx src/cli/bin.ts",
29
+ format: "prettier . --write",
30
+ "format:check": "prettier . --check",
31
+ lint: "eslint .",
32
+ "lint:fix": "eslint . --fix",
33
+ prepack: "pnpm check",
34
+ test: "vitest run",
35
+ "test:watch": "vitest",
36
+ typecheck: "tsc --noEmit",
37
+ check: "pnpm format:check && pnpm typecheck && pnpm lint && pnpm test && pnpm build"
38
+ },
39
+ keywords: [
40
+ "cli",
41
+ "visual-diff",
42
+ "figma",
43
+ "playwright",
44
+ "pixelmatch"
45
+ ],
46
+ homepage: "https://github.com/americano98/peye#readme",
47
+ repository: {
48
+ type: "git",
49
+ url: "git+https://github.com/americano98/peye.git"
50
+ },
51
+ bugs: {
52
+ url: "https://github.com/americano98/peye/issues"
53
+ },
54
+ license: "MIT",
55
+ dependencies: {
56
+ "@modelcontextprotocol/sdk": "1.27.1",
57
+ commander: "^14.0.3",
58
+ pixelmatch: "7.1.0",
59
+ playwright: "1.58.2",
60
+ sharp: "0.34.5"
61
+ },
62
+ devDependencies: {
63
+ "@eslint/js": "10.0.1",
64
+ "@types/node": "^22.19.15",
65
+ eslint: "10.0.3",
66
+ "eslint-config-prettier": "10.1.8",
67
+ globals: "17.4.0",
68
+ prettier: "3.8.1",
69
+ tsup: "8.5.1",
70
+ tsx: "4.21.0",
71
+ typescript: "5.9.3",
72
+ "typescript-eslint": "8.57.1",
73
+ vitest: "^4.1.0"
74
+ }
75
+ };
76
+
77
+ // src/cli/compare-command.ts
78
+ import path7 from "path";
79
+
80
+ // src/config/defaults.ts
81
+ var DEFAULT_MODE = "all";
82
+ var DEFAULT_THRESHOLDS = {
83
+ pass: 0.5,
84
+ tolerated: 1.5,
85
+ retry: 5
86
+ };
87
+ var DEFAULT_PIXELMATCH_THRESHOLD = 0.1;
88
+ var DEFAULT_EDGE_THRESHOLD = 96;
89
+ var DEFAULT_MIN_REGION_PIXELS = 9;
90
+ var DEFAULT_CAPTURE_DELAY_MS = 250;
91
+ var DEFAULT_VIEWPORT_HEIGHT = 900;
92
+ var DEFAULT_NAVIGATION_TIMEOUT_MS = 3e4;
93
+ var DEFAULT_RESOURCE_TIMEOUT_MS = 15e3;
94
+ var DEFAULT_FONT_READY_TIMEOUT_MS = 5e3;
95
+ var DEFAULT_REPORT_FINDINGS_LIMIT = 25;
96
+ var DEFAULT_MAX_TEXT_SNIPPET_LENGTH = 80;
97
+ var DEFAULT_MAX_SELECTOR_LENGTH = 120;
98
+ var DEFAULT_MAX_TAG_ROLLUPS = 10;
99
+ var DEFAULT_DOM_OVERLAP_THRESHOLD = 0.3;
100
+ var DEFAULT_CLUSTER_PADDING_PX = 8;
101
+ var DEFAULT_HOTSPOT_CLUSTER_PADDING_PX = 6;
102
+ var DEFAULT_HOTSPOT_LIMIT_PER_FINDING = 5;
103
+ var DEFAULT_FIGMA_SOURCE = "auto";
104
+ var DEFAULT_FIGMA_API_BASE_URL = "https://api.figma.com";
105
+ var DEFAULT_FIGMA_MCP_DESKTOP_URL = "http://127.0.0.1:3845/mcp";
106
+ var DEFAULT_FIGMA_MCP_REMOTE_URL = "https://mcp.figma.com/mcp";
107
+ var DEFAULT_FIGMA_OAUTH_TIMEOUT_MS = 18e4;
108
+ var LAYOUT_EDGE_RATIO_THRESHOLD = 0.35;
109
+ var COLOR_REGION_DELTA_THRESHOLD = 10;
110
+ var STRONG_DIMENSION_DELTA_PX = 40;
111
+ var STRONG_DIMENSION_ASPECT_DELTA = 0.1;
112
+ var HUMAN_REVIEW_MISMATCH_MULTIPLIER = 3;
113
+ var MAX_RGB_DISTANCE = 441.6729559300637;
114
+
115
+ // src/core/run-compare.ts
116
+ import path6 from "path";
117
+
118
+ // src/utils/errors.ts
119
+ var AppError = class extends Error {
120
+ code;
121
+ exitCode;
122
+ recommendation;
123
+ severity;
124
+ constructor(message, options) {
125
+ super(message, options?.cause ? { cause: options.cause } : void 0);
126
+ this.name = "AppError";
127
+ this.code = options?.code ?? "app_error";
128
+ this.exitCode = options?.exitCode ?? 1;
129
+ this.recommendation = options?.recommendation ?? null;
130
+ this.severity = options?.severity ?? null;
131
+ }
132
+ };
133
+ function isAppError(error) {
134
+ return error instanceof AppError;
135
+ }
136
+ function ensureError(error) {
137
+ if (error instanceof Error) {
138
+ return error;
139
+ }
140
+ return new Error(typeof error === "string" ? error : "Unknown error");
141
+ }
142
+ function createFailureReport(params) {
143
+ const emptyMetrics = {
144
+ mismatchPixels: 0,
145
+ mismatchPercent: 0,
146
+ meanColorDelta: null,
147
+ maxColorDelta: null,
148
+ structuralMismatchPercent: null,
149
+ dimensionMismatch: {
150
+ widthDelta: 0,
151
+ heightDelta: 0,
152
+ aspectRatioDelta: 0,
153
+ hasMismatch: false
154
+ },
155
+ findingsCount: 0,
156
+ affectedElementCount: 0
157
+ };
158
+ return {
159
+ analysisMode: params.analysisMode,
160
+ summary: {
161
+ recommendation: params.recommendation,
162
+ severity: params.severity,
163
+ reason: params.reason
164
+ },
165
+ inputs: {
166
+ preview: params.preview,
167
+ reference: params.reference,
168
+ viewport: params.viewport,
169
+ mode: params.mode,
170
+ fullPage: params.fullPage
171
+ },
172
+ images: params.images,
173
+ metrics: emptyMetrics,
174
+ rollups: {
175
+ bySeverity: [],
176
+ byKind: [],
177
+ byTag: [],
178
+ rawRegionCount: 0,
179
+ findingsCount: 0,
180
+ affectedElementCount: 0,
181
+ omittedFindings: 0
182
+ },
183
+ findings: [],
184
+ artifacts: params.artifacts,
185
+ error: params.error
186
+ };
187
+ }
188
+
189
+ // src/utils/severity.ts
190
+ var SEVERITY_RANK = {
191
+ low: 0,
192
+ medium: 1,
193
+ high: 2,
194
+ critical: 3
195
+ };
196
+ function maxSeverity(values) {
197
+ if (values.length === 0) {
198
+ return "low";
199
+ }
200
+ return values.reduce(
201
+ (current, candidate) => SEVERITY_RANK[candidate] > SEVERITY_RANK[current] ? candidate : current
202
+ );
203
+ }
204
+ function compareSeverityDescending(left, right) {
205
+ return SEVERITY_RANK[right] - SEVERITY_RANK[left];
206
+ }
207
+
208
+ // src/analysis/findings.ts
209
+ var FINDING_KIND_ORDER = ["dimension", "mixed", "layout", "color", "pixel"];
210
+ function buildFindingsAnalysis(params) {
211
+ const totalPixels = params.width * params.height;
212
+ const draftFindingGroups = params.analysisMode === "dom-elements" ? buildDomFindings(
213
+ params.rawRegions,
214
+ params.domSnapshot,
215
+ params.width,
216
+ params.height,
217
+ totalPixels
218
+ ) : buildVisualClusterFindings(params.rawRegions, totalPixels, params.width, params.height);
219
+ const sortedGroups = draftFindingGroups.sort(
220
+ (left, right) => compareDraftFindings(left.finding, right.finding)
221
+ );
222
+ const sortedFindings = sortedGroups.map((group) => group.finding);
223
+ const limitedGroups = sortedGroups.slice(0, DEFAULT_REPORT_FINDINGS_LIMIT);
224
+ const limitedFindings = limitedGroups.map((group) => group.finding);
225
+ const affectedElementCount = params.analysisMode === "dom-elements" ? new Set(
226
+ sortedFindings.map((finding) => finding.element?.selector ?? null).filter((selector) => selector !== null)
227
+ ).size : 0;
228
+ return {
229
+ findings: limitedFindings.map((finding, index) => ({
230
+ id: `finding-${String(index + 1).padStart(3, "0")}`,
231
+ ...finding
232
+ })),
233
+ rollups: {
234
+ bySeverity: buildSeverityRollups(sortedFindings),
235
+ byKind: buildKindRollups(sortedFindings),
236
+ byTag: params.analysisMode === "dom-elements" ? buildTagRollups(sortedFindings).slice(0, DEFAULT_MAX_TAG_ROLLUPS) : [],
237
+ rawRegionCount: params.rawRegions.length,
238
+ findingsCount: sortedFindings.length,
239
+ affectedElementCount,
240
+ omittedFindings: Math.max(0, sortedFindings.length - limitedFindings.length)
241
+ },
242
+ metrics: {
243
+ findingsCount: sortedFindings.length,
244
+ affectedElementCount
245
+ },
246
+ visuals: sortedGroups.map((group) => {
247
+ const primaryBox = group.finding.element?.bbox ?? group.finding.bbox;
248
+ return {
249
+ severity: group.finding.severity,
250
+ primaryBox,
251
+ hotspotBoxes: group.finding.hotspots
252
+ };
253
+ })
254
+ };
255
+ }
256
+ function buildDomFindings(rawRegions, domSnapshot, width, height, totalPixels) {
257
+ if (!domSnapshot) {
258
+ throw new AppError("Preview URL capture did not produce a DOM snapshot.", {
259
+ exitCode: 3,
260
+ recommendation: "needs_human_review",
261
+ severity: "high",
262
+ code: "dom_snapshot_missing"
263
+ });
264
+ }
265
+ if (rawRegions.length === 0) {
266
+ return [];
267
+ }
268
+ const candidates = buildDomCandidates(domSnapshot, width, height);
269
+ const regionsByElement = /* @__PURE__ */ new Map();
270
+ const elementsById = new Map(candidates.map((element) => [element.id, element]));
271
+ for (const region of rawRegions) {
272
+ const element = resolveDomElementForRegion(region, candidates);
273
+ const existing = regionsByElement.get(element.id);
274
+ if (existing) {
275
+ existing.push(region);
276
+ } else {
277
+ regionsByElement.set(element.id, [region]);
278
+ }
279
+ }
280
+ return Array.from(regionsByElement.entries(), ([elementId, regions]) => {
281
+ const element = elementsById.get(elementId);
282
+ if (!element) {
283
+ throw new AppError(`DOM element snapshot missing for finding group ${elementId}.`, {
284
+ exitCode: 3,
285
+ recommendation: "needs_human_review",
286
+ severity: "high",
287
+ code: "dom_snapshot_element_missing"
288
+ });
289
+ }
290
+ return {
291
+ finding: buildDraftFinding({
292
+ source: "dom-element",
293
+ regions,
294
+ totalPixels,
295
+ element,
296
+ canvasWidth: width,
297
+ canvasHeight: height
298
+ }),
299
+ regions
300
+ };
301
+ });
302
+ }
303
+ function buildVisualClusterFindings(rawRegions, totalPixels, width, height) {
304
+ if (rawRegions.length === 0) {
305
+ return [];
306
+ }
307
+ const clusters = clusterRegions(rawRegions);
308
+ return clusters.map((cluster) => ({
309
+ finding: buildDraftFinding({
310
+ source: "visual-cluster",
311
+ regions: cluster,
312
+ totalPixels,
313
+ element: null,
314
+ canvasWidth: width,
315
+ canvasHeight: height
316
+ }),
317
+ regions: cluster
318
+ }));
319
+ }
320
+ function buildDraftFinding(params) {
321
+ const bbox = unionRegionBoxes(params.regions);
322
+ const mismatchPixels = params.regions.reduce((sum, region) => sum + region.pixelCount, 0);
323
+ const kind = aggregateKind(params.regions);
324
+ const elementReport = params.element ? toElementReport(params.element) : null;
325
+ const primaryBox = elementReport?.bbox ?? bbox;
326
+ return {
327
+ source: params.source,
328
+ kind,
329
+ severity: maxSeverity(params.regions.map((region) => region.severity)),
330
+ summary: buildFindingSummary(kind, elementReport?.tag ?? null),
331
+ bbox,
332
+ regionCount: params.regions.length,
333
+ mismatchPixels,
334
+ mismatchPercentOfCanvas: params.totalPixels === 0 ? 0 : Number((mismatchPixels / params.totalPixels * 100).toFixed(4)),
335
+ issueTypes: issueTypesForKind(kind),
336
+ signals: buildFindingSignals({
337
+ kind,
338
+ bbox,
339
+ element: params.element,
340
+ canvasWidth: params.canvasWidth,
341
+ canvasHeight: params.canvasHeight
342
+ }),
343
+ hotspots: buildHotspotBoxes(params.regions, primaryBox),
344
+ element: elementReport
345
+ };
346
+ }
347
+ function buildDomCandidates(domSnapshot, width, height) {
348
+ const root = {
349
+ ...domSnapshot.root,
350
+ bbox: {
351
+ x: 0,
352
+ y: 0,
353
+ width,
354
+ height
355
+ }
356
+ };
357
+ return [root, ...domSnapshot.elements];
358
+ }
359
+ function resolveDomElementForRegion(region, candidates) {
360
+ const centerX = region.x + region.width / 2;
361
+ const centerY = region.y + region.height / 2;
362
+ const containing = candidates.filter(
363
+ (candidate) => containsPoint(candidate.bbox, centerX, centerY)
364
+ );
365
+ if (containing.length > 0) {
366
+ return containing.sort(compareDomCandidates)[0];
367
+ }
368
+ const regionBox = {
369
+ x: region.x,
370
+ y: region.y,
371
+ width: region.width,
372
+ height: region.height
373
+ };
374
+ let bestCandidate = null;
375
+ let bestOverlap = 0;
376
+ for (const candidate of candidates) {
377
+ const overlap = overlapRatio(regionBox, candidate.bbox);
378
+ if (overlap > bestOverlap) {
379
+ bestCandidate = candidate;
380
+ bestOverlap = overlap;
381
+ continue;
382
+ }
383
+ if (overlap === bestOverlap && bestCandidate && compareDomCandidates(candidate, bestCandidate) < 0) {
384
+ bestCandidate = candidate;
385
+ }
386
+ }
387
+ if (bestCandidate && bestOverlap >= DEFAULT_DOM_OVERLAP_THRESHOLD) {
388
+ return bestCandidate;
389
+ }
390
+ throw new AppError(
391
+ `Could not assign mismatch region at (${region.x}, ${region.y}, ${region.width}x${region.height}) to a DOM element.`,
392
+ {
393
+ exitCode: 3,
394
+ recommendation: "needs_human_review",
395
+ severity: "high",
396
+ code: "dom_region_assignment_failed"
397
+ }
398
+ );
399
+ }
400
+ function compareDomCandidates(left, right) {
401
+ if (left.depth !== right.depth) {
402
+ return right.depth - left.depth;
403
+ }
404
+ const leftArea = left.bbox.width * left.bbox.height;
405
+ const rightArea = right.bbox.width * right.bbox.height;
406
+ if (leftArea !== rightArea) {
407
+ return leftArea - rightArea;
408
+ }
409
+ return left.selector.localeCompare(right.selector);
410
+ }
411
+ function clusterRegions(rawRegions, padding = DEFAULT_CLUSTER_PADDING_PX) {
412
+ const visited = new Uint8Array(rawRegions.length);
413
+ const clusters = [];
414
+ for (let index = 0; index < rawRegions.length; index += 1) {
415
+ if (visited[index] === 1) {
416
+ continue;
417
+ }
418
+ visited[index] = 1;
419
+ const queue = [index];
420
+ const cluster = [];
421
+ while (queue.length > 0) {
422
+ const currentIndex = queue.shift();
423
+ if (currentIndex === void 0) {
424
+ break;
425
+ }
426
+ const currentRegion = rawRegions[currentIndex];
427
+ cluster.push(currentRegion);
428
+ for (let candidateIndex = 0; candidateIndex < rawRegions.length; candidateIndex += 1) {
429
+ if (visited[candidateIndex] === 1) {
430
+ continue;
431
+ }
432
+ if (expandedBoxesIntersect(currentRegion, rawRegions[candidateIndex], padding)) {
433
+ visited[candidateIndex] = 1;
434
+ queue.push(candidateIndex);
435
+ }
436
+ }
437
+ }
438
+ clusters.push(cluster);
439
+ }
440
+ return clusters;
441
+ }
442
+ function expandedBoxesIntersect(left, right, padding) {
443
+ return boxesIntersect(expandBox(left, padding), expandBox(right, padding));
444
+ }
445
+ function expandBox(box, padding) {
446
+ return {
447
+ x: box.x - padding,
448
+ y: box.y - padding,
449
+ width: box.width + padding * 2,
450
+ height: box.height + padding * 2
451
+ };
452
+ }
453
+ function boxesIntersect(left, right) {
454
+ return !(left.x + left.width < right.x || right.x + right.width < left.x || left.y + left.height < right.y || right.y + right.height < left.y);
455
+ }
456
+ function containsPoint(box, x, y) {
457
+ return x >= box.x && x <= box.x + box.width && y >= box.y && y <= box.y + box.height;
458
+ }
459
+ function overlapRatio(left, right) {
460
+ const intersectionWidth = Math.min(left.x + left.width, right.x + right.width) - Math.max(left.x, right.x);
461
+ const intersectionHeight = Math.min(left.y + left.height, right.y + right.height) - Math.max(left.y, right.y);
462
+ if (intersectionWidth <= 0 || intersectionHeight <= 0) {
463
+ return 0;
464
+ }
465
+ const regionArea = Math.max(1, left.width * left.height);
466
+ return intersectionWidth * intersectionHeight / regionArea;
467
+ }
468
+ function unionRegionBoxes(regions) {
469
+ const minX = Math.min(...regions.map((region) => region.x));
470
+ const minY = Math.min(...regions.map((region) => region.y));
471
+ const maxX = Math.max(...regions.map((region) => region.x + region.width));
472
+ const maxY = Math.max(...regions.map((region) => region.y + region.height));
473
+ return {
474
+ x: minX,
475
+ y: minY,
476
+ width: maxX - minX,
477
+ height: maxY - minY
478
+ };
479
+ }
480
+ function buildHotspotBoxes(regions, primaryBox) {
481
+ if (regions.length <= 1) {
482
+ return [];
483
+ }
484
+ return clusterRegions(regions, DEFAULT_HOTSPOT_CLUSTER_PADDING_PX).map((cluster) => ({
485
+ bbox: unionRegionBoxes(cluster),
486
+ mismatchPixels: cluster.reduce((sum, region) => sum + region.pixelCount, 0)
487
+ })).filter(({ bbox }) => !isNearlySameBox(bbox, primaryBox)).sort((left, right) => {
488
+ if (left.mismatchPixels !== right.mismatchPixels) {
489
+ return right.mismatchPixels - left.mismatchPixels;
490
+ }
491
+ if (left.bbox.y !== right.bbox.y) {
492
+ return left.bbox.y - right.bbox.y;
493
+ }
494
+ return left.bbox.x - right.bbox.x;
495
+ }).slice(0, DEFAULT_HOTSPOT_LIMIT_PER_FINDING).map(({ bbox }) => bbox);
496
+ }
497
+ function isNearlySameBox(left, right) {
498
+ const leftArea = Math.max(1, left.width * left.height);
499
+ const rightArea = Math.max(1, right.width * right.height);
500
+ const areaRatio = Math.min(leftArea, rightArea) / Math.max(leftArea, rightArea);
501
+ return areaRatio >= 0.9 && overlapRatio(left, right) >= 0.9;
502
+ }
503
+ function aggregateKind(regions) {
504
+ const kinds = new Set(regions.map((region) => region.kind));
505
+ if (kinds.has("dimension")) {
506
+ return "dimension";
507
+ }
508
+ if (kinds.has("mixed")) {
509
+ return "mixed";
510
+ }
511
+ if (kinds.has("layout") && (kinds.has("color") || kinds.has("pixel"))) {
512
+ return "mixed";
513
+ }
514
+ if (kinds.has("layout")) {
515
+ return "layout";
516
+ }
517
+ if (kinds.has("color")) {
518
+ return "color";
519
+ }
520
+ return "pixel";
521
+ }
522
+ function buildFindingSummary(kind, tag) {
523
+ const subject = tag ? `Element <${tag}>` : "Visual cluster";
524
+ switch (kind) {
525
+ case "dimension":
526
+ return `${subject} is missing content, has extra content, or was captured at the wrong canvas size.`;
527
+ case "layout":
528
+ return `${subject} differs in position, spacing, or alignment.`;
529
+ case "color":
530
+ return `${subject} differs in color or visual styling.`;
531
+ case "mixed":
532
+ return `${subject} differs in both layout and styling.`;
533
+ case "pixel":
534
+ default:
535
+ return `${subject} differs in rendering or fine-grained styling.`;
536
+ }
537
+ }
538
+ function issueTypesForKind(kind) {
539
+ switch (kind) {
540
+ case "dimension":
541
+ return ["missing_or_extra", "size"];
542
+ case "layout":
543
+ return ["position", "spacing"];
544
+ case "color":
545
+ return ["color", "style"];
546
+ case "mixed":
547
+ return ["position", "spacing", "style"];
548
+ case "pixel":
549
+ default:
550
+ return ["style"];
551
+ }
552
+ }
553
+ function buildFindingSignals(params) {
554
+ const signals = [];
555
+ const textClippingSignal = buildTextClippingSignal(params.element);
556
+ if (textClippingSignal) {
557
+ signals.push(textClippingSignal);
558
+ }
559
+ const captureCropSignal = buildCaptureCropSignal(params.element);
560
+ if (captureCropSignal) {
561
+ signals.push(captureCropSignal);
562
+ } else {
563
+ const viewportSignal = buildViewportMismatchSignal(
564
+ params.kind,
565
+ params.bbox,
566
+ params.canvasWidth,
567
+ params.canvasHeight
568
+ );
569
+ if (viewportSignal) {
570
+ signals.push(viewportSignal);
571
+ }
572
+ }
573
+ return signals;
574
+ }
575
+ function buildTextClippingSignal(element) {
576
+ if (!element?.textSnippet || !element.textMetrics) {
577
+ return null;
578
+ }
579
+ const { textMetrics } = element;
580
+ const hasOverflowX = textMetrics.scrollWidth > textMetrics.clientWidth + 1;
581
+ const hasOverflowY = textMetrics.scrollHeight > textMetrics.clientHeight + 1;
582
+ const clipsX = hasOverflowX && (textMetrics.overflowX === "hidden" || textMetrics.overflowX === "clip" || textMetrics.textOverflow === "ellipsis");
583
+ const lineClampActive = textMetrics.lineClamp !== null && textMetrics.lineClamp !== "" && textMetrics.lineClamp !== "none" && textMetrics.lineClamp !== "0" && textMetrics.lineClamp !== "normal";
584
+ const clipsY = hasOverflowY && (textMetrics.overflowY === "hidden" || textMetrics.overflowY === "clip" || lineClampActive);
585
+ if (!clipsX && !clipsY) {
586
+ return null;
587
+ }
588
+ const axis = clipsX && clipsY ? "horizontal and vertical axes" : clipsX ? "horizontal axis" : "vertical axis";
589
+ return {
590
+ code: "probable_text_clipping",
591
+ confidence: lineClampActive || clipsX && clipsY ? "high" : "medium",
592
+ message: `Text content likely overflows the element bounds and is being clipped on the ${axis}.`
593
+ };
594
+ }
595
+ function buildCaptureCropSignal(element) {
596
+ if (!element || element.captureClippedEdges.length === 0) {
597
+ return null;
598
+ }
599
+ return {
600
+ code: "possible_capture_crop",
601
+ confidence: "high",
602
+ message: `Element bounds were clipped by the preview capture on the ${formatEdgeList(element.captureClippedEdges)} edge(s); check selector scope and capture framing.`
603
+ };
604
+ }
605
+ function buildViewportMismatchSignal(kind, bbox, canvasWidth, canvasHeight) {
606
+ if (kind !== "dimension") {
607
+ return null;
608
+ }
609
+ const touchingEdges = boxEdgesAgainstCanvas(bbox, canvasWidth, canvasHeight);
610
+ if (touchingEdges.length === 0) {
611
+ return null;
612
+ }
613
+ return {
614
+ code: "possible_viewport_mismatch",
615
+ confidence: "medium",
616
+ message: `Dimension mismatch reaches the ${formatEdgeList(touchingEdges)} edge(s) of the comparison canvas; verify viewport, selected frame, and capture target.`
617
+ };
618
+ }
619
+ function boxEdgesAgainstCanvas(box, canvasWidth, canvasHeight) {
620
+ const edges = [];
621
+ if (box.y <= 0) {
622
+ edges.push("top");
623
+ }
624
+ if (box.x + box.width >= canvasWidth) {
625
+ edges.push("right");
626
+ }
627
+ if (box.y + box.height >= canvasHeight) {
628
+ edges.push("bottom");
629
+ }
630
+ if (box.x <= 0) {
631
+ edges.push("left");
632
+ }
633
+ return edges;
634
+ }
635
+ function formatEdgeList(edges) {
636
+ if (edges.length === 1) {
637
+ return edges[0];
638
+ }
639
+ if (edges.length === 2) {
640
+ return `${edges[0]} and ${edges[1]}`;
641
+ }
642
+ return `${edges.slice(0, -1).join(", ")}, and ${edges[edges.length - 1]}`;
643
+ }
644
+ function toElementReport(element) {
645
+ return {
646
+ tag: element.tag,
647
+ selector: element.selector,
648
+ role: element.role,
649
+ textSnippet: element.textSnippet,
650
+ bbox: element.bbox
651
+ };
652
+ }
653
+ function buildSeverityRollups(findings) {
654
+ const severityCounts = /* @__PURE__ */ new Map();
655
+ for (const finding of findings) {
656
+ severityCounts.set(finding.severity, (severityCounts.get(finding.severity) ?? 0) + 1);
657
+ }
658
+ return Array.from(severityCounts.entries()).map(([severity, count]) => ({ severity, count })).sort((left, right) => compareSeverityDescending(left.severity, right.severity));
659
+ }
660
+ function buildKindRollups(findings) {
661
+ const kindCounts = /* @__PURE__ */ new Map();
662
+ for (const finding of findings) {
663
+ kindCounts.set(finding.kind, (kindCounts.get(finding.kind) ?? 0) + 1);
664
+ }
665
+ return Array.from(kindCounts.entries()).map(([kind, count]) => ({ kind, count })).sort(
666
+ (left, right) => FINDING_KIND_ORDER.indexOf(left.kind) - FINDING_KIND_ORDER.indexOf(right.kind)
667
+ );
668
+ }
669
+ function buildTagRollups(findings) {
670
+ const tagCounts = /* @__PURE__ */ new Map();
671
+ for (const finding of findings) {
672
+ const tag = finding.element?.tag;
673
+ if (!tag) {
674
+ continue;
675
+ }
676
+ tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
677
+ }
678
+ return Array.from(tagCounts.entries()).map(([tag, count]) => ({ tag, count })).sort((left, right) => {
679
+ if (left.count !== right.count) {
680
+ return right.count - left.count;
681
+ }
682
+ return left.tag.localeCompare(right.tag);
683
+ });
684
+ }
685
+ function compareDraftFindings(left, right) {
686
+ const severityOrder = compareSeverityDescending(left.severity, right.severity);
687
+ if (severityOrder !== 0) {
688
+ return severityOrder;
689
+ }
690
+ if (left.mismatchPixels !== right.mismatchPixels) {
691
+ return right.mismatchPixels - left.mismatchPixels;
692
+ }
693
+ if (left.bbox.y !== right.bbox.y) {
694
+ return left.bbox.y - right.bbox.y;
695
+ }
696
+ if (left.bbox.x !== right.bbox.x) {
697
+ return left.bbox.x - right.bbox.x;
698
+ }
699
+ return left.summary.localeCompare(right.summary);
700
+ }
701
+
702
+ // src/analysis/recommendation.ts
703
+ function decideRecommendation(params) {
704
+ const { metrics, thresholds, findings } = params;
705
+ const highestFindingSeverity = maxSeverity(findings.map((finding) => finding.severity));
706
+ const strongDimensionMismatch = hasStrongDimensionMismatch(metrics);
707
+ if (strongDimensionMismatch || metrics.mismatchPercent > thresholds.retry * HUMAN_REVIEW_MISMATCH_MULTIPLIER) {
708
+ return {
709
+ recommendation: "needs_human_review",
710
+ severity: maxSeverity([
711
+ highestFindingSeverity,
712
+ strongDimensionMismatch ? "critical" : "high"
713
+ ]),
714
+ reason: strongDimensionMismatch ? "Reference and preview dimensions diverge too much for a reliable automated verdict." : `Mismatch percentage ${metrics.mismatchPercent.toFixed(2)}% is too large for an automated fix recommendation.`
715
+ };
716
+ }
717
+ if (metrics.mismatchPercent <= thresholds.pass && highestFindingSeverity === "low") {
718
+ return {
719
+ recommendation: "pass",
720
+ severity: "low",
721
+ reason: `Mismatch is ${metrics.mismatchPercent.toFixed(2)}%, within the strict pass threshold.`
722
+ };
723
+ }
724
+ if (metrics.mismatchPercent <= thresholds.tolerated && highestFindingSeverity === "low") {
725
+ return {
726
+ recommendation: "pass_with_tolerated_differences",
727
+ severity: "low",
728
+ reason: `Mismatch is ${metrics.mismatchPercent.toFixed(2)}%, within the tolerated threshold.`
729
+ };
730
+ }
731
+ if (metrics.mismatchPercent <= thresholds.retry || highestFindingSeverity === "medium" || highestFindingSeverity === "high") {
732
+ return {
733
+ recommendation: "retry_fix",
734
+ severity: highestFindingSeverity === "critical" ? "high" : highestFindingSeverity,
735
+ reason: `Mismatch is ${metrics.mismatchPercent.toFixed(2)}%; localized issues were detected and should be fixed before retrying.`
736
+ };
737
+ }
738
+ return {
739
+ recommendation: "needs_human_review",
740
+ severity: maxSeverity([highestFindingSeverity, "high"]),
741
+ reason: `Mismatch is ${metrics.mismatchPercent.toFixed(2)}% and exceeds the retry threshold.`
742
+ };
743
+ }
744
+ function hasStrongDimensionMismatch(metrics) {
745
+ return metrics.dimensionMismatch.hasMismatch && (Math.abs(metrics.dimensionMismatch.widthDelta) >= STRONG_DIMENSION_DELTA_PX || Math.abs(metrics.dimensionMismatch.heightDelta) >= STRONG_DIMENSION_DELTA_PX || metrics.dimensionMismatch.aspectRatioDelta >= STRONG_DIMENSION_ASPECT_DELTA);
746
+ }
747
+
748
+ // src/capture/playwright-capture.ts
749
+ import { unlink } from "fs/promises";
750
+ import path2 from "path";
751
+ import { chromium } from "playwright";
752
+
753
+ // src/io/image.ts
754
+ import { writeFile } from "fs/promises";
755
+ import path from "path";
756
+ import sharp from "sharp";
757
+ async function normalizeImageToPng(inputPath, outputPath) {
758
+ return normalizeSourceToPng(inputPath, outputPath, `Failed to normalize image: ${inputPath}`);
759
+ }
760
+ async function bufferToNormalizedPng(buffer, outputPath, options) {
761
+ return normalizeSourceToPng(
762
+ buffer,
763
+ outputPath,
764
+ "Failed to normalize in-memory image buffer.",
765
+ options
766
+ );
767
+ }
768
+ async function loadNormalizedImage(imagePath) {
769
+ try {
770
+ const { data, info } = await sharp(imagePath).rotate().ensureAlpha().toColorspace("srgb").raw().toBuffer({ resolveWithObject: true });
771
+ return {
772
+ width: info.width,
773
+ height: info.height,
774
+ data: new Uint8ClampedArray(data)
775
+ };
776
+ } catch (error) {
777
+ throw new AppError(`Failed to load normalized image data: ${imagePath}`, {
778
+ code: "image_load_failed",
779
+ cause: error
780
+ });
781
+ }
782
+ }
783
+ function padImageToCanvas(image, width, height) {
784
+ if (image.width === width && image.height === height) {
785
+ return image;
786
+ }
787
+ const target = new Uint8ClampedArray(width * height * 4);
788
+ for (let y = 0; y < image.height; y += 1) {
789
+ const sourceOffset = y * image.width * 4;
790
+ const targetOffset = y * width * 4;
791
+ target.set(image.data.subarray(sourceOffset, sourceOffset + image.width * 4), targetOffset);
792
+ }
793
+ return {
794
+ width,
795
+ height,
796
+ data: target
797
+ };
798
+ }
799
+ async function writeRawRgbaPng(outputPath, data, width, height) {
800
+ try {
801
+ await sharp(Buffer.from(data), {
802
+ raw: {
803
+ width,
804
+ height,
805
+ channels: 4
806
+ }
807
+ }).png().toFile(outputPath);
808
+ } catch (error) {
809
+ throw new AppError(`Failed to write PNG artifact: ${outputPath}`, {
810
+ code: "artifact_write_failed",
811
+ cause: error
812
+ });
813
+ }
814
+ }
815
+ async function normalizeSourceToPng(input, outputPath, errorMessage, options) {
816
+ const resolvedOutputPath = path.resolve(outputPath);
817
+ try {
818
+ let pipeline = sharp(input).rotate().ensureAlpha().toColorspace("srgb");
819
+ if (options?.resizeTo) {
820
+ pipeline = pipeline.resize(options.resizeTo.width, options.resizeTo.height, {
821
+ fit: "fill",
822
+ kernel: sharp.kernel.lanczos3
823
+ });
824
+ }
825
+ const { data, info } = await pipeline.png().toBuffer({ resolveWithObject: true });
826
+ if (!info.width || !info.height) {
827
+ throw new AppError(`Could not read normalized image dimensions for ${resolvedOutputPath}`, {
828
+ code: "image_dimensions_missing"
829
+ });
830
+ }
831
+ await writeFile(resolvedOutputPath, data);
832
+ return {
833
+ path: resolvedOutputPath,
834
+ width: info.width,
835
+ height: info.height
836
+ };
837
+ } catch (error) {
838
+ if (error instanceof AppError) {
839
+ throw error;
840
+ }
841
+ throw new AppError(`${errorMessage}: ${ensureError(error).message}`, {
842
+ code: "image_normalization_failed",
843
+ cause: error
844
+ });
845
+ }
846
+ }
847
+
848
+ // src/capture/playwright-capture.ts
849
+ async function materializePreviewImage(preview, outputPath, fullPage) {
850
+ if (preview.kind === "path") {
851
+ try {
852
+ const normalized = await normalizeImageToPng(preview.resolved, outputPath);
853
+ return {
854
+ ...normalized,
855
+ analysisMode: "visual-clusters",
856
+ domSnapshot: null
857
+ };
858
+ } catch (error) {
859
+ throw new AppError(
860
+ `Failed to normalize preview image: ${preview.resolved}. ${ensureError(error).message}`,
861
+ {
862
+ code: "preview_image_normalization_failed",
863
+ cause: error
864
+ }
865
+ );
866
+ }
867
+ }
868
+ const temporaryCapturePath = buildTemporaryCapturePath(outputPath);
869
+ const browser = await chromium.launch({ headless: true });
870
+ let domSnapshot;
871
+ try {
872
+ const page = await browser.newPage({
873
+ viewport: preview.viewport,
874
+ deviceScaleFactor: 1
875
+ });
876
+ await navigateForCapture(page, preview.resolved);
877
+ await waitForCaptureStability(page);
878
+ if (preview.selector !== null) {
879
+ domSnapshot = await captureSelectorScreenshot(page, preview.selector, temporaryCapturePath);
880
+ } else {
881
+ domSnapshot = await collectPageDomSnapshot(page, fullPage);
882
+ await page.screenshot({
883
+ path: temporaryCapturePath,
884
+ animations: "disabled",
885
+ caret: "hide",
886
+ fullPage,
887
+ scale: "css",
888
+ type: "png"
889
+ });
890
+ }
891
+ } finally {
892
+ await browser.close();
893
+ }
894
+ try {
895
+ const normalized = await normalizeImageToPng(temporaryCapturePath, outputPath);
896
+ return {
897
+ ...normalized,
898
+ analysisMode: "dom-elements",
899
+ domSnapshot
900
+ };
901
+ } catch (error) {
902
+ throw new AppError(
903
+ `Failed to normalize captured preview image: ${temporaryCapturePath}. ${ensureError(error).message}`,
904
+ {
905
+ code: "preview_capture_normalization_failed",
906
+ cause: error
907
+ }
908
+ );
909
+ } finally {
910
+ await unlink(temporaryCapturePath).catch(() => void 0);
911
+ }
912
+ }
913
+ async function navigateForCapture(page, url) {
914
+ try {
915
+ await page.goto(url, {
916
+ waitUntil: "networkidle",
917
+ timeout: DEFAULT_NAVIGATION_TIMEOUT_MS
918
+ });
919
+ } catch {
920
+ await page.goto(url, {
921
+ waitUntil: "load",
922
+ timeout: DEFAULT_NAVIGATION_TIMEOUT_MS
923
+ });
924
+ }
925
+ }
926
+ async function waitForCaptureStability(page) {
927
+ await page.evaluate(
928
+ async ({ fontTimeoutMs }) => {
929
+ const fonts = document.fonts;
930
+ if (!fonts) {
931
+ return;
932
+ }
933
+ await Promise.race([
934
+ fonts.ready,
935
+ new Promise((resolve) => {
936
+ window.setTimeout(resolve, fontTimeoutMs);
937
+ })
938
+ ]);
939
+ },
940
+ { fontTimeoutMs: DEFAULT_FONT_READY_TIMEOUT_MS }
941
+ ).catch(() => void 0);
942
+ await page.waitForTimeout(DEFAULT_CAPTURE_DELAY_MS);
943
+ }
944
+ async function captureSelectorScreenshot(page, selector, outputPath) {
945
+ try {
946
+ const locator = page.locator(selector).first();
947
+ await locator.waitFor({ state: "visible", timeout: DEFAULT_NAVIGATION_TIMEOUT_MS });
948
+ await locator.scrollIntoViewIfNeeded();
949
+ const domSnapshot = await collectSelectorDomSnapshot(locator);
950
+ await locator.screenshot({
951
+ path: outputPath,
952
+ animations: "disabled",
953
+ caret: "hide",
954
+ scale: "css",
955
+ type: "png"
956
+ });
957
+ return domSnapshot;
958
+ } catch (error) {
959
+ throw new AppError(
960
+ `Preview selector could not be captured: ${selector}. ${ensureError(error).message}`,
961
+ {
962
+ exitCode: 3,
963
+ recommendation: "needs_human_review",
964
+ severity: "high",
965
+ code: "preview_selector_capture_failed",
966
+ cause: error
967
+ }
968
+ );
969
+ }
970
+ }
971
+ function buildTemporaryCapturePath(outputPath) {
972
+ const parsedPath = path2.parse(outputPath);
973
+ return path2.join(
974
+ parsedPath.dir,
975
+ `${parsedPath.name}.capture-${process.pid}-${Date.now()}${parsedPath.ext || ".png"}`
976
+ );
977
+ }
978
+ async function collectSelectorDomSnapshot(locator) {
979
+ return locator.evaluate(
980
+ (root, { maxSelectorLength, maxTextLength }) => {
981
+ const excludedTags = /* @__PURE__ */ new Set(["script", "style", "noscript", "meta", "link", "head"]);
982
+ const inlineNoiseTags = /* @__PURE__ */ new Set(["span", "strong", "em", "b", "i", "u", "small"]);
983
+ const semanticTags = /* @__PURE__ */ new Set([
984
+ "h1",
985
+ "h2",
986
+ "h3",
987
+ "h4",
988
+ "h5",
989
+ "h6",
990
+ "p",
991
+ "button",
992
+ "a",
993
+ "img",
994
+ "svg",
995
+ "video",
996
+ "canvas",
997
+ "input",
998
+ "textarea",
999
+ "select",
1000
+ "label",
1001
+ "li",
1002
+ "section",
1003
+ "article",
1004
+ "nav",
1005
+ "main",
1006
+ "aside",
1007
+ "header",
1008
+ "footer"
1009
+ ]);
1010
+ const rootRect = root.getBoundingClientRect();
1011
+ const captureBounds = {
1012
+ x: 0,
1013
+ y: 0,
1014
+ width: Math.max(1, Math.round(rootRect.width)),
1015
+ height: Math.max(1, Math.round(rootRect.height))
1016
+ };
1017
+ const normalizeWhitespace = (value) => value.replace(/\s+/g, " ").trim();
1018
+ const clipValue = (value, limit) => value.length <= limit ? value : `${value.slice(0, Math.max(0, limit - 1))}\u2026`;
1019
+ const toTag = (element) => element.tagName.toLowerCase();
1020
+ const isTransparent = (value) => value === "transparent" || value === "rgba(0, 0, 0, 0)" || value === "rgba(0,0,0,0)";
1021
+ const hasVisibleBorder = (style) => Number.parseFloat(style.borderTopWidth) > 0 || Number.parseFloat(style.borderRightWidth) > 0 || Number.parseFloat(style.borderBottomWidth) > 0 || Number.parseFloat(style.borderLeftWidth) > 0;
1022
+ const hasPaintedBox = (style) => style.backgroundImage !== "none" || !isTransparent(style.backgroundColor) || hasVisibleBorder(style) || style.boxShadow !== "none";
1023
+ const escapeIdentifier = (value) => typeof CSS !== "undefined" && typeof CSS.escape === "function" ? CSS.escape(value) : value;
1024
+ const directTextSnippet = (element) => {
1025
+ const text = element instanceof HTMLElement ? element.innerText || element.textContent || "" : element.textContent || "";
1026
+ return clipValue(normalizeWhitespace(text), maxTextLength);
1027
+ };
1028
+ const depthFromRoot = (element) => {
1029
+ let depth = 0;
1030
+ let current = element;
1031
+ while (current && current !== root) {
1032
+ depth += 1;
1033
+ current = current.parentElement;
1034
+ }
1035
+ return depth;
1036
+ };
1037
+ const nthOfType = (element) => {
1038
+ let index = 1;
1039
+ let sibling = element.previousElementSibling;
1040
+ while (sibling) {
1041
+ if (sibling.tagName === element.tagName) {
1042
+ index += 1;
1043
+ }
1044
+ sibling = sibling.previousElementSibling;
1045
+ }
1046
+ return index;
1047
+ };
1048
+ const segmentFor = (element) => {
1049
+ const tag = toTag(element);
1050
+ if (element.id) {
1051
+ return `${tag}#${escapeIdentifier(element.id)}`;
1052
+ }
1053
+ const classNames = Array.from(element.classList).filter((className) => /^[A-Za-z_][\w-]*$/u.test(className)).slice(0, 2);
1054
+ if (classNames.length > 0) {
1055
+ return `${tag}.${classNames.map((className) => escapeIdentifier(className)).join(".")}`;
1056
+ }
1057
+ return `${tag}:nth-of-type(${nthOfType(element)})`;
1058
+ };
1059
+ const buildSelector = (element) => {
1060
+ if (element === root) {
1061
+ return clipValue(segmentFor(element), maxSelectorLength);
1062
+ }
1063
+ const parts = [];
1064
+ let current = element;
1065
+ while (current && parts.length < 3) {
1066
+ parts.unshift(segmentFor(current));
1067
+ if (current === root) {
1068
+ break;
1069
+ }
1070
+ current = current.parentElement;
1071
+ }
1072
+ return clipValue(parts.join(" > "), maxSelectorLength);
1073
+ };
1074
+ const clipBoxToBounds = (box, bounds) => {
1075
+ const left = Math.max(bounds.x, box.x);
1076
+ const top = Math.max(bounds.y, box.y);
1077
+ const right = Math.min(bounds.x + bounds.width, box.x + box.width);
1078
+ const bottom = Math.min(bounds.y + bounds.height, box.y + box.height);
1079
+ if (right <= left || bottom <= top) {
1080
+ return null;
1081
+ }
1082
+ return {
1083
+ x: Math.round(left),
1084
+ y: Math.round(top),
1085
+ width: Math.round(right - left),
1086
+ height: Math.round(bottom - top)
1087
+ };
1088
+ };
1089
+ const clippedEdgesForBox = (box, bounds) => {
1090
+ const edges = [];
1091
+ if (box.y < bounds.y) {
1092
+ edges.push("top");
1093
+ }
1094
+ if (box.x + box.width > bounds.x + bounds.width) {
1095
+ edges.push("right");
1096
+ }
1097
+ if (box.y + box.height > bounds.y + bounds.height) {
1098
+ edges.push("bottom");
1099
+ }
1100
+ if (box.x < bounds.x) {
1101
+ edges.push("left");
1102
+ }
1103
+ return edges;
1104
+ };
1105
+ const toRelativeBox = (element) => {
1106
+ const rect = element.getBoundingClientRect();
1107
+ const rawBox = {
1108
+ x: rect.left - rootRect.left,
1109
+ y: rect.top - rootRect.top,
1110
+ width: rect.width,
1111
+ height: rect.height
1112
+ };
1113
+ const bbox = clipBoxToBounds(rawBox, captureBounds);
1114
+ if (!bbox) {
1115
+ return null;
1116
+ }
1117
+ return {
1118
+ bbox,
1119
+ captureClippedEdges: clippedEdgesForBox(rawBox, captureBounds)
1120
+ };
1121
+ };
1122
+ const isVisible = (element) => {
1123
+ const rect = element.getBoundingClientRect();
1124
+ if (rect.width <= 0 || rect.height <= 0) {
1125
+ return false;
1126
+ }
1127
+ const style = window.getComputedStyle(element);
1128
+ return style.display !== "none" && style.visibility !== "hidden" && style.opacity !== "0" && style.pointerEvents !== "none";
1129
+ };
1130
+ const isMeaningful = (element) => {
1131
+ if (element === root) {
1132
+ return true;
1133
+ }
1134
+ const tag = toTag(element);
1135
+ if (excludedTags.has(tag) || inlineNoiseTags.has(tag)) {
1136
+ return false;
1137
+ }
1138
+ if (!isVisible(element)) {
1139
+ return false;
1140
+ }
1141
+ const style = window.getComputedStyle(element);
1142
+ const hasText = directTextSnippet(element).length > 0;
1143
+ const hasRole = Boolean(element.getAttribute("role"));
1144
+ return hasText || hasRole || semanticTags.has(tag) || hasPaintedBox(style) || element.children.length === 0;
1145
+ };
1146
+ const buildSnapshotElement = (element) => {
1147
+ const boxInfo = toRelativeBox(element);
1148
+ if (!boxInfo) {
1149
+ return null;
1150
+ }
1151
+ const style = window.getComputedStyle(element);
1152
+ const textMetrics = element instanceof HTMLElement ? {
1153
+ clientWidth: Math.round(element.clientWidth),
1154
+ clientHeight: Math.round(element.clientHeight),
1155
+ scrollWidth: Math.round(element.scrollWidth),
1156
+ scrollHeight: Math.round(element.scrollHeight),
1157
+ overflowX: style.overflowX,
1158
+ overflowY: style.overflowY,
1159
+ textOverflow: style.textOverflow,
1160
+ whiteSpace: style.whiteSpace,
1161
+ lineClamp: style.getPropertyValue("-webkit-line-clamp") || style.getPropertyValue("line-clamp") || null
1162
+ } : null;
1163
+ return {
1164
+ id: buildSelector(element),
1165
+ tag: toTag(element),
1166
+ selector: buildSelector(element),
1167
+ role: element.getAttribute("role"),
1168
+ textSnippet: directTextSnippet(element) || null,
1169
+ bbox: boxInfo.bbox,
1170
+ depth: depthFromRoot(element),
1171
+ captureClippedEdges: boxInfo.captureClippedEdges,
1172
+ textMetrics
1173
+ };
1174
+ };
1175
+ const allElements = [root, ...Array.from(root.querySelectorAll("*"))].filter((element) => isMeaningful(element)).map((element) => buildSnapshotElement(element)).filter((element) => element !== null);
1176
+ const [snapshotRoot, ...elements] = allElements;
1177
+ if (!snapshotRoot) {
1178
+ throw new Error("No meaningful DOM elements were found inside the selector capture.");
1179
+ }
1180
+ return {
1181
+ root: snapshotRoot,
1182
+ elements
1183
+ };
1184
+ },
1185
+ {
1186
+ maxSelectorLength: DEFAULT_MAX_SELECTOR_LENGTH,
1187
+ maxTextLength: DEFAULT_MAX_TEXT_SNIPPET_LENGTH
1188
+ }
1189
+ );
1190
+ }
1191
+ async function collectPageDomSnapshot(page, fullPage) {
1192
+ return page.evaluate(
1193
+ ({ fullPageCapture, maxSelectorLength, maxTextLength }) => {
1194
+ const excludedTags = /* @__PURE__ */ new Set(["script", "style", "noscript", "meta", "link", "head"]);
1195
+ const inlineNoiseTags = /* @__PURE__ */ new Set(["span", "strong", "em", "b", "i", "u", "small"]);
1196
+ const semanticTags = /* @__PURE__ */ new Set([
1197
+ "h1",
1198
+ "h2",
1199
+ "h3",
1200
+ "h4",
1201
+ "h5",
1202
+ "h6",
1203
+ "p",
1204
+ "button",
1205
+ "a",
1206
+ "img",
1207
+ "svg",
1208
+ "video",
1209
+ "canvas",
1210
+ "input",
1211
+ "textarea",
1212
+ "select",
1213
+ "label",
1214
+ "li",
1215
+ "section",
1216
+ "article",
1217
+ "nav",
1218
+ "main",
1219
+ "aside",
1220
+ "header",
1221
+ "footer"
1222
+ ]);
1223
+ const root = document.body ?? document.documentElement;
1224
+ const captureBounds = fullPageCapture ? {
1225
+ x: 0,
1226
+ y: 0,
1227
+ width: Math.max(
1228
+ document.documentElement.scrollWidth,
1229
+ document.body?.scrollWidth ?? 0,
1230
+ window.innerWidth
1231
+ ),
1232
+ height: Math.max(
1233
+ document.documentElement.scrollHeight,
1234
+ document.body?.scrollHeight ?? 0,
1235
+ window.innerHeight
1236
+ )
1237
+ } : {
1238
+ x: 0,
1239
+ y: 0,
1240
+ width: window.innerWidth,
1241
+ height: window.innerHeight
1242
+ };
1243
+ const normalizeWhitespace = (value) => value.replace(/\s+/g, " ").trim();
1244
+ const clipValue = (value, limit) => value.length <= limit ? value : `${value.slice(0, Math.max(0, limit - 1))}\u2026`;
1245
+ const toTag = (element) => element.tagName.toLowerCase();
1246
+ const isTransparent = (value) => value === "transparent" || value === "rgba(0, 0, 0, 0)" || value === "rgba(0,0,0,0)";
1247
+ const hasVisibleBorder = (style) => Number.parseFloat(style.borderTopWidth) > 0 || Number.parseFloat(style.borderRightWidth) > 0 || Number.parseFloat(style.borderBottomWidth) > 0 || Number.parseFloat(style.borderLeftWidth) > 0;
1248
+ const hasPaintedBox = (style) => style.backgroundImage !== "none" || !isTransparent(style.backgroundColor) || hasVisibleBorder(style) || style.boxShadow !== "none";
1249
+ const escapeIdentifier = (value) => typeof CSS !== "undefined" && typeof CSS.escape === "function" ? CSS.escape(value) : value;
1250
+ const directTextSnippet = (element) => {
1251
+ const text = element instanceof HTMLElement ? element.innerText || element.textContent || "" : element.textContent || "";
1252
+ return clipValue(normalizeWhitespace(text), maxTextLength);
1253
+ };
1254
+ const depthFromRoot = (element) => {
1255
+ let depth = 0;
1256
+ let current = element;
1257
+ while (current && current !== root) {
1258
+ depth += 1;
1259
+ current = current.parentElement;
1260
+ }
1261
+ return depth;
1262
+ };
1263
+ const nthOfType = (element) => {
1264
+ let index = 1;
1265
+ let sibling = element.previousElementSibling;
1266
+ while (sibling) {
1267
+ if (sibling.tagName === element.tagName) {
1268
+ index += 1;
1269
+ }
1270
+ sibling = sibling.previousElementSibling;
1271
+ }
1272
+ return index;
1273
+ };
1274
+ const segmentFor = (element) => {
1275
+ const tag = toTag(element);
1276
+ if (element.id) {
1277
+ return `${tag}#${escapeIdentifier(element.id)}`;
1278
+ }
1279
+ const classNames = Array.from(element.classList).filter((className) => /^[A-Za-z_][\w-]*$/u.test(className)).slice(0, 2);
1280
+ if (classNames.length > 0) {
1281
+ return `${tag}.${classNames.map((className) => escapeIdentifier(className)).join(".")}`;
1282
+ }
1283
+ return `${tag}:nth-of-type(${nthOfType(element)})`;
1284
+ };
1285
+ const buildSelector = (element) => {
1286
+ if (element === root) {
1287
+ return clipValue(segmentFor(element), maxSelectorLength);
1288
+ }
1289
+ const parts = [];
1290
+ let current = element;
1291
+ while (current && parts.length < 3) {
1292
+ parts.unshift(segmentFor(current));
1293
+ if (current === root) {
1294
+ break;
1295
+ }
1296
+ current = current.parentElement;
1297
+ }
1298
+ return clipValue(parts.join(" > "), maxSelectorLength);
1299
+ };
1300
+ const clipBoxToBounds = (box, bounds) => {
1301
+ const left = Math.max(bounds.x, box.x);
1302
+ const top = Math.max(bounds.y, box.y);
1303
+ const right = Math.min(bounds.x + bounds.width, box.x + box.width);
1304
+ const bottom = Math.min(bounds.y + bounds.height, box.y + box.height);
1305
+ if (right <= left || bottom <= top) {
1306
+ return null;
1307
+ }
1308
+ return {
1309
+ x: Math.round(left),
1310
+ y: Math.round(top),
1311
+ width: Math.round(right - left),
1312
+ height: Math.round(bottom - top)
1313
+ };
1314
+ };
1315
+ const clippedEdgesForBox = (box, bounds) => {
1316
+ const edges = [];
1317
+ if (box.y < bounds.y) {
1318
+ edges.push("top");
1319
+ }
1320
+ if (box.x + box.width > bounds.x + bounds.width) {
1321
+ edges.push("right");
1322
+ }
1323
+ if (box.y + box.height > bounds.y + bounds.height) {
1324
+ edges.push("bottom");
1325
+ }
1326
+ if (box.x < bounds.x) {
1327
+ edges.push("left");
1328
+ }
1329
+ return edges;
1330
+ };
1331
+ const toRelativeBox = (element) => {
1332
+ const rect = element.getBoundingClientRect();
1333
+ const x = fullPageCapture ? rect.left + window.scrollX : rect.left;
1334
+ const y = fullPageCapture ? rect.top + window.scrollY : rect.top;
1335
+ const rawBox = {
1336
+ x,
1337
+ y,
1338
+ width: rect.width,
1339
+ height: rect.height
1340
+ };
1341
+ const bbox = clipBoxToBounds(rawBox, captureBounds);
1342
+ if (!bbox) {
1343
+ return null;
1344
+ }
1345
+ return {
1346
+ bbox,
1347
+ captureClippedEdges: clippedEdgesForBox(rawBox, captureBounds)
1348
+ };
1349
+ };
1350
+ const isVisible = (element) => {
1351
+ const rect = element.getBoundingClientRect();
1352
+ if (rect.width <= 0 || rect.height <= 0) {
1353
+ return false;
1354
+ }
1355
+ const style = window.getComputedStyle(element);
1356
+ return style.display !== "none" && style.visibility !== "hidden" && style.opacity !== "0" && style.pointerEvents !== "none";
1357
+ };
1358
+ const isMeaningful = (element) => {
1359
+ if (element === root) {
1360
+ return true;
1361
+ }
1362
+ const tag = toTag(element);
1363
+ if (excludedTags.has(tag) || inlineNoiseTags.has(tag)) {
1364
+ return false;
1365
+ }
1366
+ if (!isVisible(element)) {
1367
+ return false;
1368
+ }
1369
+ const style = window.getComputedStyle(element);
1370
+ const hasText = directTextSnippet(element).length > 0;
1371
+ const hasRole = Boolean(element.getAttribute("role"));
1372
+ return hasText || hasRole || semanticTags.has(tag) || hasPaintedBox(style) || element.children.length === 0;
1373
+ };
1374
+ const buildSnapshotElement = (element) => {
1375
+ const boxInfo = toRelativeBox(element);
1376
+ if (!boxInfo) {
1377
+ return null;
1378
+ }
1379
+ const style = window.getComputedStyle(element);
1380
+ const textMetrics = element instanceof HTMLElement ? {
1381
+ clientWidth: Math.round(element.clientWidth),
1382
+ clientHeight: Math.round(element.clientHeight),
1383
+ scrollWidth: Math.round(element.scrollWidth),
1384
+ scrollHeight: Math.round(element.scrollHeight),
1385
+ overflowX: style.overflowX,
1386
+ overflowY: style.overflowY,
1387
+ textOverflow: style.textOverflow,
1388
+ whiteSpace: style.whiteSpace,
1389
+ lineClamp: style.getPropertyValue("-webkit-line-clamp") || style.getPropertyValue("line-clamp") || null
1390
+ } : null;
1391
+ return {
1392
+ id: buildSelector(element),
1393
+ tag: toTag(element),
1394
+ selector: buildSelector(element),
1395
+ role: element.getAttribute("role"),
1396
+ textSnippet: directTextSnippet(element) || null,
1397
+ bbox: boxInfo.bbox,
1398
+ depth: depthFromRoot(element),
1399
+ captureClippedEdges: boxInfo.captureClippedEdges,
1400
+ textMetrics
1401
+ };
1402
+ };
1403
+ const allElements = [root, ...Array.from(root.querySelectorAll("*"))].filter((element) => isMeaningful(element)).map((element) => buildSnapshotElement(element)).filter((element) => element !== null);
1404
+ const [snapshotRoot, ...elements] = allElements;
1405
+ if (!snapshotRoot) {
1406
+ throw new Error("No meaningful DOM elements were found in the page capture.");
1407
+ }
1408
+ return {
1409
+ root: snapshotRoot,
1410
+ elements
1411
+ };
1412
+ },
1413
+ {
1414
+ fullPageCapture: fullPage,
1415
+ maxSelectorLength: DEFAULT_MAX_SELECTOR_LENGTH,
1416
+ maxTextLength: DEFAULT_MAX_TEXT_SNIPPET_LENGTH
1417
+ }
1418
+ );
1419
+ }
1420
+
1421
+ // src/compare/engine.ts
1422
+ import pixelmatch from "pixelmatch";
1423
+
1424
+ // src/compare/regions.ts
1425
+ function extractRegions(mask, width, height, minPixels = DEFAULT_MIN_REGION_PIXELS) {
1426
+ const visited = new Uint8Array(mask.length);
1427
+ const regions = [];
1428
+ const queue = new Uint32Array(mask.length);
1429
+ for (let index = 0; index < mask.length; index += 1) {
1430
+ if (mask[index] === 0 || visited[index] === 1) {
1431
+ continue;
1432
+ }
1433
+ let head = 0;
1434
+ let tail = 0;
1435
+ queue[tail] = index;
1436
+ tail += 1;
1437
+ visited[index] = 1;
1438
+ let minX = index % width;
1439
+ let maxX = minX;
1440
+ let minY = Math.floor(index / width);
1441
+ let maxY = minY;
1442
+ let pixelCount = 0;
1443
+ while (head < tail) {
1444
+ const current = queue[head];
1445
+ head += 1;
1446
+ const x = current % width;
1447
+ const y = Math.floor(current / width);
1448
+ pixelCount += 1;
1449
+ minX = Math.min(minX, x);
1450
+ maxX = Math.max(maxX, x);
1451
+ minY = Math.min(minY, y);
1452
+ maxY = Math.max(maxY, y);
1453
+ if (x > 0) {
1454
+ const neighbor = current - 1;
1455
+ if (mask[neighbor] === 1 && visited[neighbor] === 0) {
1456
+ visited[neighbor] = 1;
1457
+ queue[tail] = neighbor;
1458
+ tail += 1;
1459
+ }
1460
+ }
1461
+ if (x < width - 1) {
1462
+ const neighbor = current + 1;
1463
+ if (mask[neighbor] === 1 && visited[neighbor] === 0) {
1464
+ visited[neighbor] = 1;
1465
+ queue[tail] = neighbor;
1466
+ tail += 1;
1467
+ }
1468
+ }
1469
+ if (y > 0) {
1470
+ const neighbor = current - width;
1471
+ if (mask[neighbor] === 1 && visited[neighbor] === 0) {
1472
+ visited[neighbor] = 1;
1473
+ queue[tail] = neighbor;
1474
+ tail += 1;
1475
+ }
1476
+ }
1477
+ if (y < height - 1) {
1478
+ const neighbor = current + width;
1479
+ if (mask[neighbor] === 1 && visited[neighbor] === 0) {
1480
+ visited[neighbor] = 1;
1481
+ queue[tail] = neighbor;
1482
+ tail += 1;
1483
+ }
1484
+ }
1485
+ }
1486
+ if (pixelCount < minPixels) {
1487
+ continue;
1488
+ }
1489
+ regions.push({
1490
+ x: minX,
1491
+ y: minY,
1492
+ width: maxX - minX + 1,
1493
+ height: maxY - minY + 1,
1494
+ pixelCount
1495
+ });
1496
+ }
1497
+ return regions;
1498
+ }
1499
+
1500
+ // src/compare/structure.ts
1501
+ function analyzeStructure(reference, preview, width, height) {
1502
+ const lumaReference = rgbaToLuma(reference, width, height);
1503
+ const lumaPreview = rgbaToLuma(preview, width, height);
1504
+ const edgeMaskReference = sobelEdgeMask(lumaReference, width, height, DEFAULT_EDGE_THRESHOLD);
1505
+ const edgeMaskPreview = sobelEdgeMask(lumaPreview, width, height, DEFAULT_EDGE_THRESHOLD);
1506
+ const edgeDiffMask = new Uint8Array(width * height);
1507
+ let unionCount = 0;
1508
+ let diffCount = 0;
1509
+ for (let index = 0; index < edgeDiffMask.length; index += 1) {
1510
+ const ref = edgeMaskReference[index];
1511
+ const prev = edgeMaskPreview[index];
1512
+ const union = ref === 1 || prev === 1;
1513
+ if (union) {
1514
+ unionCount += 1;
1515
+ }
1516
+ if (ref !== prev) {
1517
+ edgeDiffMask[index] = 1;
1518
+ diffCount += 1;
1519
+ }
1520
+ }
1521
+ return {
1522
+ edgeMaskReference,
1523
+ edgeMaskPreview,
1524
+ edgeDiffMask,
1525
+ structuralMismatchPercent: unionCount === 0 ? 0 : diffCount / unionCount * 100
1526
+ };
1527
+ }
1528
+ function rgbaToLuma(data, width, height) {
1529
+ const luma = new Float32Array(width * height);
1530
+ for (let index = 0; index < width * height; index += 1) {
1531
+ const offset = index * 4;
1532
+ luma[index] = data[offset] * 0.2126 + data[offset + 1] * 0.7152 + data[offset + 2] * 0.0722;
1533
+ }
1534
+ return luma;
1535
+ }
1536
+ function sobelEdgeMask(luma, width, height, threshold) {
1537
+ const edgeMask = new Uint8Array(width * height);
1538
+ for (let y = 1; y < height - 1; y += 1) {
1539
+ for (let x = 1; x < width - 1; x += 1) {
1540
+ const topLeft = luma[(y - 1) * width + (x - 1)];
1541
+ const top = luma[(y - 1) * width + x];
1542
+ const topRight = luma[(y - 1) * width + (x + 1)];
1543
+ const left = luma[y * width + (x - 1)];
1544
+ const right = luma[y * width + (x + 1)];
1545
+ const bottomLeft = luma[(y + 1) * width + (x - 1)];
1546
+ const bottom = luma[(y + 1) * width + x];
1547
+ const bottomRight = luma[(y + 1) * width + (x + 1)];
1548
+ const gx = -topLeft + topRight - 2 * left + 2 * right - bottomLeft + bottomRight;
1549
+ const gy = topLeft + 2 * top + topRight - bottomLeft - 2 * bottom - bottomRight;
1550
+ const magnitude = Math.sqrt(gx * gx + gy * gy);
1551
+ if (magnitude >= threshold) {
1552
+ edgeMask[y * width + x] = 1;
1553
+ }
1554
+ }
1555
+ }
1556
+ return edgeMask;
1557
+ }
1558
+
1559
+ // src/compare/engine.ts
1560
+ function runComparisonEngine(params) {
1561
+ const totalPixels = params.width * params.height;
1562
+ const diffBuffer = new Uint8ClampedArray(totalPixels * 4);
1563
+ const maskBuffer = new Uint8ClampedArray(totalPixels * 4);
1564
+ const colorDeltaMap = new Float32Array(totalPixels);
1565
+ const mismatchPixels = pixelmatch(
1566
+ params.reference,
1567
+ params.preview,
1568
+ diffBuffer,
1569
+ params.width,
1570
+ params.height,
1571
+ {
1572
+ threshold: DEFAULT_PIXELMATCH_THRESHOLD,
1573
+ alpha: 0.2,
1574
+ diffColor: [255, 0, 0],
1575
+ diffColorAlt: [0, 180, 255],
1576
+ includeAA: true
1577
+ }
1578
+ );
1579
+ pixelmatch(params.reference, params.preview, maskBuffer, params.width, params.height, {
1580
+ threshold: DEFAULT_PIXELMATCH_THRESHOLD,
1581
+ alpha: 1,
1582
+ diffColor: [255, 255, 255],
1583
+ diffColorAlt: [255, 255, 255],
1584
+ includeAA: true,
1585
+ diffMask: true
1586
+ });
1587
+ const mismatchMask = new Uint8Array(totalPixels);
1588
+ let colorDeltaSum = 0;
1589
+ let maxColorDelta = 0;
1590
+ let mismatchedColorSamples = 0;
1591
+ for (let index = 0; index < totalPixels; index += 1) {
1592
+ const offset = index * 4;
1593
+ const hasMismatch = maskBuffer[offset] > 0 || maskBuffer[offset + 1] > 0 || maskBuffer[offset + 2] > 0 || maskBuffer[offset + 3] > 0;
1594
+ if (!hasMismatch) {
1595
+ continue;
1596
+ }
1597
+ mismatchMask[index] = 1;
1598
+ const redDelta = params.reference[offset] - params.preview[offset];
1599
+ const greenDelta = params.reference[offset + 1] - params.preview[offset + 1];
1600
+ const blueDelta = params.reference[offset + 2] - params.preview[offset + 2];
1601
+ const delta = Math.sqrt(redDelta ** 2 + greenDelta ** 2 + blueDelta ** 2) / MAX_RGB_DISTANCE * 100;
1602
+ colorDeltaMap[index] = delta;
1603
+ colorDeltaSum += delta;
1604
+ maxColorDelta = Math.max(maxColorDelta, delta);
1605
+ mismatchedColorSamples += 1;
1606
+ }
1607
+ const dimensionMismatch = buildDimensionMismatch(
1608
+ params.referenceOriginal,
1609
+ params.previewOriginal
1610
+ );
1611
+ const structural = params.mode === "all" || params.mode === "layout" ? analyzeStructure(params.reference, params.preview, params.width, params.height) : null;
1612
+ const rawRegions = buildRegions({
1613
+ mismatchMask,
1614
+ edgeDiffMask: structural?.edgeDiffMask ?? null,
1615
+ width: params.width,
1616
+ height: params.height,
1617
+ totalPixels,
1618
+ dimensionMismatch,
1619
+ overlapWidth: Math.min(params.referenceOriginal.width, params.previewOriginal.width),
1620
+ overlapHeight: Math.min(params.referenceOriginal.height, params.previewOriginal.height),
1621
+ colorDeltaMap,
1622
+ mode: params.mode
1623
+ });
1624
+ const overlay = createOverlay(params.reference, params.preview, mismatchMask);
1625
+ return {
1626
+ metrics: {
1627
+ mismatchPixels,
1628
+ mismatchPercent: totalPixels === 0 ? 0 : Number((mismatchPixels / totalPixels * 100).toFixed(4)),
1629
+ meanColorDelta: params.mode === "all" || params.mode === "color" ? mismatchedColorSamples > 0 ? Number((colorDeltaSum / mismatchedColorSamples).toFixed(4)) : 0 : null,
1630
+ maxColorDelta: params.mode === "all" || params.mode === "color" ? Number(maxColorDelta.toFixed(4)) : null,
1631
+ structuralMismatchPercent: structural === null ? null : Number(structural.structuralMismatchPercent.toFixed(4)),
1632
+ dimensionMismatch,
1633
+ findingsCount: 0,
1634
+ affectedElementCount: 0
1635
+ },
1636
+ rawRegions,
1637
+ mismatchMask,
1638
+ buffers: {
1639
+ overlay,
1640
+ diff: diffBuffer
1641
+ }
1642
+ };
1643
+ }
1644
+ function buildDimensionMismatch(reference, preview) {
1645
+ const widthDelta = preview.width - reference.width;
1646
+ const heightDelta = preview.height - reference.height;
1647
+ const referenceAspect = reference.width / reference.height;
1648
+ const previewAspect = preview.width / preview.height;
1649
+ return {
1650
+ widthDelta,
1651
+ heightDelta,
1652
+ aspectRatioDelta: Number(Math.abs(previewAspect - referenceAspect).toFixed(6)),
1653
+ hasMismatch: widthDelta !== 0 || heightDelta !== 0
1654
+ };
1655
+ }
1656
+ function buildRegions(params) {
1657
+ const rawRegions = extractRegions(params.mismatchMask, params.width, params.height);
1658
+ return rawRegions.map((region) => {
1659
+ const bboxArea = region.width * region.height;
1660
+ const mismatchPercent = bboxArea === 0 ? 0 : region.pixelCount / bboxArea * 100;
1661
+ const kind = classifyRegionKind({
1662
+ region,
1663
+ edgeDiffMask: params.edgeDiffMask,
1664
+ width: params.width,
1665
+ overlapWidth: params.overlapWidth,
1666
+ overlapHeight: params.overlapHeight,
1667
+ colorDeltaMap: params.colorDeltaMap,
1668
+ mode: params.mode
1669
+ });
1670
+ const severity = classifyRegionSeverity(
1671
+ region.pixelCount / params.totalPixels * 100,
1672
+ kind,
1673
+ params.dimensionMismatch
1674
+ );
1675
+ return {
1676
+ ...region,
1677
+ mismatchPercent: Number(mismatchPercent.toFixed(4)),
1678
+ kind,
1679
+ severity
1680
+ };
1681
+ }).sort(compareRegions);
1682
+ }
1683
+ function classifyRegionKind(params) {
1684
+ const { region, edgeDiffMask, width, overlapWidth, overlapHeight, colorDeltaMap, mode } = params;
1685
+ const outsideOverlap = region.x + region.width > overlapWidth || region.y + region.height > overlapHeight;
1686
+ if (outsideOverlap) {
1687
+ return "dimension";
1688
+ }
1689
+ const regionMeanColorDelta = meanColorDeltaForRegion(region, colorDeltaMap, width);
1690
+ if (mode === "pixel") {
1691
+ return "pixel";
1692
+ }
1693
+ if (mode === "color") {
1694
+ return regionMeanColorDelta >= COLOR_REGION_DELTA_THRESHOLD ? "color" : "pixel";
1695
+ }
1696
+ if (!edgeDiffMask) {
1697
+ return regionMeanColorDelta >= COLOR_REGION_DELTA_THRESHOLD ? "color" : "pixel";
1698
+ }
1699
+ let edgePixels = 0;
1700
+ for (let y = region.y; y < region.y + region.height; y += 1) {
1701
+ for (let x = region.x; x < region.x + region.width; x += 1) {
1702
+ if (edgeDiffMask[y * width + x] === 1) {
1703
+ edgePixels += 1;
1704
+ }
1705
+ }
1706
+ }
1707
+ const edgeRatio = edgePixels / Math.max(1, region.width * region.height);
1708
+ if (mode === "layout") {
1709
+ return edgeRatio >= LAYOUT_EDGE_RATIO_THRESHOLD ? "layout" : "pixel";
1710
+ }
1711
+ if (edgeRatio >= LAYOUT_EDGE_RATIO_THRESHOLD && regionMeanColorDelta >= COLOR_REGION_DELTA_THRESHOLD) {
1712
+ return "mixed";
1713
+ }
1714
+ if (edgeRatio >= LAYOUT_EDGE_RATIO_THRESHOLD) {
1715
+ return "layout";
1716
+ }
1717
+ if (regionMeanColorDelta >= COLOR_REGION_DELTA_THRESHOLD) {
1718
+ return "color";
1719
+ }
1720
+ return "pixel";
1721
+ }
1722
+ function meanColorDeltaForRegion(region, colorDeltaMap, width) {
1723
+ let totalDelta = 0;
1724
+ let sampleCount = 0;
1725
+ for (let y = region.y; y < region.y + region.height; y += 1) {
1726
+ for (let x = region.x; x < region.x + region.width; x += 1) {
1727
+ const delta = colorDeltaMap[y * width + x];
1728
+ if (delta <= 0) {
1729
+ continue;
1730
+ }
1731
+ totalDelta += delta;
1732
+ sampleCount += 1;
1733
+ }
1734
+ }
1735
+ return sampleCount === 0 ? 0 : totalDelta / sampleCount;
1736
+ }
1737
+ function classifyRegionSeverity(areaPercent, kind, dimensionMismatch) {
1738
+ if (kind === "dimension") {
1739
+ const strongDimensionShift = Math.abs(dimensionMismatch.widthDelta) >= STRONG_DIMENSION_DELTA_PX || Math.abs(dimensionMismatch.heightDelta) >= STRONG_DIMENSION_DELTA_PX || dimensionMismatch.aspectRatioDelta >= STRONG_DIMENSION_ASPECT_DELTA;
1740
+ return strongDimensionShift ? "critical" : "high";
1741
+ }
1742
+ let severity;
1743
+ if (areaPercent >= 12) {
1744
+ severity = "critical";
1745
+ } else if (areaPercent >= 4) {
1746
+ severity = "high";
1747
+ } else if (areaPercent >= 1.5) {
1748
+ severity = "medium";
1749
+ } else {
1750
+ severity = "low";
1751
+ }
1752
+ if (kind === "layout" && severity === "low") {
1753
+ return "medium";
1754
+ }
1755
+ if (kind === "mixed" && severity === "low") {
1756
+ return "medium";
1757
+ }
1758
+ return severity;
1759
+ }
1760
+ function createOverlay(reference, preview, mismatchMask) {
1761
+ const overlay = new Uint8ClampedArray(reference.length);
1762
+ for (let index = 0; index < mismatchMask.length; index += 1) {
1763
+ const offset = index * 4;
1764
+ overlay[offset] = Math.round((reference[offset] + preview[offset]) / 2);
1765
+ overlay[offset + 1] = Math.round((reference[offset + 1] + preview[offset + 1]) / 2);
1766
+ overlay[offset + 2] = Math.round((reference[offset + 2] + preview[offset + 2]) / 2);
1767
+ overlay[offset + 3] = 255;
1768
+ if (mismatchMask[index] === 1) {
1769
+ overlay[offset] = Math.min(255, overlay[offset] + 50);
1770
+ }
1771
+ }
1772
+ return overlay;
1773
+ }
1774
+ function compareRegions(left, right) {
1775
+ const severityOrder = compareSeverityDescending(left.severity, right.severity);
1776
+ if (severityOrder !== 0) {
1777
+ return severityOrder;
1778
+ }
1779
+ if (left.pixelCount !== right.pixelCount) {
1780
+ return right.pixelCount - left.pixelCount;
1781
+ }
1782
+ if (left.y !== right.y) {
1783
+ return left.y - right.y;
1784
+ }
1785
+ return left.x - right.x;
1786
+ }
1787
+
1788
+ // src/compare/heatmap.ts
1789
+ function createHeatmapArtifact(params) {
1790
+ const heatmap = new Uint8ClampedArray(params.reference.length);
1791
+ for (let index = 0; index < params.mismatchMask.length; index += 1) {
1792
+ const offset = index * 4;
1793
+ const base = Math.round((params.reference[offset] + params.preview[offset]) / 2 * 0.25);
1794
+ heatmap[offset] = base;
1795
+ heatmap[offset + 1] = base;
1796
+ heatmap[offset + 2] = base;
1797
+ heatmap[offset + 3] = 255;
1798
+ if (params.mismatchMask[index] === 1) {
1799
+ heatmap[offset] = 255;
1800
+ heatmap[offset + 1] = 120;
1801
+ heatmap[offset + 2] = 0;
1802
+ }
1803
+ }
1804
+ for (const visual of params.visuals) {
1805
+ drawBoundingBox(
1806
+ heatmap,
1807
+ params.width,
1808
+ params.height,
1809
+ visual.primaryBox,
1810
+ boundingBoxColor(visual.severity),
1811
+ 2
1812
+ );
1813
+ for (const hotspotBox of visual.hotspotBoxes) {
1814
+ drawBoundingBox(
1815
+ heatmap,
1816
+ params.width,
1817
+ params.height,
1818
+ hotspotBox,
1819
+ hotspotColor(visual.severity),
1820
+ 1
1821
+ );
1822
+ }
1823
+ }
1824
+ return heatmap;
1825
+ }
1826
+ function drawBoundingBox(image, width, height, box, color, thickness) {
1827
+ for (let inset = 0; inset < thickness; inset += 1) {
1828
+ const xStart = Math.max(0, box.x + inset);
1829
+ const yStart = Math.max(0, box.y + inset);
1830
+ const xEnd = Math.min(width - 1, box.x + box.width - 1 - inset);
1831
+ const yEnd = Math.min(height - 1, box.y + box.height - 1 - inset);
1832
+ if (xEnd < xStart || yEnd < yStart) {
1833
+ continue;
1834
+ }
1835
+ for (let x = xStart; x <= xEnd; x += 1) {
1836
+ paintPixel(image, width, x, yStart, color);
1837
+ paintPixel(image, width, x, yEnd, color);
1838
+ }
1839
+ for (let y = yStart; y <= yEnd; y += 1) {
1840
+ paintPixel(image, width, xStart, y, color);
1841
+ paintPixel(image, width, xEnd, y, color);
1842
+ }
1843
+ }
1844
+ }
1845
+ function paintPixel(image, width, x, y, color) {
1846
+ const offset = (y * width + x) * 4;
1847
+ image[offset] = color[0];
1848
+ image[offset + 1] = color[1];
1849
+ image[offset + 2] = color[2];
1850
+ image[offset + 3] = color[3];
1851
+ }
1852
+ function boundingBoxColor(severity) {
1853
+ switch (severity) {
1854
+ case "critical":
1855
+ return [255, 255, 255, 255];
1856
+ case "high":
1857
+ return [255, 0, 255, 255];
1858
+ case "medium":
1859
+ return [0, 255, 255, 255];
1860
+ case "low":
1861
+ default:
1862
+ return [0, 255, 0, 255];
1863
+ }
1864
+ }
1865
+ function hotspotColor(severity) {
1866
+ switch (severity) {
1867
+ case "critical":
1868
+ return [255, 220, 120, 255];
1869
+ case "high":
1870
+ return [255, 140, 120, 255];
1871
+ case "medium":
1872
+ return [120, 220, 255, 255];
1873
+ case "low":
1874
+ default:
1875
+ return [120, 255, 180, 255];
1876
+ }
1877
+ }
1878
+
1879
+ // src/io/fs.ts
1880
+ import { access, mkdir, stat, writeFile as writeFile2 } from "fs/promises";
1881
+ import path3 from "path";
1882
+ async function ensureDirectory(dirPath) {
1883
+ const resolved = path3.resolve(dirPath);
1884
+ await mkdir(resolved, { recursive: true });
1885
+ return resolved;
1886
+ }
1887
+ async function ensureFileExists(filePath) {
1888
+ const resolved = path3.resolve(filePath);
1889
+ try {
1890
+ const fileStat = await stat(resolved);
1891
+ if (!fileStat.isFile()) {
1892
+ throw new AppError(`Expected a file but received a different path: ${resolved}`);
1893
+ }
1894
+ return resolved;
1895
+ } catch (error) {
1896
+ if (error instanceof AppError) {
1897
+ throw error;
1898
+ }
1899
+ const code = error.code;
1900
+ if (code === "ENOENT") {
1901
+ throw new AppError(`Input file does not exist: ${resolved}`, {
1902
+ code: "input_file_missing",
1903
+ cause: error
1904
+ });
1905
+ }
1906
+ if (code === "EACCES") {
1907
+ throw new AppError(`Input file is not readable: ${resolved}`, {
1908
+ code: "input_file_unreadable",
1909
+ cause: error
1910
+ });
1911
+ }
1912
+ throw new AppError(`Failed to access input file: ${resolved}`, {
1913
+ code: "input_file_access_failed",
1914
+ cause: error
1915
+ });
1916
+ }
1917
+ }
1918
+ async function writeJsonFile(filePath, value) {
1919
+ await writeFile2(filePath, `${JSON.stringify(value, null, 2)}
1920
+ `, "utf8");
1921
+ }
1922
+ async function pathExists(filePath) {
1923
+ try {
1924
+ await access(filePath);
1925
+ return true;
1926
+ } catch {
1927
+ return false;
1928
+ }
1929
+ }
1930
+
1931
+ // src/io/inputs.ts
1932
+ import path4 from "path";
1933
+
1934
+ // src/utils/url.ts
1935
+ function parseUrl(value) {
1936
+ try {
1937
+ return new URL(value);
1938
+ } catch {
1939
+ return null;
1940
+ }
1941
+ }
1942
+ function isHttpUrl(value) {
1943
+ const url = parseUrl(value);
1944
+ return url?.protocol === "http:" || url?.protocol === "https:";
1945
+ }
1946
+ function isFigmaUrl(value) {
1947
+ const url = parseUrl(value);
1948
+ if (!url) {
1949
+ return false;
1950
+ }
1951
+ return url.hostname === "figma.com" || url.hostname.endsWith(".figma.com");
1952
+ }
1953
+ function hashToSelector(input, explicitSelector) {
1954
+ if (explicitSelector) {
1955
+ return explicitSelector;
1956
+ }
1957
+ if (!isHttpUrl(input)) {
1958
+ return null;
1959
+ }
1960
+ const url = new URL(input);
1961
+ const hash = url.hash.replace(/^#/, "");
1962
+ if (!hash) {
1963
+ return null;
1964
+ }
1965
+ return `#${decodeURIComponent(hash)}`;
1966
+ }
1967
+ function normalizeNodeId(value) {
1968
+ return decodeURIComponent(value).replace(/-/g, ":");
1969
+ }
1970
+ function parseFigmaUrl(input) {
1971
+ const url = parseUrl(input);
1972
+ if (!url) {
1973
+ throw new AppError(`Invalid Figma URL: ${input}`, {
1974
+ code: "figma_url_invalid"
1975
+ });
1976
+ }
1977
+ if (!isFigmaUrl(input)) {
1978
+ throw new AppError(`Reference URL must be a Figma URL or local image path: ${input}`, {
1979
+ code: "figma_url_expected"
1980
+ });
1981
+ }
1982
+ const parts = url.pathname.split("/").filter(Boolean);
1983
+ if (parts.length < 2) {
1984
+ throw new AppError(`Could not extract file key from Figma URL: ${input}`, {
1985
+ code: "figma_file_key_missing"
1986
+ });
1987
+ }
1988
+ let fileKey;
1989
+ if (parts[0] === "design" && parts[2] === "branch") {
1990
+ fileKey = parts[3];
1991
+ } else if (parts[0] === "design" || parts[0] === "file") {
1992
+ fileKey = parts[1];
1993
+ } else if (parts[1] === "branch" && parts[2]) {
1994
+ fileKey = parts[2];
1995
+ }
1996
+ if (!fileKey) {
1997
+ fileKey = parts[1];
1998
+ }
1999
+ const nodeId = url.searchParams.get("node-id");
2000
+ if (!nodeId) {
2001
+ throw new AppError(
2002
+ `Figma URL must include a node-id query parameter so the CLI can export the correct frame: ${input}`,
2003
+ {
2004
+ exitCode: 3,
2005
+ recommendation: "needs_human_review",
2006
+ severity: "high",
2007
+ code: "figma_node_id_missing"
2008
+ }
2009
+ );
2010
+ }
2011
+ return {
2012
+ fileKey,
2013
+ nodeId: normalizeNodeId(nodeId),
2014
+ resolved: url.toString()
2015
+ };
2016
+ }
2017
+
2018
+ // src/utils/viewport.ts
2019
+ function parseViewport(value) {
2020
+ const trimmedValue = value.trim();
2021
+ const explicitMatch = /^(?<width>\d+)x(?<height>\d+)$/i.exec(trimmedValue);
2022
+ if (explicitMatch?.groups) {
2023
+ const width = Number(explicitMatch.groups.width);
2024
+ const height = Number(explicitMatch.groups.height);
2025
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
2026
+ throw new AppError(
2027
+ `Invalid viewport "${value}". Width and height must be positive integers.`,
2028
+ {
2029
+ code: "viewport_invalid_dimensions"
2030
+ }
2031
+ );
2032
+ }
2033
+ return { width, height };
2034
+ }
2035
+ const widthOnlyMatch = /^(?<width>\d+)$/i.exec(trimmedValue);
2036
+ if (widthOnlyMatch?.groups) {
2037
+ const width = Number(widthOnlyMatch.groups.width);
2038
+ if (!Number.isFinite(width) || width <= 0) {
2039
+ throw new AppError(`Invalid viewport "${value}". Width must be a positive integer.`, {
2040
+ code: "viewport_invalid_width"
2041
+ });
2042
+ }
2043
+ return {
2044
+ width,
2045
+ height: DEFAULT_VIEWPORT_HEIGHT
2046
+ };
2047
+ }
2048
+ throw new AppError(
2049
+ `Invalid viewport "${value}". Expected WIDTH or WIDTHxHEIGHT, for example 1920 or 1920x900.`,
2050
+ {
2051
+ code: "viewport_invalid_format"
2052
+ }
2053
+ );
2054
+ }
2055
+
2056
+ // src/io/inputs.ts
2057
+ async function parsePreviewInput(options) {
2058
+ const selector = hashToSelector(options.preview, options.selector);
2059
+ if (isHttpUrl(options.preview)) {
2060
+ if (!options.viewport) {
2061
+ throw new AppError(
2062
+ "Preview URL requires --viewport so the browser screenshot is deterministic.",
2063
+ {
2064
+ code: "preview_viewport_required"
2065
+ }
2066
+ );
2067
+ }
2068
+ const viewport2 = parseViewport(options.viewport);
2069
+ if (options.fullPage && selector) {
2070
+ throw new AppError(
2071
+ "--full-page cannot be used together with --selector or a preview URL hash fragment.",
2072
+ {
2073
+ code: "preview_full_page_selector_conflict"
2074
+ }
2075
+ );
2076
+ }
2077
+ return {
2078
+ kind: "url",
2079
+ input: options.preview,
2080
+ resolved: new URL(options.preview).toString(),
2081
+ selector,
2082
+ viewport: viewport2
2083
+ };
2084
+ }
2085
+ if (options.selector) {
2086
+ throw new AppError("--selector can only be used when --preview is a URL.", {
2087
+ code: "preview_selector_requires_url"
2088
+ });
2089
+ }
2090
+ if (options.fullPage) {
2091
+ throw new AppError("--full-page can only be used when --preview is a URL.", {
2092
+ code: "preview_full_page_requires_url"
2093
+ });
2094
+ }
2095
+ const resolvedPath = await ensureFileExists(options.preview);
2096
+ const viewport = options.viewport ? parseViewport(options.viewport) : null;
2097
+ return {
2098
+ kind: "path",
2099
+ input: options.preview,
2100
+ resolved: resolvedPath,
2101
+ selector: null,
2102
+ viewport
2103
+ };
2104
+ }
2105
+ async function parseReferenceInput(reference) {
2106
+ if (isHttpUrl(reference)) {
2107
+ const parsedFigmaUrl = parseFigmaUrl(reference);
2108
+ return {
2109
+ kind: "figma-url",
2110
+ input: reference,
2111
+ resolved: parsedFigmaUrl.resolved,
2112
+ fileKey: parsedFigmaUrl.fileKey,
2113
+ nodeId: parsedFigmaUrl.nodeId
2114
+ };
2115
+ }
2116
+ const resolvedPath = await ensureFileExists(reference);
2117
+ return {
2118
+ kind: "path",
2119
+ input: reference,
2120
+ resolved: resolvedPath
2121
+ };
2122
+ }
2123
+ function resolveViewportForReport(previewViewport, imageDimensions) {
2124
+ return previewViewport ?? { width: imageDimensions.width, height: imageDimensions.height };
2125
+ }
2126
+ function inferPreviewInputSource(options) {
2127
+ if (isHttpUrl(options.preview)) {
2128
+ return {
2129
+ input: options.preview,
2130
+ kind: "url",
2131
+ resolved: new URL(options.preview).toString(),
2132
+ selector: hashToSelector(options.preview, options.selector)
2133
+ };
2134
+ }
2135
+ return {
2136
+ input: options.preview,
2137
+ kind: "path",
2138
+ resolved: path4.resolve(options.preview),
2139
+ selector: null
2140
+ };
2141
+ }
2142
+ function inferReferenceInputSource(reference) {
2143
+ if (isHttpUrl(reference)) {
2144
+ return {
2145
+ input: reference,
2146
+ kind: isFigmaUrl(reference) ? "figma-url" : "url",
2147
+ resolved: new URL(reference).toString(),
2148
+ selector: null,
2149
+ transport: null
2150
+ };
2151
+ }
2152
+ return {
2153
+ input: reference,
2154
+ kind: "path",
2155
+ resolved: path4.resolve(reference),
2156
+ selector: null,
2157
+ transport: "path"
2158
+ };
2159
+ }
2160
+
2161
+ // src/reference/figma-mcp-reference-provider.ts
2162
+ import { spawn } from "child_process";
2163
+ import { createHash } from "crypto";
2164
+ import { createServer } from "http";
2165
+ import { readFile, rm, writeFile as writeFile3 } from "fs/promises";
2166
+ import os from "os";
2167
+ import path5 from "path";
2168
+ import sharp2 from "sharp";
2169
+ import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
2170
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2171
+ import {
2172
+ StreamableHTTPClientTransport,
2173
+ StreamableHTTPError
2174
+ } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
2175
+ var PEYE_CLIENT_NAME = "peye";
2176
+ var PEYE_CLIENT_VERSION = "0.1.0";
2177
+ var SCREENSHOT_TOOL_NAME = "get_screenshot";
2178
+ var METADATA_TOOL_NAME = "get_metadata";
2179
+ var MAX_ACCEPTABLE_ASPECT_RATIO_DELTA = 0.02;
2180
+ var FigmaMcpReferenceProvider = class {
2181
+ kind = "figma-url";
2182
+ serverUrl;
2183
+ transport;
2184
+ constructor(options) {
2185
+ this.serverUrl = options.serverUrl;
2186
+ this.transport = options.transport;
2187
+ }
2188
+ async prepare(reference, outputPath) {
2189
+ if (reference.kind !== "figma-url") {
2190
+ throw new AppError(
2191
+ `Unsupported reference input kind for Figma MCP provider: ${reference.kind}`,
2192
+ {
2193
+ code: "reference_provider_kind_mismatch"
2194
+ }
2195
+ );
2196
+ }
2197
+ const callbackServer = this.transport === "figma-mcp-remote" && isInteractiveTerminal() ? await createOAuthCallbackServer() : null;
2198
+ const oauthProvider = this.transport === "figma-mcp-remote" && callbackServer ? await PersistentOAuthClientProvider.create(this.serverUrl, callbackServer) : null;
2199
+ const transportOptions = oauthProvider === null ? { fetch: fetchWithTimeout } : { authProvider: oauthProvider, fetch: fetchWithTimeout };
2200
+ const transport = new StreamableHTTPClientTransport(new URL(this.serverUrl), transportOptions);
2201
+ const client = new Client(
2202
+ {
2203
+ name: PEYE_CLIENT_NAME,
2204
+ version: PEYE_CLIENT_VERSION
2205
+ },
2206
+ {
2207
+ capabilities: {}
2208
+ }
2209
+ );
2210
+ try {
2211
+ await runWithOAuthRetry(
2212
+ transport,
2213
+ oauthProvider,
2214
+ callbackServer,
2215
+ () => client.connect(transport)
2216
+ );
2217
+ const tools = await runWithOAuthRetry(
2218
+ transport,
2219
+ oauthProvider,
2220
+ callbackServer,
2221
+ () => client.listTools()
2222
+ );
2223
+ const hasScreenshotTool = tools.tools.some((tool) => tool.name === SCREENSHOT_TOOL_NAME);
2224
+ if (!hasScreenshotTool) {
2225
+ throw new AppError(
2226
+ `Figma MCP server at ${this.serverUrl} does not expose ${SCREENSHOT_TOOL_NAME}.`,
2227
+ {
2228
+ code: "figma_mcp_tool_missing"
2229
+ }
2230
+ );
2231
+ }
2232
+ const result = await runWithOAuthRetry(
2233
+ transport,
2234
+ oauthProvider,
2235
+ callbackServer,
2236
+ () => client.callTool({
2237
+ name: SCREENSHOT_TOOL_NAME,
2238
+ arguments: {
2239
+ fileKey: reference.fileKey,
2240
+ nodeId: reference.nodeId
2241
+ }
2242
+ })
2243
+ );
2244
+ const buffer = extractImageBuffer(result, this.serverUrl);
2245
+ const resizeTarget = tools.tools.some((tool) => tool.name === METADATA_TOOL_NAME) ? await resolveMetadataResizeTarget(
2246
+ client,
2247
+ transport,
2248
+ oauthProvider,
2249
+ callbackServer,
2250
+ reference,
2251
+ buffer
2252
+ ) : null;
2253
+ const prepared = resizeTarget === null ? await bufferToNormalizedPng(buffer, outputPath) : await bufferToNormalizedPng(buffer, outputPath, {
2254
+ resizeTo: resizeTarget
2255
+ });
2256
+ return {
2257
+ ...prepared,
2258
+ transport: this.transport
2259
+ };
2260
+ } catch (error) {
2261
+ throw mapMcpError(error, this.transport, this.serverUrl);
2262
+ } finally {
2263
+ await client.close().catch(() => void 0);
2264
+ await transport.close().catch(() => void 0);
2265
+ await callbackServer?.close().catch(() => void 0);
2266
+ }
2267
+ }
2268
+ };
2269
+ async function resolveMetadataResizeTarget(client, transport, oauthProvider, callbackServer, reference, screenshotBuffer) {
2270
+ const metadataResult = await runWithOAuthRetry(
2271
+ transport,
2272
+ oauthProvider,
2273
+ callbackServer,
2274
+ () => client.callTool({
2275
+ name: METADATA_TOOL_NAME,
2276
+ arguments: {
2277
+ fileKey: reference.fileKey,
2278
+ nodeId: reference.nodeId
2279
+ }
2280
+ })
2281
+ );
2282
+ const targetDimensions = extractNodeDimensions(metadataResult);
2283
+ if (targetDimensions === null) {
2284
+ return null;
2285
+ }
2286
+ const currentDimensions = await readBufferDimensions(screenshotBuffer);
2287
+ if (currentDimensions.width >= targetDimensions.width || currentDimensions.height >= targetDimensions.height) {
2288
+ return null;
2289
+ }
2290
+ const currentAspectRatio = currentDimensions.width / currentDimensions.height;
2291
+ const targetAspectRatio = targetDimensions.width / targetDimensions.height;
2292
+ if (Math.abs(currentAspectRatio - targetAspectRatio) > MAX_ACCEPTABLE_ASPECT_RATIO_DELTA) {
2293
+ return null;
2294
+ }
2295
+ return targetDimensions;
2296
+ }
2297
+ async function runWithOAuthRetry(transport, provider, callbackServer, operation) {
2298
+ let hasRetried = false;
2299
+ while (true) {
2300
+ try {
2301
+ return await operation();
2302
+ } catch (error) {
2303
+ if (!(error instanceof UnauthorizedError) || provider === null || callbackServer === null) {
2304
+ throw error;
2305
+ }
2306
+ if (hasRetried) {
2307
+ throw error;
2308
+ }
2309
+ hasRetried = true;
2310
+ const authorizationCode = await callbackServer.waitForAuthorizationCode();
2311
+ await transport.finishAuth(authorizationCode);
2312
+ }
2313
+ }
2314
+ }
2315
+ function extractImageBuffer(result, serverUrl) {
2316
+ if ("toolResult" in result) {
2317
+ throw new AppError(`Figma MCP server at ${serverUrl} returned an unsupported tool result.`, {
2318
+ exitCode: 3,
2319
+ recommendation: "needs_human_review",
2320
+ severity: "high",
2321
+ code: "figma_mcp_invalid_response"
2322
+ });
2323
+ }
2324
+ if (result.isError) {
2325
+ const message = extractTextContent(result.content) ?? "Figma MCP tool returned an error.";
2326
+ throw new AppError(message, {
2327
+ exitCode: 3,
2328
+ recommendation: "needs_human_review",
2329
+ severity: "high",
2330
+ code: "figma_mcp_invalid_response"
2331
+ });
2332
+ }
2333
+ for (const block of result.content) {
2334
+ if (block.type === "image" && block.mimeType.startsWith("image/")) {
2335
+ return Buffer.from(block.data, "base64");
2336
+ }
2337
+ if (block.type === "resource" && "blob" in block.resource) {
2338
+ const mimeType = block.resource.mimeType ?? "";
2339
+ if (mimeType.startsWith("image/")) {
2340
+ return Buffer.from(block.resource.blob, "base64");
2341
+ }
2342
+ }
2343
+ }
2344
+ throw new AppError(
2345
+ `Figma MCP server at ${serverUrl} returned no image content for get_screenshot.`,
2346
+ {
2347
+ exitCode: 3,
2348
+ recommendation: "needs_human_review",
2349
+ severity: "high",
2350
+ code: "figma_mcp_invalid_response"
2351
+ }
2352
+ );
2353
+ }
2354
+ function extractNodeDimensions(result) {
2355
+ if ("toolResult" in result || result.isError) {
2356
+ return null;
2357
+ }
2358
+ const metadataText = extractTextContent(result.content);
2359
+ if (!metadataText) {
2360
+ return null;
2361
+ }
2362
+ const rootTagMatch = metadataText.match(/<[^>]+>/);
2363
+ if (!rootTagMatch) {
2364
+ return null;
2365
+ }
2366
+ const widthMatch = rootTagMatch[0].match(/\bwidth="([^"]+)"/);
2367
+ const heightMatch = rootTagMatch[0].match(/\bheight="([^"]+)"/);
2368
+ const width = Math.round(Number.parseFloat(widthMatch?.[1] ?? ""));
2369
+ const height = Math.round(Number.parseFloat(heightMatch?.[1] ?? ""));
2370
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
2371
+ return null;
2372
+ }
2373
+ return { width, height };
2374
+ }
2375
+ function extractTextContent(content) {
2376
+ for (const block of content) {
2377
+ if (block.type === "text") {
2378
+ return block.text ?? null;
2379
+ }
2380
+ }
2381
+ return null;
2382
+ }
2383
+ async function readBufferDimensions(buffer) {
2384
+ const metadata = await sharp2(buffer).metadata();
2385
+ if (!metadata.width || !metadata.height) {
2386
+ throw new AppError("Could not read dimensions from the Figma MCP screenshot buffer.", {
2387
+ code: "figma_mcp_invalid_response"
2388
+ });
2389
+ }
2390
+ return {
2391
+ width: metadata.width,
2392
+ height: metadata.height
2393
+ };
2394
+ }
2395
+ function mapMcpError(error, transport, serverUrl) {
2396
+ if (error instanceof AppError) {
2397
+ return error;
2398
+ }
2399
+ if (error instanceof UnauthorizedError) {
2400
+ return new AppError(
2401
+ transport === "figma-mcp-remote" ? "Remote Figma MCP requires OAuth authorization." : `Figma MCP at ${serverUrl} rejected the request as unauthorized.`,
2402
+ {
2403
+ code: "figma_mcp_auth_required",
2404
+ cause: error
2405
+ }
2406
+ );
2407
+ }
2408
+ if (error instanceof StreamableHTTPError && error.code === 401) {
2409
+ return new AppError(
2410
+ transport === "figma-mcp-remote" ? "Remote Figma MCP requires OAuth authorization." : `Figma MCP at ${serverUrl} rejected the request as unauthorized.`,
2411
+ {
2412
+ code: "figma_mcp_auth_required",
2413
+ cause: error
2414
+ }
2415
+ );
2416
+ }
2417
+ return new AppError(
2418
+ `Failed to read Figma reference through ${transport} at ${serverUrl}. ${ensureError(error).message}`,
2419
+ {
2420
+ code: "figma_mcp_request_failed",
2421
+ cause: error
2422
+ }
2423
+ );
2424
+ }
2425
+ function isInteractiveTerminal() {
2426
+ return process.stdin.isTTY === true && process.stdout.isTTY === true;
2427
+ }
2428
+ function fetchWithTimeout(input, init) {
2429
+ const timeoutSignal = AbortSignal.timeout(DEFAULT_RESOURCE_TIMEOUT_MS);
2430
+ const signal = init?.signal ? AbortSignal.any([init.signal, timeoutSignal]) : timeoutSignal;
2431
+ return fetch(input, {
2432
+ ...init,
2433
+ signal
2434
+ });
2435
+ }
2436
+ var PersistentOAuthClientProvider = class _PersistentOAuthClientProvider {
2437
+ callbackServer;
2438
+ storagePath;
2439
+ oauthState;
2440
+ constructor(callbackServer, storagePath, state) {
2441
+ this.callbackServer = callbackServer;
2442
+ this.storagePath = storagePath;
2443
+ this.oauthState = state;
2444
+ }
2445
+ static async create(serverUrl, callbackServer) {
2446
+ const storagePath = await oauthStatePath(serverUrl);
2447
+ const state = await loadOAuthState(storagePath);
2448
+ return new _PersistentOAuthClientProvider(callbackServer, storagePath, state);
2449
+ }
2450
+ get redirectUrl() {
2451
+ return this.callbackServer.redirectUrl;
2452
+ }
2453
+ get clientMetadata() {
2454
+ return {
2455
+ client_name: "peye",
2456
+ redirect_uris: [this.callbackServer.redirectUrl],
2457
+ grant_types: ["authorization_code", "refresh_token"],
2458
+ response_types: ["code"],
2459
+ token_endpoint_auth_method: "none"
2460
+ };
2461
+ }
2462
+ clientInformation() {
2463
+ return this.oauthState.clientInformation;
2464
+ }
2465
+ async saveClientInformation(clientInformation) {
2466
+ this.oauthState.clientInformation = clientInformation;
2467
+ await this.persist();
2468
+ }
2469
+ tokens() {
2470
+ return this.oauthState.tokens;
2471
+ }
2472
+ async saveTokens(tokens) {
2473
+ this.oauthState.tokens = tokens;
2474
+ await this.persist();
2475
+ }
2476
+ redirectToAuthorization(authorizationUrl) {
2477
+ process.stderr.write(
2478
+ [
2479
+ "Remote Figma MCP requires authorization.",
2480
+ `Open this URL to continue: ${authorizationUrl.toString()}`
2481
+ ].join("\n") + "\n"
2482
+ );
2483
+ openExternalUrl(authorizationUrl.toString());
2484
+ }
2485
+ async saveCodeVerifier(codeVerifier) {
2486
+ this.oauthState.codeVerifier = codeVerifier;
2487
+ await this.persist();
2488
+ }
2489
+ codeVerifier() {
2490
+ if (!this.oauthState.codeVerifier) {
2491
+ throw new Error("No OAuth code verifier available.");
2492
+ }
2493
+ return this.oauthState.codeVerifier;
2494
+ }
2495
+ async invalidateCredentials(scope) {
2496
+ if (scope === "all" || scope === "client") {
2497
+ delete this.oauthState.clientInformation;
2498
+ }
2499
+ if (scope === "all" || scope === "tokens") {
2500
+ delete this.oauthState.tokens;
2501
+ }
2502
+ if (scope === "all" || scope === "verifier") {
2503
+ delete this.oauthState.codeVerifier;
2504
+ }
2505
+ if (scope === "all" || scope === "discovery") {
2506
+ delete this.oauthState.discoveryState;
2507
+ }
2508
+ const hasState = Object.values(this.oauthState).some((value) => value !== void 0);
2509
+ if (!hasState) {
2510
+ await rm(this.storagePath, { force: true }).catch(() => void 0);
2511
+ return;
2512
+ }
2513
+ await this.persist();
2514
+ }
2515
+ discoveryState() {
2516
+ return this.oauthState.discoveryState;
2517
+ }
2518
+ async saveDiscoveryState(state) {
2519
+ this.oauthState.discoveryState = state;
2520
+ await this.persist();
2521
+ }
2522
+ async persist() {
2523
+ await ensureDirectory(path5.dirname(this.storagePath));
2524
+ await writeFile3(this.storagePath, `${JSON.stringify(this.oauthState, null, 2)}
2525
+ `, "utf8");
2526
+ }
2527
+ };
2528
+ async function loadOAuthState(storagePath) {
2529
+ try {
2530
+ const raw = await readFile(storagePath, "utf8");
2531
+ return JSON.parse(raw);
2532
+ } catch (error) {
2533
+ const message = ensureError(error).message;
2534
+ if (error.code === "ENOENT") {
2535
+ return {};
2536
+ }
2537
+ throw new AppError(`Failed to load persisted Figma MCP OAuth state. ${message}`, {
2538
+ code: "figma_mcp_oauth_state_invalid",
2539
+ cause: error
2540
+ });
2541
+ }
2542
+ }
2543
+ async function oauthStatePath(serverUrl) {
2544
+ const digest = createHash("sha256").update(serverUrl).digest("hex").slice(0, 16);
2545
+ const dir = path5.join(resolveConfigDir(), "oauth");
2546
+ await ensureDirectory(dir);
2547
+ return path5.join(dir, `figma-${digest}.json`);
2548
+ }
2549
+ function resolveConfigDir() {
2550
+ if (process.platform === "win32" && process.env.APPDATA) {
2551
+ return path5.join(process.env.APPDATA, "peye");
2552
+ }
2553
+ if (process.env.XDG_CONFIG_HOME) {
2554
+ return path5.join(process.env.XDG_CONFIG_HOME, "peye");
2555
+ }
2556
+ return path5.join(os.homedir(), ".config", "peye");
2557
+ }
2558
+ async function createOAuthCallbackServer() {
2559
+ const deferred = createDeferred();
2560
+ const server = createServer((request, response) => {
2561
+ const requestUrl = new URL(request.url ?? "/", "http://127.0.0.1");
2562
+ const authorizationCode = requestUrl.searchParams.get("code");
2563
+ const error = requestUrl.searchParams.get("error");
2564
+ if (authorizationCode) {
2565
+ response.statusCode = 200;
2566
+ response.setHeader("content-type", "text/html; charset=utf-8");
2567
+ response.end(
2568
+ "<html><body><h1>Authorization complete</h1><p>You can close this window.</p></body></html>"
2569
+ );
2570
+ deferred.resolve(authorizationCode);
2571
+ return;
2572
+ }
2573
+ if (error) {
2574
+ response.statusCode = 400;
2575
+ response.setHeader("content-type", "text/html; charset=utf-8");
2576
+ response.end(`<html><body><h1>Authorization failed</h1><p>${error}</p></body></html>`);
2577
+ deferred.reject(new Error(`OAuth authorization failed: ${error}`));
2578
+ return;
2579
+ }
2580
+ response.statusCode = 400;
2581
+ response.setHeader("content-type", "text/html; charset=utf-8");
2582
+ response.end("<html><body><h1>Invalid callback</h1></body></html>");
2583
+ });
2584
+ await new Promise((resolve, reject) => {
2585
+ server.once("error", reject);
2586
+ server.listen(0, "127.0.0.1", () => {
2587
+ server.off("error", reject);
2588
+ resolve();
2589
+ });
2590
+ });
2591
+ const address = server.address();
2592
+ if (!address || typeof address === "string") {
2593
+ throw new AppError("Failed to start local OAuth callback server.", {
2594
+ code: "figma_mcp_oauth_callback_failed"
2595
+ });
2596
+ }
2597
+ return {
2598
+ redirectUrl: `http://127.0.0.1:${address.port}/callback`,
2599
+ waitForAuthorizationCode: async () => {
2600
+ return new Promise((resolve, reject) => {
2601
+ const timeout = setTimeout(() => {
2602
+ reject(
2603
+ new AppError("Timed out waiting for the Figma MCP OAuth callback.", {
2604
+ code: "figma_mcp_oauth_timeout"
2605
+ })
2606
+ );
2607
+ }, DEFAULT_FIGMA_OAUTH_TIMEOUT_MS);
2608
+ timeout.unref?.();
2609
+ deferred.promise.then(
2610
+ (code) => {
2611
+ clearTimeout(timeout);
2612
+ resolve(code);
2613
+ },
2614
+ (error) => {
2615
+ clearTimeout(timeout);
2616
+ reject(ensureError(error));
2617
+ }
2618
+ );
2619
+ });
2620
+ },
2621
+ close: async () => {
2622
+ await new Promise((resolve, reject) => {
2623
+ server.close((error) => {
2624
+ if (error) {
2625
+ reject(error);
2626
+ return;
2627
+ }
2628
+ resolve();
2629
+ });
2630
+ });
2631
+ }
2632
+ };
2633
+ }
2634
+ function createDeferred() {
2635
+ let resolve;
2636
+ let reject;
2637
+ const promise = new Promise((innerResolve, innerReject) => {
2638
+ resolve = innerResolve;
2639
+ reject = innerReject;
2640
+ });
2641
+ return {
2642
+ promise,
2643
+ reject,
2644
+ resolve
2645
+ };
2646
+ }
2647
+ function openExternalUrl(url) {
2648
+ const command = process.platform === "darwin" ? ["open", url] : process.platform === "win32" ? ["cmd", "/c", "start", "", url] : ["xdg-open", url];
2649
+ const [file, ...args] = command;
2650
+ try {
2651
+ const child = spawn(file, args, {
2652
+ detached: true,
2653
+ stdio: "ignore"
2654
+ });
2655
+ child.once("error", () => {
2656
+ process.stderr.write("Open the authorization URL manually if the browser did not start.\n");
2657
+ });
2658
+ child.unref();
2659
+ } catch {
2660
+ process.stderr.write(`Open the authorization URL manually if the browser did not start.
2661
+ `);
2662
+ }
2663
+ }
2664
+
2665
+ // src/reference/figma-rest-reference-provider.ts
2666
+ var FigmaRestReferenceProvider = class {
2667
+ kind = "figma-url";
2668
+ async prepare(reference, outputPath) {
2669
+ if (reference.kind !== "figma-url") {
2670
+ throw new AppError(`Unsupported reference input kind for Figma provider: ${reference.kind}`, {
2671
+ code: "reference_provider_kind_mismatch"
2672
+ });
2673
+ }
2674
+ const token = process.env.FIGMA_TOKEN;
2675
+ if (!token) {
2676
+ throw new AppError("FIGMA_TOKEN is required when --reference points to a Figma URL.", {
2677
+ code: "figma_token_missing"
2678
+ });
2679
+ }
2680
+ const endpoint = new URL(`/v1/images/${reference.fileKey}`, figmaApiBaseUrl());
2681
+ endpoint.searchParams.set("ids", reference.nodeId);
2682
+ endpoint.searchParams.set("format", "png");
2683
+ endpoint.searchParams.set("scale", "1");
2684
+ const payload = await fetchFigmaJson(
2685
+ endpoint,
2686
+ token,
2687
+ `Failed to export Figma node image for ${reference.nodeId}.`
2688
+ );
2689
+ const imageUrl = payload.images?.[reference.nodeId];
2690
+ if (!imageUrl) {
2691
+ throw new AppError(`Figma did not return an image URL for node ${reference.nodeId}.`, {
2692
+ exitCode: 3,
2693
+ recommendation: "needs_human_review",
2694
+ severity: "high",
2695
+ code: "figma_image_missing"
2696
+ });
2697
+ }
2698
+ const buffer = await fetchBinary(
2699
+ imageUrl,
2700
+ `Failed to download exported Figma image for ${reference.nodeId}.`
2701
+ );
2702
+ const prepared = await bufferToNormalizedPng(buffer, outputPath);
2703
+ return {
2704
+ ...prepared,
2705
+ transport: "figma-rest"
2706
+ };
2707
+ }
2708
+ };
2709
+ function figmaApiBaseUrl() {
2710
+ return process.env.FIGMA_API_BASE_URL ?? DEFAULT_FIGMA_API_BASE_URL;
2711
+ }
2712
+ async function fetchFigmaJson(url, token, failureMessage) {
2713
+ const response = await fetchWithTimeout2(
2714
+ url,
2715
+ {
2716
+ headers: {
2717
+ "X-Figma-Token": token
2718
+ }
2719
+ },
2720
+ failureMessage
2721
+ );
2722
+ try {
2723
+ return await response.json();
2724
+ } catch (error) {
2725
+ throw new AppError(`${failureMessage} Invalid JSON response from Figma API.`, {
2726
+ code: "figma_response_invalid_json",
2727
+ cause: error
2728
+ });
2729
+ }
2730
+ }
2731
+ async function fetchBinary(url, failureMessage) {
2732
+ const response = await fetchWithTimeout2(url, void 0, failureMessage);
2733
+ return Buffer.from(await response.arrayBuffer());
2734
+ }
2735
+ async function fetchWithTimeout2(input, init, failureMessage) {
2736
+ try {
2737
+ const response = await fetch(input, {
2738
+ ...init,
2739
+ signal: AbortSignal.timeout(DEFAULT_RESOURCE_TIMEOUT_MS)
2740
+ });
2741
+ if (!response.ok) {
2742
+ throw new AppError(`${failureMessage} Received ${response.status} ${response.statusText}.`, {
2743
+ code: "remote_request_failed"
2744
+ });
2745
+ }
2746
+ return response;
2747
+ } catch (error) {
2748
+ if (error instanceof AppError) {
2749
+ throw error;
2750
+ }
2751
+ throw new AppError(`${failureMessage} ${ensureError(error).message}`, {
2752
+ code: "remote_request_failed",
2753
+ cause: error
2754
+ });
2755
+ }
2756
+ }
2757
+
2758
+ // src/reference/figma-reference-provider.ts
2759
+ var FigmaReferenceProvider = class {
2760
+ kind = "figma-url";
2761
+ async prepare(reference, outputPath) {
2762
+ const providers2 = buildProviders();
2763
+ const failures = [];
2764
+ for (const provider of providers2) {
2765
+ try {
2766
+ return await provider.instance.prepare(reference, outputPath);
2767
+ } catch (error) {
2768
+ const appError = error instanceof AppError ? error : new AppError(ensureError(error).message, {
2769
+ cause: error,
2770
+ code: "figma_reference_attempt_failed"
2771
+ });
2772
+ failures.push({
2773
+ label: provider.label,
2774
+ error: appError
2775
+ });
2776
+ }
2777
+ }
2778
+ throw buildFailureFromAttempts(failures);
2779
+ }
2780
+ };
2781
+ function buildProviders() {
2782
+ const mode = resolveFigmaSourceMode();
2783
+ const desktopProvider = new FigmaMcpReferenceProvider({
2784
+ serverUrl: process.env.PEYE_FIGMA_MCP_DESKTOP_URL ?? DEFAULT_FIGMA_MCP_DESKTOP_URL,
2785
+ transport: "figma-mcp-desktop"
2786
+ });
2787
+ const remoteProvider = new FigmaMcpReferenceProvider({
2788
+ serverUrl: process.env.PEYE_FIGMA_MCP_REMOTE_URL ?? DEFAULT_FIGMA_MCP_REMOTE_URL,
2789
+ transport: "figma-mcp-remote"
2790
+ });
2791
+ const restProvider = new FigmaRestReferenceProvider();
2792
+ if (mode === "rest") {
2793
+ return [{ instance: restProvider, label: "Figma REST" }];
2794
+ }
2795
+ if (mode === "mcp") {
2796
+ return [
2797
+ { instance: desktopProvider, label: "Figma MCP desktop" },
2798
+ { instance: remoteProvider, label: "Figma MCP remote" }
2799
+ ];
2800
+ }
2801
+ return [
2802
+ { instance: desktopProvider, label: "Figma MCP desktop" },
2803
+ { instance: remoteProvider, label: "Figma MCP remote" },
2804
+ { instance: restProvider, label: "Figma REST" }
2805
+ ];
2806
+ }
2807
+ function resolveFigmaSourceMode() {
2808
+ const mode = process.env.PEYE_FIGMA_SOURCE ?? DEFAULT_FIGMA_SOURCE;
2809
+ if (mode === "auto" || mode === "mcp" || mode === "rest") {
2810
+ return mode;
2811
+ }
2812
+ throw new AppError(
2813
+ `Invalid PEYE_FIGMA_SOURCE value "${mode}". Expected one of: auto, mcp, rest.`,
2814
+ {
2815
+ code: "figma_source_invalid"
2816
+ }
2817
+ );
2818
+ }
2819
+ function buildFailureFromAttempts(failures) {
2820
+ if (failures.length === 1) {
2821
+ return failures[0].error;
2822
+ }
2823
+ const preferredError = failures.find(
2824
+ (failure) => failure.error.code === "figma_mcp_invalid_response" || failure.error.code === "figma_image_missing"
2825
+ )?.error ?? failures.at(-1)?.error;
2826
+ const nextSteps = [
2827
+ "Start the Figma desktop app MCP server.",
2828
+ "Run peye in an interactive terminal to authorize remote Figma MCP.",
2829
+ "Set FIGMA_TOKEN to enable REST fallback."
2830
+ ];
2831
+ const attemptLines = failures.map((failure) => `- ${failure.label}: ${failure.error.message}`);
2832
+ return new AppError(
2833
+ [
2834
+ preferredError?.message ?? "Failed to resolve the Figma reference.",
2835
+ "Reference lookup attempts:",
2836
+ ...attemptLines,
2837
+ "Next steps:",
2838
+ ...nextSteps.map((step) => `- ${step}`)
2839
+ ].join("\n"),
2840
+ {
2841
+ exitCode: preferredError?.exitCode ?? 3,
2842
+ recommendation: preferredError?.recommendation ?? "needs_human_review",
2843
+ severity: preferredError?.severity ?? "high",
2844
+ code: preferredError?.code ?? "figma_reference_unavailable",
2845
+ cause: preferredError
2846
+ }
2847
+ );
2848
+ }
2849
+
2850
+ // src/reference/local-reference-provider.ts
2851
+ var LocalReferenceProvider = class {
2852
+ kind = "path";
2853
+ async prepare(reference, outputPath) {
2854
+ try {
2855
+ const prepared = await normalizeImageToPng(reference.resolved, outputPath);
2856
+ return {
2857
+ ...prepared,
2858
+ transport: "path"
2859
+ };
2860
+ } catch (error) {
2861
+ throw new AppError(
2862
+ `Failed to normalize reference image: ${reference.resolved}. ${ensureError(error).message}`,
2863
+ {
2864
+ code: "reference_image_normalization_failed",
2865
+ cause: error
2866
+ }
2867
+ );
2868
+ }
2869
+ }
2870
+ };
2871
+
2872
+ // src/reference/index.ts
2873
+ var providers = /* @__PURE__ */ new Map([
2874
+ ["figma-url", new FigmaReferenceProvider()],
2875
+ ["path", new LocalReferenceProvider()]
2876
+ ]);
2877
+ async function materializeReferenceImage(reference, outputPath) {
2878
+ const provider = providers.get(reference.kind);
2879
+ if (!provider) {
2880
+ throw new AppError(`No reference provider registered for ${reference.kind}.`, {
2881
+ code: "reference_provider_missing"
2882
+ });
2883
+ }
2884
+ return provider.prepare(reference, outputPath);
2885
+ }
2886
+
2887
+ // src/core/run-compare.ts
2888
+ async function runCompare(options) {
2889
+ validateThresholdOrdering(options);
2890
+ const outputDir = await ensureDirectory(options.output);
2891
+ const artifactPaths = createArtifactPaths(outputDir);
2892
+ let previewInput = null;
2893
+ let referenceInput = null;
2894
+ let preparedPreview = null;
2895
+ let preparedReference = null;
2896
+ try {
2897
+ previewInput = await parsePreviewInput(options);
2898
+ referenceInput = await parseReferenceInput(options.reference);
2899
+ preparedPreview = await materializePreviewImage(
2900
+ previewInput,
2901
+ artifactPaths.preview,
2902
+ options.fullPage
2903
+ );
2904
+ preparedReference = await materializeReferenceImage(referenceInput, artifactPaths.reference);
2905
+ const previewImage = await loadNormalizedImage(preparedPreview.path);
2906
+ const referenceImage = await loadNormalizedImage(preparedReference.path);
2907
+ const width = Math.max(previewImage.width, referenceImage.width);
2908
+ const height = Math.max(previewImage.height, referenceImage.height);
2909
+ const paddedPreview = padImageToCanvas(previewImage, width, height);
2910
+ const paddedReference = padImageToCanvas(referenceImage, width, height);
2911
+ const comparison = runComparisonEngine({
2912
+ reference: paddedReference.data,
2913
+ preview: paddedPreview.data,
2914
+ width,
2915
+ height,
2916
+ referenceOriginal: {
2917
+ width: referenceImage.width,
2918
+ height: referenceImage.height
2919
+ },
2920
+ previewOriginal: {
2921
+ width: previewImage.width,
2922
+ height: previewImage.height
2923
+ },
2924
+ mode: options.mode ?? DEFAULT_MODE
2925
+ });
2926
+ const findingsAnalysis = buildFindingsAnalysis({
2927
+ analysisMode: preparedPreview.analysisMode,
2928
+ rawRegions: comparison.rawRegions,
2929
+ domSnapshot: preparedPreview.domSnapshot,
2930
+ width,
2931
+ height
2932
+ });
2933
+ const heatmap = createHeatmapArtifact({
2934
+ reference: paddedReference.data,
2935
+ preview: paddedPreview.data,
2936
+ mismatchMask: comparison.mismatchMask,
2937
+ visuals: findingsAnalysis.visuals,
2938
+ width,
2939
+ height
2940
+ });
2941
+ await Promise.all([
2942
+ writeRawRgbaPng(artifactPaths.overlay, comparison.buffers.overlay, width, height),
2943
+ writeRawRgbaPng(artifactPaths.diff, comparison.buffers.diff, width, height),
2944
+ writeRawRgbaPng(artifactPaths.heatmap, heatmap, width, height)
2945
+ ]);
2946
+ const metrics = {
2947
+ ...comparison.metrics,
2948
+ findingsCount: findingsAnalysis.metrics.findingsCount,
2949
+ affectedElementCount: findingsAnalysis.metrics.affectedElementCount
2950
+ };
2951
+ const decision = decideRecommendation({
2952
+ metrics,
2953
+ thresholds: {
2954
+ pass: options.thresholdPass,
2955
+ tolerated: options.thresholdTolerated,
2956
+ retry: options.thresholdRetry
2957
+ },
2958
+ findings: findingsAnalysis.findings
2959
+ });
2960
+ const report = {
2961
+ analysisMode: preparedPreview.analysisMode,
2962
+ summary: decision,
2963
+ inputs: {
2964
+ preview: {
2965
+ input: previewInput.input,
2966
+ kind: previewInput.kind,
2967
+ resolved: previewInput.resolved,
2968
+ selector: previewInput.selector
2969
+ },
2970
+ reference: {
2971
+ input: referenceInput.input,
2972
+ kind: referenceInput.kind,
2973
+ resolved: referenceInput.resolved,
2974
+ selector: null,
2975
+ transport: preparedReference.transport
2976
+ },
2977
+ viewport: resolveViewportForReport(previewInput.viewport, {
2978
+ width: previewImage.width,
2979
+ height: previewImage.height
2980
+ }),
2981
+ mode: options.mode,
2982
+ fullPage: options.fullPage
2983
+ },
2984
+ images: {
2985
+ preview: { width: previewImage.width, height: previewImage.height },
2986
+ reference: { width: referenceImage.width, height: referenceImage.height },
2987
+ canvas: { width, height }
2988
+ },
2989
+ metrics,
2990
+ rollups: findingsAnalysis.rollups,
2991
+ findings: findingsAnalysis.findings,
2992
+ artifacts: artifactPaths,
2993
+ error: null
2994
+ };
2995
+ await writeJsonFile(artifactPaths.report, report);
2996
+ return {
2997
+ report,
2998
+ exitCode: exitCodeForRecommendation(report.summary.recommendation)
2999
+ };
3000
+ } catch (error) {
3001
+ if (isAppError(error)) {
3002
+ const report = createFailureReport({
3003
+ reason: error.message,
3004
+ recommendation: error.recommendation ?? "needs_human_review",
3005
+ severity: error.severity ?? (error.exitCode === 1 ? "medium" : "high"),
3006
+ preview: toPreviewSourceReport(previewInput, options),
3007
+ reference: toReferenceSourceReport(referenceInput, options.reference, preparedReference),
3008
+ viewport: previewInput?.viewport ?? null,
3009
+ analysisMode: preparedPreview?.analysisMode ?? inferAnalysisMode(previewInput, options),
3010
+ mode: options.mode,
3011
+ fullPage: options.fullPage,
3012
+ images: buildImagesReport(preparedPreview, preparedReference),
3013
+ artifacts: {
3014
+ ...artifactPaths,
3015
+ preview: await pathExists(artifactPaths.preview) ? artifactPaths.preview : null,
3016
+ reference: await pathExists(artifactPaths.reference) ? artifactPaths.reference : null,
3017
+ overlay: null,
3018
+ diff: null,
3019
+ heatmap: null
3020
+ },
3021
+ error: {
3022
+ code: error.code,
3023
+ message: error.message,
3024
+ exitCode: error.exitCode
3025
+ }
3026
+ });
3027
+ await writeJsonFile(artifactPaths.report, report);
3028
+ return {
3029
+ report,
3030
+ exitCode: error.exitCode
3031
+ };
3032
+ }
3033
+ throw error;
3034
+ }
3035
+ }
3036
+ function buildImagesReport(preparedPreview, preparedReference) {
3037
+ const preview = preparedPreview === null ? null : {
3038
+ width: preparedPreview.width,
3039
+ height: preparedPreview.height
3040
+ };
3041
+ const reference = preparedReference === null ? null : {
3042
+ width: preparedReference.width,
3043
+ height: preparedReference.height
3044
+ };
3045
+ return {
3046
+ preview,
3047
+ reference,
3048
+ canvas: preview === null || reference === null ? null : {
3049
+ width: Math.max(preview.width, reference.width),
3050
+ height: Math.max(preview.height, reference.height)
3051
+ }
3052
+ };
3053
+ }
3054
+ function inferAnalysisMode(previewInput, options) {
3055
+ if (previewInput) {
3056
+ return previewInput.kind === "url" ? "dom-elements" : "visual-clusters";
3057
+ }
3058
+ return options.preview.startsWith("http://") || options.preview.startsWith("https://") ? "dom-elements" : "visual-clusters";
3059
+ }
3060
+ function createArtifactPaths(outputDir) {
3061
+ return {
3062
+ preview: path6.join(outputDir, "preview.png"),
3063
+ reference: path6.join(outputDir, "reference.png"),
3064
+ overlay: path6.join(outputDir, "overlay.png"),
3065
+ diff: path6.join(outputDir, "diff.png"),
3066
+ heatmap: path6.join(outputDir, "heatmap.png"),
3067
+ report: path6.join(outputDir, "report.json")
3068
+ };
3069
+ }
3070
+ function toPreviewSourceReport(previewInput, options) {
3071
+ if (!previewInput) {
3072
+ return inferPreviewInputSource(options);
3073
+ }
3074
+ return {
3075
+ input: previewInput.input,
3076
+ kind: previewInput.kind,
3077
+ resolved: previewInput.resolved,
3078
+ selector: previewInput.selector
3079
+ };
3080
+ }
3081
+ function toReferenceSourceReport(referenceInput, reference, preparedReference) {
3082
+ if (preparedReference) {
3083
+ const inferred = inferReferenceInputSource(reference);
3084
+ return {
3085
+ input: referenceInput?.input ?? reference,
3086
+ kind: referenceInput?.kind ?? inferred.kind,
3087
+ resolved: referenceInput?.resolved ?? inferred.resolved,
3088
+ selector: null,
3089
+ transport: preparedReference.transport
3090
+ };
3091
+ }
3092
+ if (!referenceInput) {
3093
+ return inferReferenceInputSource(reference);
3094
+ }
3095
+ return {
3096
+ input: referenceInput.input,
3097
+ kind: referenceInput.kind,
3098
+ resolved: referenceInput.resolved,
3099
+ selector: null,
3100
+ transport: referenceInput.kind === "path" ? "path" : null
3101
+ };
3102
+ }
3103
+ function validateThresholdOrdering(options) {
3104
+ if (options.thresholdPass > options.thresholdTolerated) {
3105
+ throw new AppError("--threshold-pass must be less than or equal to --threshold-tolerated.");
3106
+ }
3107
+ if (options.thresholdTolerated > options.thresholdRetry) {
3108
+ throw new AppError("--threshold-tolerated must be less than or equal to --threshold-retry.");
3109
+ }
3110
+ }
3111
+ function exitCodeForRecommendation(recommendation) {
3112
+ switch (recommendation) {
3113
+ case "pass":
3114
+ case "pass_with_tolerated_differences":
3115
+ return 0;
3116
+ case "retry_fix":
3117
+ return 2;
3118
+ case "needs_human_review":
3119
+ return 3;
3120
+ default:
3121
+ return 1;
3122
+ }
3123
+ }
3124
+
3125
+ // src/types/report.ts
3126
+ var COMPARE_MODES = ["all", "pixel", "layout", "color"];
3127
+
3128
+ // src/cli/compare-command.ts
3129
+ function registerCompareCommand(program2) {
3130
+ program2.command("compare").description("Compare a preview screenshot or URL against a Figma reference or local image.").requiredOption("--preview <url|path>", "Preview URL or screenshot path").requiredOption("--reference <figma-url|path>", "Figma URL or local reference screenshot path").requiredOption("--output <dir>", "Output directory for report and generated artifacts").option(
3131
+ "--viewport <width|widthxheight>",
3132
+ "Viewport width or widthxheight used for preview URL capture, for example 1920 or 1920x900"
3133
+ ).option("--mode <mode>", "Analysis mode: all, pixel, layout, color", DEFAULT_MODE).option("--selector <css>", "CSS selector for preview element capture").option("--full-page", "Capture the full preview page when preview is a URL", false).option("--quiet", "Suppress the human-readable terminal summary", false).option(
3134
+ "--report-stdout",
3135
+ "Write compact report JSON to stdout and suppress the human-readable summary",
3136
+ false
3137
+ ).option(
3138
+ "--threshold-pass <number>",
3139
+ "Pass threshold in percent",
3140
+ parseFloat,
3141
+ DEFAULT_THRESHOLDS.pass
3142
+ ).option(
3143
+ "--threshold-tolerated <number>",
3144
+ "Tolerated threshold in percent",
3145
+ parseFloat,
3146
+ DEFAULT_THRESHOLDS.tolerated
3147
+ ).option(
3148
+ "--threshold-retry <number>",
3149
+ "Retry threshold in percent",
3150
+ parseFloat,
3151
+ DEFAULT_THRESHOLDS.retry
3152
+ ).action(async (rawOptions) => {
3153
+ const { quiet, reportStdout, ...options } = validateOptions(rawOptions);
3154
+ const result = await runCompare(options);
3155
+ writeCliOutput(result.report, { quiet, reportStdout });
3156
+ process.exitCode = result.exitCode;
3157
+ });
3158
+ }
3159
+ function validateOptions(options) {
3160
+ if (!COMPARE_MODES.includes(options.mode)) {
3161
+ throw new AppError(
3162
+ `Invalid --mode value "${options.mode}". Expected one of: ${COMPARE_MODES.join(", ")}.`
3163
+ );
3164
+ }
3165
+ for (const [label, value] of [
3166
+ ["threshold-pass", options.thresholdPass],
3167
+ ["threshold-tolerated", options.thresholdTolerated],
3168
+ ["threshold-retry", options.thresholdRetry]
3169
+ ]) {
3170
+ if (!Number.isFinite(value) || value < 0) {
3171
+ throw new AppError(`--${label} must be a non-negative number.`);
3172
+ }
3173
+ }
3174
+ return options;
3175
+ }
3176
+ function writeCliOutput(report, options) {
3177
+ if (options.reportStdout) {
3178
+ process.stdout.write(`${JSON.stringify(report)}
3179
+ `);
3180
+ return;
3181
+ }
3182
+ if (options.quiet) {
3183
+ return;
3184
+ }
3185
+ printSummary(report);
3186
+ }
3187
+ function printSummary(report) {
3188
+ console.log(`recommendation: ${report.summary.recommendation}`);
3189
+ console.log(`severity: ${report.summary.severity}`);
3190
+ console.log(`reason: ${report.summary.reason}`);
3191
+ console.log(`mismatchPercent: ${report.metrics.mismatchPercent.toFixed(4)}%`);
3192
+ console.log(`findings: ${report.findings.length}/${report.metrics.findingsCount}`);
3193
+ console.log(`output: ${path7.dirname(report.artifacts.report)}`);
3194
+ }
3195
+ function handleCliError(error) {
3196
+ if (isAppError(error)) {
3197
+ console.error(error.message);
3198
+ process.exitCode = error.exitCode;
3199
+ return;
3200
+ }
3201
+ if (error instanceof Error) {
3202
+ console.error(error.message);
3203
+ } else {
3204
+ console.error("Unknown error");
3205
+ }
3206
+ process.exitCode = 1;
3207
+ }
3208
+
3209
+ // src/cli/bin.ts
3210
+ var program = new Command();
3211
+ program.name("peye").version(package_default.version).description(
3212
+ "Standalone visual diff CLI for comparing preview screenshots against Figma references."
3213
+ ).configureHelp({
3214
+ sortOptions: true,
3215
+ sortSubcommands: true
3216
+ }).showHelpAfterError();
3217
+ registerCompareCommand(program);
3218
+ program.parseAsync(process.argv).catch(handleCliError);
3219
+ //# sourceMappingURL=bin.js.map