argusqa-os 9.5.0 → 9.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,685 @@
1
+ /**
2
+ * ARGUS Design Fidelity Analyzer (Sprint 2 — D9: Design Fidelity)
3
+ *
4
+ * Compares a live page's computed CSS against every property extracted by
5
+ * src/adapters/figma.js. Requires pre-fetched figmaData — analysis is skipped
6
+ * when figmaData is null (no figmaFrameUrl on the route).
7
+ *
8
+ * Selector strategy: each Figma node carries a `selectors` array of candidates
9
+ * (data-testid, aria-label, #id, .class). The in-page script tries each in
10
+ * order and uses the first that matches a DOM element. Falls back gracefully
11
+ * when no candidate matches — the node is silently skipped.
12
+ *
13
+ * Detections:
14
+ * design_token_mismatch — CSS custom property differs from Figma token
15
+ * design_component_missing — Figma component selector not found in DOM
16
+ * design_color_mismatch — Computed fill/text color deviates >5% RGB distance
17
+ * design_typography_mismatch — fontSize, fontWeight, lineHeight, fontFamily, or letterSpacing differs
18
+ * design_spacing_mismatch — Computed padding deviates from Figma Auto Layout by >2px
19
+ * design_radius_mismatch — Computed border-radius differs from Figma cornerRadius by >1px (per-corner)
20
+ * design_bounds_overflow — Element rect overflows Figma bounding box by >5px
21
+ * design_position_drift — Element absolute position deviates from Figma bounds x/y by >20px
22
+ * design_stroke_mismatch — Border color or width differs from Figma stroke
23
+ * design_shadow_mismatch — box-shadow offset, blur, spread, or color differs from Figma DROP_SHADOW
24
+ * design_opacity_mismatch — CSS opacity differs from Figma node opacity by >10%
25
+ * design_gap_mismatch — CSS column-gap/row-gap differs from Figma Auto Layout gap by >2px
26
+ * design_text_mismatch — DOM textContent differs from Figma characters string
27
+ * design_fidelity_summary — Aggregate counts for all mismatch types
28
+ */
29
+
30
+ import { registerExpensive } from '../registry.js';
31
+ import { unwrapEval } from './mcp-client.js';
32
+ import { childLogger } from './logger.js';
33
+
34
+ const logger = childLogger('design-fidelity');
35
+
36
+ // ── Comparison thresholds ─────────────────────────────────────────────────────
37
+ const COLOR_THRESHOLD = 22; // Euclidean RGB distance (~5% of 255√3 ≈ 441)
38
+ const SPACING_THRESHOLD = 2; // px padding deviation
39
+ const FONT_THRESHOLD = 1; // px font-size / line-height
40
+ const RADIUS_THRESHOLD = 1; // px border-radius (per corner)
41
+ const BOUNDS_TOLERANCE = 5; // px bounding-box overflow
42
+ const POSITION_DRIFT_THRESHOLD = 20; // px absolute position drift (scroll-corrected)
43
+ const OPACITY_THRESHOLD = 0.1; // ±10% opacity tolerance
44
+ const LETTER_SPACING_THRESHOLD = 0.5; // px
45
+ const BORDER_WEIGHT_THRESHOLD = 0.5; // px
46
+ const SHADOW_BLUR_THRESHOLD = 2; // px
47
+ const SHADOW_OFFSET_THRESHOLD = 1; // px
48
+ const SHADOW_SPREAD_THRESHOLD = 2; // px
49
+
50
+ // ── In-page comparison script ─────────────────────────────────────────────────
51
+ function buildFidelityScript(figmaData, thresholds) {
52
+ const figmaJson = JSON.stringify(figmaData);
53
+ const threshJson = JSON.stringify(thresholds);
54
+ return `() => {
55
+ var figma = ${figmaJson};
56
+ var THRESH = ${threshJson};
57
+ var result = {
58
+ tokenMismatches: [],
59
+ missingComponents: [],
60
+ colorMismatches: [],
61
+ typographyMismatches: [],
62
+ spacingMismatches: [],
63
+ radiusMismatches: [],
64
+ boundsOverflows: [],
65
+ positionDrifts: [],
66
+ strokeMismatches: [],
67
+ shadowMismatches: [],
68
+ opacityMismatches: [],
69
+ gapMismatches: [],
70
+ textMismatches: [],
71
+ };
72
+
73
+ var rootStyle = getComputedStyle(document.documentElement);
74
+
75
+ // ── Helpers ──────────────────────────────────────────────────────────────────
76
+
77
+ function parseRgb(str) {
78
+ var m = str && str.match(/rgba?\\((\\d+)[,\\s]+(\\d+)[,\\s]+(\\d+)/);
79
+ return m ? { r: +m[1], g: +m[2], b: +m[3] } : null;
80
+ }
81
+
82
+ function rgbDelta(a, b) {
83
+ var dr = a.r - b.r, dg = a.g - b.g, db = a.b - b.b;
84
+ return Math.sqrt(dr*dr + dg*dg + db*db);
85
+ }
86
+
87
+ function parsePx(str) {
88
+ var v = parseFloat(str);
89
+ return isNaN(v) ? null : v;
90
+ }
91
+
92
+ function isTransparentBg(str) {
93
+ if (!str || str.indexOf('rgba') === -1) return false;
94
+ var parts = str.split(',');
95
+ return parseFloat(parts[3]) === 0;
96
+ }
97
+
98
+ // Parses Chrome's computed box-shadow regardless of whether color leads or trails.
99
+ function parseBoxShadow(str) {
100
+ if (!str || str === 'none') return null;
101
+ var colorMatch = str.match(/rgba?\\([^)]+\\)/);
102
+ var rest = colorMatch ? str.replace(colorMatch[0], '').trim() : str.trim();
103
+ var nums = rest.match(/-?[\\d.]+px/g);
104
+ if (!nums || nums.length < 2) return null;
105
+ return {
106
+ colorStr: colorMatch ? colorMatch[0] : null,
107
+ offsetX: parseFloat(nums[0]),
108
+ offsetY: parseFloat(nums[1]),
109
+ blur: nums[2] ? parseFloat(nums[2]) : 0,
110
+ spread: nums[3] ? parseFloat(nums[3]) : 0,
111
+ };
112
+ }
113
+
114
+ // Normalise CSS fontFamily: '"Inter", sans-serif' → 'inter'
115
+ function parseFontFamily(str) {
116
+ if (!str) return '';
117
+ return str.split(',')[0].trim().replace(/^["']|["']$/g, '').toLowerCase();
118
+ }
119
+
120
+ // Try each selector candidate in order; return { el, sel } for first match.
121
+ function findElementWithSelector(candidates) {
122
+ for (var i = 0; i < candidates.length; i++) {
123
+ try {
124
+ var found = document.querySelector(candidates[i]);
125
+ if (found) return { el: found, sel: candidates[i] };
126
+ } catch(e) { /* invalid selector, skip */ }
127
+ }
128
+ return null;
129
+ }
130
+
131
+ // ── 1. CSS custom property tokens (legacy) ────────────────────────────────
132
+
133
+ var tokens = figma.tokens || {};
134
+ for (var name in tokens) {
135
+ if (!Object.prototype.hasOwnProperty.call(tokens, name)) continue;
136
+ var expected = String(tokens[name]).trim();
137
+ var actual = rootStyle.getPropertyValue(name).trim();
138
+ if (!actual) continue;
139
+ if (actual.toLowerCase() !== expected.toLowerCase()) {
140
+ result.tokenMismatches.push({ token: name, expected: expected, actual: actual });
141
+ }
142
+ }
143
+
144
+ // ── 2. Component presence (legacy) ───────────────────────────────────────
145
+
146
+ var components = figma.components || [];
147
+ for (var ci = 0; ci < components.length; ci++) {
148
+ var comp = components[ci];
149
+ if (!document.querySelector(comp.selector)) {
150
+ result.missingComponents.push({ name: comp.name, selector: comp.selector });
151
+ }
152
+ }
153
+
154
+ // ── 3. Per-node rich property comparison ─────────────────────────────────
155
+
156
+ var nodes = figma.nodes || [];
157
+ for (var ni = 0; ni < nodes.length; ni++) {
158
+ var node = nodes[ni];
159
+
160
+ // Try selector candidates in order (data-testid → aria-label → #id → .class)
161
+ var candidates = node.selectors || (node.selector ? [node.selector] : []);
162
+ var match = findElementWithSelector(candidates);
163
+ if (!match) continue;
164
+
165
+ var el = match.el;
166
+ var sel = match.sel; // the selector that actually matched
167
+ var cs = getComputedStyle(el);
168
+
169
+ // Color — fill maps to color (TEXT) or backgroundColor (others).
170
+ if (node.fill) {
171
+ var isText = node.type === 'TEXT';
172
+ var colorStr = isText ? cs.color : cs.backgroundColor;
173
+ if (!(!isText && isTransparentBg(colorStr))) {
174
+ var domRgb = parseRgb(colorStr);
175
+ if (domRgb) {
176
+ var dist = rgbDelta(node.fill, domRgb);
177
+ if (dist > THRESH.color) {
178
+ result.colorMismatches.push({
179
+ selector: sel, name: node.name,
180
+ property: isText ? 'color' : 'backgroundColor',
181
+ expected: 'rgb(' + node.fill.r + ',' + node.fill.g + ',' + node.fill.b + ')',
182
+ actual: colorStr.replace(/\\s/g, ''),
183
+ delta: Math.round(dist),
184
+ });
185
+ }
186
+ }
187
+ }
188
+ }
189
+
190
+ // Typography — fontSize, fontWeight, lineHeight, fontFamily, letterSpacing.
191
+ if (node.typography) {
192
+ var typo = node.typography;
193
+
194
+ if (typo.fontSize != null) {
195
+ var domFs = parsePx(cs.fontSize);
196
+ if (domFs !== null && Math.abs(domFs - typo.fontSize) > THRESH.font) {
197
+ result.typographyMismatches.push({
198
+ selector: sel, name: node.name,
199
+ property: 'fontSize', expected: typo.fontSize, actual: domFs,
200
+ });
201
+ }
202
+ }
203
+
204
+ if (typo.fontWeight != null) {
205
+ var domFw = parsePx(cs.fontWeight);
206
+ if (domFw !== null && Math.abs(domFw - typo.fontWeight) > 0) {
207
+ result.typographyMismatches.push({
208
+ selector: sel, name: node.name,
209
+ property: 'fontWeight', expected: typo.fontWeight, actual: domFw,
210
+ });
211
+ }
212
+ }
213
+
214
+ if (typo.lineHeightPx != null) {
215
+ var domLh = parsePx(cs.lineHeight);
216
+ if (domLh !== null && Math.abs(domLh - typo.lineHeightPx) > THRESH.font) {
217
+ result.typographyMismatches.push({
218
+ selector: sel, name: node.name,
219
+ property: 'lineHeight', expected: typo.lineHeightPx, actual: domLh,
220
+ });
221
+ }
222
+ }
223
+
224
+ if (typo.fontFamily) {
225
+ var domFamily = parseFontFamily(cs.fontFamily);
226
+ var figmaFamily = typo.fontFamily.toLowerCase();
227
+ if (domFamily && domFamily !== figmaFamily) {
228
+ result.typographyMismatches.push({
229
+ selector: sel, name: node.name,
230
+ property: 'fontFamily',
231
+ expected: typo.fontFamily,
232
+ actual: cs.fontFamily.split(',')[0].trim(),
233
+ });
234
+ }
235
+ }
236
+
237
+ if (typo.letterSpacing != null && typo.letterSpacing !== 0) {
238
+ var domLs = parsePx(cs.letterSpacing);
239
+ if (domLs !== null && Math.abs(domLs - typo.letterSpacing) > THRESH.letterSpacing) {
240
+ result.typographyMismatches.push({
241
+ selector: sel, name: node.name,
242
+ property: 'letterSpacing', expected: typo.letterSpacing, actual: domLs,
243
+ });
244
+ }
245
+ }
246
+ }
247
+
248
+ // Spacing — Auto Layout padding + gap.
249
+ if (node.spacing) {
250
+ var sp = node.spacing;
251
+ var sides = ['paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft'];
252
+ for (var si = 0; si < sides.length; si++) {
253
+ var side = sides[si];
254
+ if (sp[side] == null) continue;
255
+ var domPad = parsePx(cs[side]);
256
+ if (domPad !== null && Math.abs(domPad - sp[side]) > THRESH.spacing) {
257
+ result.spacingMismatches.push({
258
+ selector: sel, name: node.name,
259
+ property: side, expected: sp[side], actual: domPad,
260
+ });
261
+ }
262
+ }
263
+
264
+ // Gap — only when Figma explicitly sets a positive gap.
265
+ if (sp.gap > 0) {
266
+ var gapProp = sp.layoutMode === 'HORIZONTAL' ? 'columnGap' :
267
+ sp.layoutMode === 'VERTICAL' ? 'rowGap' : 'columnGap';
268
+ var domGapStr = cs[gapProp];
269
+ if (!domGapStr || domGapStr === 'normal') domGapStr = cs.gap;
270
+ var domGap = parsePx(domGapStr);
271
+ if (domGap !== null && Math.abs(domGap - sp.gap) > THRESH.spacing) {
272
+ result.gapMismatches.push({
273
+ selector: sel, name: node.name,
274
+ property: gapProp, expected: sp.gap, actual: domGap,
275
+ });
276
+ }
277
+ }
278
+ }
279
+
280
+ // Corner radius — uniform (number) or per-corner (object with topLeft/topRight/bottomRight/bottomLeft).
281
+ if (node.cornerRadius != null) {
282
+ if (typeof node.cornerRadius === 'number') {
283
+ var domRad = parsePx(cs.borderRadius);
284
+ if (domRad !== null && Math.abs(domRad - node.cornerRadius) > THRESH.radius) {
285
+ result.radiusMismatches.push({
286
+ selector: sel, name: node.name,
287
+ corner: 'all', expected: node.cornerRadius, actual: domRad,
288
+ });
289
+ }
290
+ } else {
291
+ var rcorners = [
292
+ { figma: 'topLeft', css: 'borderTopLeftRadius' },
293
+ { figma: 'topRight', css: 'borderTopRightRadius' },
294
+ { figma: 'bottomRight', css: 'borderBottomRightRadius' },
295
+ { figma: 'bottomLeft', css: 'borderBottomLeftRadius' },
296
+ ];
297
+ for (var rci = 0; rci < rcorners.length; rci++) {
298
+ var rc = rcorners[rci];
299
+ var expRad = node.cornerRadius[rc.figma];
300
+ if (expRad == null) continue;
301
+ var domRad = parsePx(cs[rc.css]);
302
+ if (domRad !== null && Math.abs(domRad - expRad) > THRESH.radius) {
303
+ result.radiusMismatches.push({
304
+ selector: sel, name: node.name,
305
+ corner: rc.figma, expected: expRad, actual: domRad,
306
+ });
307
+ }
308
+ }
309
+ }
310
+ }
311
+
312
+ // Bounds — overflow (size) + position drift (x/y).
313
+ if (node.bounds) {
314
+ var rect = el.getBoundingClientRect();
315
+ var b = node.bounds;
316
+
317
+ // Overflow: element must not be larger than its Figma bounding box.
318
+ if ((rect.width - b.width) > THRESH.bounds || (rect.height - b.height) > THRESH.bounds) {
319
+ result.boundsOverflows.push({
320
+ selector: sel, name: node.name,
321
+ expectedWidth: b.width, expectedHeight: b.height,
322
+ actualWidth: Math.round(rect.width), actualHeight: Math.round(rect.height),
323
+ });
324
+ }
325
+
326
+ // Position drift: scroll-corrected absolute position vs Figma frame-relative x/y.
327
+ // Works best when the page matches the Figma frame width and is scrolled to top.
328
+ var scrollX = window.pageXOffset || document.documentElement.scrollLeft || 0;
329
+ var scrollY = window.pageYOffset || document.documentElement.scrollTop || 0;
330
+ var absLeft = Math.round(rect.left + scrollX);
331
+ var absTop = Math.round(rect.top + scrollY);
332
+ var driftX = Math.abs(absLeft - b.x);
333
+ var driftY = Math.abs(absTop - b.y);
334
+ if (driftX > THRESH.positionDrift || driftY > THRESH.positionDrift) {
335
+ result.positionDrifts.push({
336
+ selector: sel, name: node.name,
337
+ expectedX: b.x, expectedY: b.y,
338
+ actualX: absLeft, actualY: absTop,
339
+ driftX: Math.round(driftX), driftY: Math.round(driftY),
340
+ });
341
+ }
342
+ }
343
+
344
+ // Stroke — border color (borderTopColor) + weight (borderTopWidth).
345
+ if (node.stroke) {
346
+ var domBW = parsePx(cs.borderTopWidth);
347
+ var weightOk = domBW !== null && Math.abs((domBW || 0) - node.stroke.weight) <= THRESH.borderWeight;
348
+ var colorOk = true;
349
+ var colorDlt = null;
350
+ if (domBW > 0) {
351
+ var domBorderRgb = parseRgb(cs.borderTopColor);
352
+ if (domBorderRgb) {
353
+ colorDlt = rgbDelta(node.stroke, domBorderRgb);
354
+ colorOk = colorDlt <= THRESH.color;
355
+ }
356
+ } else if (domBW === 0 || domBW === null) {
357
+ colorOk = false;
358
+ }
359
+ if (!weightOk || !colorOk) {
360
+ result.strokeMismatches.push({
361
+ selector: sel, name: node.name,
362
+ expectedColor: 'rgb(' + node.stroke.r + ',' + node.stroke.g + ',' + node.stroke.b + ')',
363
+ actualColor: domBW > 0 ? cs.borderTopColor.replace(/\\s/g, '') : 'none',
364
+ colorDelta: colorDlt !== null ? Math.round(colorDlt) : null,
365
+ expectedWeight: node.stroke.weight,
366
+ actualWeight: domBW,
367
+ });
368
+ }
369
+ }
370
+
371
+ // Shadow — offsetX/Y, blur, spread, AND color all compared.
372
+ if (node.shadow) {
373
+ var bsStr = cs.boxShadow;
374
+ if (!bsStr || bsStr === 'none') {
375
+ result.shadowMismatches.push({
376
+ selector: sel, name: node.name,
377
+ expectedOffsetX: node.shadow.offsetX, expectedOffsetY: node.shadow.offsetY,
378
+ expectedBlur: node.shadow.blur, expectedSpread: node.shadow.spread,
379
+ expectedColor: 'rgb(' + node.shadow.r + ',' + node.shadow.g + ',' + node.shadow.b + ')',
380
+ actualOffsetX: 0, actualOffsetY: 0, actualBlur: 0, actualSpread: 0,
381
+ actualColor: 'none', colorDelta: null, reason: 'no-shadow',
382
+ });
383
+ } else {
384
+ var domShadow = parseBoxShadow(bsStr);
385
+ if (domShadow) {
386
+ var xDiff = Math.abs(domShadow.offsetX - node.shadow.offsetX);
387
+ var yDiff = Math.abs(domShadow.offsetY - node.shadow.offsetY);
388
+ var bDiff = Math.abs(domShadow.blur - node.shadow.blur);
389
+ var sDiff = Math.abs(domShadow.spread - node.shadow.spread);
390
+ var sColorDist = null;
391
+ var domShadowRgb = parseRgb(domShadow.colorStr);
392
+ if (domShadowRgb) sColorDist = rgbDelta(node.shadow, domShadowRgb);
393
+
394
+ if (xDiff > THRESH.shadowOffset || yDiff > THRESH.shadowOffset ||
395
+ bDiff > THRESH.shadowBlur || sDiff > THRESH.shadowSpread ||
396
+ (sColorDist !== null && sColorDist > THRESH.color)) {
397
+ result.shadowMismatches.push({
398
+ selector: sel, name: node.name,
399
+ expectedOffsetX: node.shadow.offsetX, expectedOffsetY: node.shadow.offsetY,
400
+ expectedBlur: node.shadow.blur, expectedSpread: node.shadow.spread,
401
+ expectedColor: 'rgb(' + node.shadow.r + ',' + node.shadow.g + ',' + node.shadow.b + ')',
402
+ actualOffsetX: domShadow.offsetX, actualOffsetY: domShadow.offsetY,
403
+ actualBlur: domShadow.blur, actualSpread: domShadow.spread,
404
+ actualColor: domShadow.colorStr || 'unknown',
405
+ colorDelta: sColorDist !== null ? Math.round(sColorDist) : null,
406
+ reason: 'values-differ',
407
+ });
408
+ }
409
+ }
410
+ }
411
+ }
412
+
413
+ // Opacity — only compared when Figma explicitly sets opacity < 100%.
414
+ if (node.opacity != null && node.opacity < 0.99) {
415
+ var domOp = parseFloat(cs.opacity);
416
+ if (!isNaN(domOp) && Math.abs(domOp - node.opacity) > THRESH.opacity) {
417
+ result.opacityMismatches.push({
418
+ selector: sel, name: node.name,
419
+ expected: node.opacity, actual: domOp,
420
+ });
421
+ }
422
+ }
423
+
424
+ // Text content — only when Figma characters string is present.
425
+ if (node.characters) {
426
+ var domText = (el.textContent || '').trim().replace(/\\s+/g, ' ');
427
+ var figmaText = node.characters.replace(/\\s+/g, ' ');
428
+ if (domText !== figmaText) {
429
+ result.textMismatches.push({
430
+ selector: sel, name: node.name,
431
+ expected: figmaText, actual: domText,
432
+ });
433
+ }
434
+ }
435
+ }
436
+
437
+ return JSON.stringify(result);
438
+ }`;
439
+ }
440
+
441
+ // ── JSON parse helper ─────────────────────────────────────────────────────────
442
+ function parseJson(raw) {
443
+ try {
444
+ const str = unwrapEval(raw);
445
+ if (typeof str === 'object' && str !== null) return str;
446
+ return JSON.parse(str);
447
+ } catch {
448
+ return null;
449
+ }
450
+ }
451
+
452
+ // ── Public API ────────────────────────────────────────────────────────────────
453
+
454
+ /**
455
+ * Analyse design-to-implementation fidelity for a single page.
456
+ *
457
+ * @param {object} browser - CdpBrowserAdapter
458
+ * @param {string} url - Fully-qualified URL to analyse
459
+ * @param {object|null} figmaData - { tokens, components, nodes, frame } from figma-adapter
460
+ * @returns {Promise<object[]>} Array of design fidelity finding objects
461
+ */
462
+ export async function analyzeDesignFidelity(browser, url, figmaData) {
463
+ if (!figmaData) return [];
464
+
465
+ const findings = [];
466
+
467
+ try {
468
+ await browser.navigate(url);
469
+ await browser.waitFor({ state: 'networkidle' }).catch(() => {});
470
+ await new Promise(r => setTimeout(r, 400));
471
+ } catch {
472
+ return findings;
473
+ }
474
+
475
+ const thresholds = {
476
+ color: COLOR_THRESHOLD,
477
+ spacing: SPACING_THRESHOLD,
478
+ font: FONT_THRESHOLD,
479
+ radius: RADIUS_THRESHOLD,
480
+ bounds: BOUNDS_TOLERANCE,
481
+ positionDrift: POSITION_DRIFT_THRESHOLD,
482
+ opacity: OPACITY_THRESHOLD,
483
+ letterSpacing: LETTER_SPACING_THRESHOLD,
484
+ borderWeight: BORDER_WEIGHT_THRESHOLD,
485
+ shadowBlur: SHADOW_BLUR_THRESHOLD,
486
+ shadowOffset: SHADOW_OFFSET_THRESHOLD,
487
+ shadowSpread: SHADOW_SPREAD_THRESHOLD,
488
+ };
489
+
490
+ let result;
491
+ try {
492
+ const script = buildFidelityScript(figmaData, thresholds);
493
+ const raw = await browser.evaluate(script);
494
+ result = parseJson(raw);
495
+ } catch (err) {
496
+ logger.warn(`[ARGUS] design-fidelity: comparison script failed for ${url}: ${err.message}`);
497
+ return findings;
498
+ }
499
+ if (!result) return findings;
500
+
501
+ const {
502
+ tokenMismatches = [],
503
+ missingComponents = [],
504
+ colorMismatches = [],
505
+ typographyMismatches = [],
506
+ spacingMismatches = [],
507
+ radiusMismatches = [],
508
+ boundsOverflows = [],
509
+ positionDrifts = [],
510
+ strokeMismatches = [],
511
+ shadowMismatches = [],
512
+ opacityMismatches = [],
513
+ gapMismatches = [],
514
+ textMismatches = [],
515
+ } = result;
516
+
517
+ for (const { token, expected, actual } of tokenMismatches) {
518
+ findings.push({
519
+ type: 'design_token_mismatch', token, expected, actual,
520
+ message: `Design token mismatch: "${token}" is "${actual}" but Figma specifies "${expected}"`,
521
+ severity: 'warning', url,
522
+ });
523
+ }
524
+
525
+ for (const { name, selector } of missingComponents) {
526
+ findings.push({
527
+ type: 'design_component_missing', component: name, selector,
528
+ message: `Figma component "${name}" (selector: "${selector}") not found in DOM`,
529
+ severity: 'warning', url,
530
+ });
531
+ }
532
+
533
+ for (const { selector, name, property, expected, actual, delta } of colorMismatches) {
534
+ findings.push({
535
+ type: 'design_color_mismatch', selector, component: name,
536
+ property, expected, actual, delta,
537
+ message: `Color mismatch on "${name}" (${selector}): ${property} is "${actual}" but Figma specifies "${expected}" (RGB delta: ${delta})`,
538
+ severity: 'warning', url,
539
+ });
540
+ }
541
+
542
+ for (const { selector, name, property, expected, actual } of typographyMismatches) {
543
+ const unit = (property === 'fontSize' || property === 'lineHeight' || property === 'letterSpacing') ? 'px' : '';
544
+ findings.push({
545
+ type: 'design_typography_mismatch', selector, component: name,
546
+ property, expected, actual,
547
+ message: `Typography mismatch on "${name}" (${selector}): ${property} is ${actual}${unit} but Figma specifies ${expected}${unit}`,
548
+ severity: 'warning', url,
549
+ });
550
+ }
551
+
552
+ for (const { selector, name, property, expected, actual } of spacingMismatches) {
553
+ findings.push({
554
+ type: 'design_spacing_mismatch', selector, component: name,
555
+ property, expected, actual,
556
+ message: `Spacing mismatch on "${name}" (${selector}): ${property} is ${actual}px but Figma specifies ${expected}px`,
557
+ severity: 'warning', url,
558
+ });
559
+ }
560
+
561
+ for (const { selector, name, corner, expected, actual } of radiusMismatches) {
562
+ const cornerLabel = corner === 'all' ? '' : ` (${corner})`;
563
+ findings.push({
564
+ type: 'design_radius_mismatch', selector, component: name,
565
+ corner, expected, actual,
566
+ message: `Corner radius mismatch on "${name}" (${selector})${cornerLabel}: border-radius is ${actual}px but Figma specifies ${expected}px`,
567
+ severity: 'warning', url,
568
+ });
569
+ }
570
+
571
+ for (const { selector, name, expectedWidth, expectedHeight, actualWidth, actualHeight } of boundsOverflows) {
572
+ findings.push({
573
+ type: 'design_bounds_overflow', selector, component: name,
574
+ expectedWidth, expectedHeight, actualWidth, actualHeight,
575
+ message: `Bounds overflow on "${name}" (${selector}): element is ${actualWidth}×${actualHeight}px but Figma specifies ${expectedWidth}×${expectedHeight}px`,
576
+ severity: 'warning', url,
577
+ });
578
+ }
579
+
580
+ for (const { selector, name, expectedX, expectedY, actualX, actualY, driftX, driftY } of positionDrifts) {
581
+ findings.push({
582
+ type: 'design_position_drift', selector, component: name,
583
+ expectedX, expectedY, actualX, actualY, driftX, driftY,
584
+ message: `Position drift on "${name}" (${selector}): element is at (${actualX},${actualY}) but Figma specifies (${expectedX},${expectedY}) — drift (${driftX}px, ${driftY}px)`,
585
+ severity: 'warning', url,
586
+ });
587
+ }
588
+
589
+ for (const { selector, name, expectedColor, actualColor, colorDelta, expectedWeight, actualWeight } of strokeMismatches) {
590
+ findings.push({
591
+ type: 'design_stroke_mismatch', selector, component: name,
592
+ expectedColor, actualColor, colorDelta, expectedWeight, actualWeight,
593
+ message: `Stroke mismatch on "${name}" (${selector}): border is ${actualWeight}px "${actualColor}" but Figma specifies ${expectedWeight}px "${expectedColor}"`,
594
+ severity: 'warning', url,
595
+ });
596
+ }
597
+
598
+ for (const { selector, name, expectedOffsetX, expectedOffsetY, expectedBlur, expectedSpread, expectedColor, actualOffsetX, actualOffsetY, actualBlur, actualSpread, actualColor, colorDelta, reason } of shadowMismatches) {
599
+ const desc = reason === 'no-shadow'
600
+ ? 'no box-shadow in DOM'
601
+ : `box-shadow is ${actualOffsetX}px ${actualOffsetY}px blur:${actualBlur}px spread:${actualSpread}px color:${actualColor}`;
602
+ findings.push({
603
+ type: 'design_shadow_mismatch', selector, component: name,
604
+ expectedOffsetX, expectedOffsetY, expectedBlur, expectedSpread, expectedColor,
605
+ actualOffsetX, actualOffsetY, actualBlur, actualSpread, actualColor, colorDelta,
606
+ message: `Shadow mismatch on "${name}" (${selector}): ${desc} but Figma specifies ${expectedOffsetX}px ${expectedOffsetY}px blur:${expectedBlur}px spread:${expectedSpread}px color:${expectedColor}`,
607
+ severity: 'warning', url,
608
+ });
609
+ }
610
+
611
+ for (const { selector, name, expected, actual } of opacityMismatches) {
612
+ findings.push({
613
+ type: 'design_opacity_mismatch', selector, component: name,
614
+ expected, actual,
615
+ message: `Opacity mismatch on "${name}" (${selector}): opacity is ${actual} but Figma specifies ${expected}`,
616
+ severity: 'warning', url,
617
+ });
618
+ }
619
+
620
+ for (const { selector, name, property, expected, actual } of gapMismatches) {
621
+ findings.push({
622
+ type: 'design_gap_mismatch', selector, component: name,
623
+ property, expected, actual,
624
+ message: `Gap mismatch on "${name}" (${selector}): ${property} is ${actual}px but Figma specifies ${expected}px`,
625
+ severity: 'warning', url,
626
+ });
627
+ }
628
+
629
+ for (const { selector, name, expected, actual } of textMismatches) {
630
+ findings.push({
631
+ type: 'design_text_mismatch', selector, component: name,
632
+ expected, actual,
633
+ message: `Text content mismatch on "${name}" (${selector}): DOM text is "${actual}" but Figma specifies "${expected}"`,
634
+ severity: 'warning', url,
635
+ });
636
+ }
637
+
638
+ // design_fidelity_summary — always emitted when figmaData is present
639
+ findings.push({
640
+ type: 'design_fidelity_summary',
641
+ tokenMismatches: tokenMismatches.length,
642
+ missingComponents: missingComponents.length,
643
+ colorMismatches: colorMismatches.length,
644
+ typographyMismatches: typographyMismatches.length,
645
+ spacingMismatches: spacingMismatches.length,
646
+ radiusMismatches: radiusMismatches.length,
647
+ boundsOverflows: boundsOverflows.length,
648
+ positionDrifts: positionDrifts.length,
649
+ strokeMismatches: strokeMismatches.length,
650
+ shadowMismatches: shadowMismatches.length,
651
+ opacityMismatches: opacityMismatches.length,
652
+ gapMismatches: gapMismatches.length,
653
+ textMismatches: textMismatches.length,
654
+ frameName: figmaData.frame?.name ?? '',
655
+ message: [
656
+ `Design fidelity:`,
657
+ `${tokenMismatches.length} token,`,
658
+ `${missingComponents.length} missing component,`,
659
+ `${colorMismatches.length} color,`,
660
+ `${typographyMismatches.length} typography,`,
661
+ `${spacingMismatches.length} spacing,`,
662
+ `${radiusMismatches.length} radius,`,
663
+ `${boundsOverflows.length} bounds,`,
664
+ `${positionDrifts.length} position,`,
665
+ `${strokeMismatches.length} stroke,`,
666
+ `${shadowMismatches.length} shadow,`,
667
+ `${opacityMismatches.length} opacity,`,
668
+ `${gapMismatches.length} gap,`,
669
+ `${textMismatches.length} text mismatch(es)`,
670
+ ].join(' '),
671
+ severity: 'info',
672
+ url,
673
+ });
674
+
675
+ return findings;
676
+ }
677
+
678
+ // ── Self-registration ─────────────────────────────────────────────────────────
679
+ registerExpensive({
680
+ name: 'design-fidelity',
681
+ analyze: (browser, url, route) => {
682
+ if (!route?.figmaData) return [];
683
+ return analyzeDesignFidelity(browser, url, route.figmaData);
684
+ },
685
+ });
@@ -362,6 +362,8 @@ export async function runFlow(flow, baseUrl, browser) {
362
362
 
363
363
  case 'click': {
364
364
  // MCP click requires uid — resolve CSS selector to uid via snapshot.
365
+ // click is NOT retried (not idempotent — submits forms, triggers deletions).
366
+ // Add a preceding waitFor step in your flow config to ensure the target is ready.
365
367
  const clickUid = await resolveUidForSelector(browser, step.selector);
366
368
  if (!clickUid) throw new Error(`click: no uid found for selector "${step.selector}"`);
367
369
  await browser.click(clickUid);