@impeccable/detect 2.0.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,2548 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Anti-Pattern Detector for Impeccable
5
+ *
6
+ * Universal file — auto-detects environment (browser vs Node) and adapts.
7
+ *
8
+ * Node usage:
9
+ * node detect-antipatterns.mjs [file-or-dir...] # jsdom for HTML, regex for rest
10
+ * node detect-antipatterns.mjs https://... # Puppeteer (auto)
11
+ * node detect-antipatterns.mjs --fast [files...] # regex-only (skip jsdom)
12
+ * node detect-antipatterns.mjs --json # JSON output
13
+ *
14
+ * Browser usage:
15
+ * <script src="detect-antipatterns-browser.js"></script>
16
+ * Re-scan: window.impeccableScan()
17
+ *
18
+ * Exit codes: 0 = clean, 2 = findings
19
+ */
20
+
21
+ // ─── Environment ────────────────────────────────────────────────────────────
22
+
23
+ const IS_BROWSER = typeof window !== 'undefined';
24
+ const IS_NODE = !IS_BROWSER;
25
+
26
+ // @browser-strip-start
27
+ let fs, path;
28
+ if (!IS_BROWSER) {
29
+ fs = (await import('node:fs')).default;
30
+ path = (await import('node:path')).default;
31
+ }
32
+ // @browser-strip-end
33
+
34
+ // ─── Section 1: Constants ───────────────────────────────────────────────────
35
+
36
+ const SAFE_TAGS = new Set([
37
+ 'blockquote', 'nav', 'a', 'input', 'textarea', 'select',
38
+ 'pre', 'code', 'span', 'th', 'td', 'tr', 'li', 'label',
39
+ 'button', 'hr', 'html', 'head', 'body', 'script', 'style',
40
+ 'link', 'meta', 'title', 'br', 'img', 'svg', 'path', 'circle',
41
+ 'rect', 'line', 'polyline', 'polygon', 'g', 'defs', 'use',
42
+ ]);
43
+
44
+ const OVERUSED_FONTS = new Set([
45
+ 'inter', 'roboto', 'open sans', 'lato', 'montserrat', 'arial', 'helvetica',
46
+ ]);
47
+
48
+ const GENERIC_FONTS = new Set([
49
+ 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy',
50
+ 'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
51
+ '-apple-system', 'blinkmacsystemfont', 'segoe ui',
52
+ 'inherit', 'initial', 'unset', 'revert',
53
+ ]);
54
+
55
+ const ANTIPATTERNS = [
56
+ {
57
+ id: 'side-tab',
58
+ name: 'Side-tab accent border',
59
+ description:
60
+ 'Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.',
61
+ },
62
+ {
63
+ id: 'border-accent-on-rounded',
64
+ name: 'Border accent on rounded element',
65
+ description:
66
+ 'Thick accent border on a rounded card — the border clashes with the rounded corners. Remove the border or the border-radius.',
67
+ },
68
+ {
69
+ id: 'overused-font',
70
+ name: 'Overused font',
71
+ description:
72
+ 'Inter, Roboto, Open Sans, Lato, Montserrat, and Arial are used on millions of sites. Choose a distinctive font that gives your interface personality.',
73
+ },
74
+ {
75
+ id: 'single-font',
76
+ name: 'Single font for everything',
77
+ description:
78
+ 'Only one font family is used for the entire page. Pair a distinctive display font with a refined body font to create typographic hierarchy.',
79
+ },
80
+ {
81
+ id: 'flat-type-hierarchy',
82
+ name: 'Flat type hierarchy',
83
+ description:
84
+ 'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).',
85
+ },
86
+ {
87
+ id: 'pure-black-white',
88
+ name: 'Pure black background',
89
+ description:
90
+ 'Pure #000000 as a background color looks harsh and unnatural. Tint it slightly toward your brand hue (e.g., oklch(12% 0.01 250)) for a more refined feel.',
91
+ },
92
+ {
93
+ id: 'gray-on-color',
94
+ name: 'Gray text on colored background',
95
+ description:
96
+ 'Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.',
97
+ },
98
+ {
99
+ id: 'low-contrast',
100
+ name: 'Low contrast text',
101
+ description:
102
+ 'Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.',
103
+ },
104
+ {
105
+ id: 'gradient-text',
106
+ name: 'Gradient text',
107
+ description:
108
+ 'Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.',
109
+ },
110
+ {
111
+ id: 'ai-color-palette',
112
+ name: 'AI color palette',
113
+ description:
114
+ 'Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.',
115
+ },
116
+ {
117
+ id: 'nested-cards',
118
+ name: 'Nested cards',
119
+ description:
120
+ 'Cards inside cards create visual noise and excessive depth. Flatten the hierarchy — use spacing, typography, and dividers instead of nesting containers.',
121
+ },
122
+ {
123
+ id: 'monotonous-spacing',
124
+ name: 'Monotonous spacing',
125
+ description:
126
+ 'The same spacing value used everywhere — no rhythm, no variation. Use tight groupings for related items and generous separations between sections.',
127
+ },
128
+ {
129
+ id: 'everything-centered',
130
+ name: 'Everything centered',
131
+ description:
132
+ 'Every text element is center-aligned. Left-aligned text with asymmetric layouts feels more designed. Center only hero sections and CTAs.',
133
+ },
134
+ {
135
+ id: 'bounce-easing',
136
+ name: 'Bounce or elastic easing',
137
+ description:
138
+ 'Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.',
139
+ },
140
+ {
141
+ id: 'layout-transition',
142
+ name: 'Layout property animation',
143
+ description:
144
+ 'Animating width, height, padding, or margin causes layout thrash and janky performance. Use transform and opacity instead, or grid-template-rows for height animations.',
145
+ },
146
+ {
147
+ id: 'dark-glow',
148
+ name: 'Dark mode with glowing accents',
149
+ description:
150
+ 'Dark backgrounds with colored box-shadow glows are the default "cool" look of AI-generated UIs. Use subtle, purposeful lighting instead — or skip the dark theme entirely.',
151
+ },
152
+ {
153
+ id: 'line-length',
154
+ name: 'Line length too long',
155
+ description:
156
+ 'Text lines wider than ~80 characters are hard to read. The eye loses its place tracking back to the start of the next line. Add a max-width (65ch to 75ch) to text containers.',
157
+ },
158
+ {
159
+ id: 'cramped-padding',
160
+ name: 'Cramped padding',
161
+ description:
162
+ 'Text is too close to the edge of its container. Add at least 8px (ideally 12-16px) of padding inside bordered or colored containers.',
163
+ },
164
+ {
165
+ id: 'tight-leading',
166
+ name: 'Tight line height',
167
+ description:
168
+ 'Line height below 1.3x the font size makes multi-line text hard to read. Use 1.5 to 1.7 for body text so lines have room to breathe.',
169
+ },
170
+ {
171
+ id: 'skipped-heading',
172
+ name: 'Skipped heading level',
173
+ description:
174
+ 'Heading levels should not skip (e.g. h1 then h3 with no h2). Screen readers use heading hierarchy for navigation. Skipping levels breaks the document outline.',
175
+ },
176
+ {
177
+ id: 'justified-text',
178
+ name: 'Justified text',
179
+ description:
180
+ 'Justified text without hyphenation creates uneven word spacing ("rivers of white"). Use text-align: left for body text, or enable hyphens: auto if you must justify.',
181
+ },
182
+ {
183
+ id: 'tiny-text',
184
+ name: 'Tiny body text',
185
+ description:
186
+ 'Body text below 12px is hard to read, especially on high-DPI screens. Use at least 14px for body content, 16px is ideal.',
187
+ },
188
+ {
189
+ id: 'all-caps-body',
190
+ name: 'All-caps body text',
191
+ description:
192
+ 'Long passages in uppercase are hard to read. We recognize words by shape (ascenders and descenders), which all-caps removes. Reserve uppercase for short labels and headings.',
193
+ },
194
+ {
195
+ id: 'wide-tracking',
196
+ name: 'Wide letter spacing on body text',
197
+ description:
198
+ 'Letter spacing above 0.05em on body text disrupts natural character groupings and slows reading. Reserve wide tracking for short uppercase labels only.',
199
+ },
200
+ ];
201
+
202
+ // ─── Section 2: Color Utilities ─────────────────────────────────────────────
203
+
204
+ function isNeutralColor(color) {
205
+ if (!color || color === 'transparent') return true;
206
+ const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
207
+ if (!m) return true;
208
+ return (Math.max(+m[1], +m[2], +m[3]) - Math.min(+m[1], +m[2], +m[3])) < 30;
209
+ }
210
+
211
+ function parseRgb(color) {
212
+ if (!color || color === 'transparent') return null;
213
+ const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
214
+ if (!m) return null;
215
+ return { r: +m[1], g: +m[2], b: +m[3], a: m[4] !== undefined ? +m[4] : 1 };
216
+ }
217
+
218
+ function relativeLuminance({ r, g, b }) {
219
+ const [rs, gs, bs] = [r / 255, g / 255, b / 255].map(c =>
220
+ c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
221
+ );
222
+ return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
223
+ }
224
+
225
+ function contrastRatio(c1, c2) {
226
+ const l1 = relativeLuminance(c1);
227
+ const l2 = relativeLuminance(c2);
228
+ return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
229
+ }
230
+
231
+ function parseGradientColors(bgImage) {
232
+ if (!bgImage || !bgImage.includes('gradient')) return [];
233
+ return [...bgImage.matchAll(/rgba?\([^)]+\)/g)]
234
+ .map(m => parseRgb(m[0]))
235
+ .filter(Boolean);
236
+ }
237
+
238
+ function hasChroma(c, threshold = 30) {
239
+ if (!c) return false;
240
+ return (Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b)) >= threshold;
241
+ }
242
+
243
+ function getHue(c) {
244
+ if (!c) return 0;
245
+ const r = c.r / 255, g = c.g / 255, b = c.b / 255;
246
+ const max = Math.max(r, g, b), min = Math.min(r, g, b);
247
+ if (max === min) return 0;
248
+ const d = max - min;
249
+ let h;
250
+ if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
251
+ else if (max === g) h = ((b - r) / d + 2) / 6;
252
+ else h = ((r - g) / d + 4) / 6;
253
+ return Math.round(h * 360);
254
+ }
255
+
256
+ function colorToHex(c) {
257
+ if (!c) return '?';
258
+ return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');
259
+ }
260
+
261
+ // ─── Section 3: Pure Detection ──────────────────────────────────────────────
262
+
263
+ function checkBorders(tag, widths, colors, radius) {
264
+ if (SAFE_TAGS.has(tag)) return [];
265
+ const findings = [];
266
+ const sides = ['Top', 'Right', 'Bottom', 'Left'];
267
+
268
+ for (const side of sides) {
269
+ const w = widths[side];
270
+ if (w < 1 || isNeutralColor(colors[side])) continue;
271
+
272
+ const otherSides = sides.filter(s => s !== side);
273
+ const maxOther = Math.max(...otherSides.map(s => widths[s]));
274
+ if (!(w >= 2 && (maxOther <= 1 || w >= maxOther * 2))) continue;
275
+
276
+ const sn = side.toLowerCase();
277
+ const isSide = side === 'Left' || side === 'Right';
278
+
279
+ if (isSide) {
280
+ if (radius > 0) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` });
281
+ else if (w >= 3) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px` });
282
+ } else {
283
+ if (radius > 0 && w >= 2) findings.push({ id: 'border-accent-on-rounded', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` });
284
+ }
285
+ }
286
+
287
+ return findings;
288
+ }
289
+
290
+ function checkColors(opts) {
291
+ const { tag, textColor, bgColor, effectiveBg, fontSize, fontWeight, hasDirectText, bgClip, bgImage, classList } = opts;
292
+ if (SAFE_TAGS.has(tag)) return [];
293
+ const findings = [];
294
+
295
+ // Pure black background (only solid or near-solid, not semi-transparent overlays)
296
+ if (bgColor && bgColor.a >= 0.9 && bgColor.r === 0 && bgColor.g === 0 && bgColor.b === 0) {
297
+ findings.push({ id: 'pure-black-white', snippet: '#000000 background' });
298
+ }
299
+
300
+ if (hasDirectText && textColor) {
301
+ // Skip background-dependent checks if we can't determine the background (e.g. gradient)
302
+ if (effectiveBg) {
303
+ // Gray on colored background
304
+ const textLum = relativeLuminance(textColor);
305
+ const isGray = !hasChroma(textColor, 20) && textLum > 0.05 && textLum < 0.85;
306
+ if (isGray && hasChroma(effectiveBg, 40)) {
307
+ findings.push({ id: 'gray-on-color', snippet: `text ${colorToHex(textColor)} on bg ${colorToHex(effectiveBg)}` });
308
+ }
309
+
310
+ // Low contrast (WCAG AA)
311
+ const ratio = contrastRatio(textColor, effectiveBg);
312
+ const isHeading = ['h1', 'h2', 'h3'].includes(tag);
313
+ const isLargeText = fontSize >= 18 || (fontSize >= 14 && fontWeight >= 700) || isHeading;
314
+ const threshold = isLargeText ? 3.0 : 4.5;
315
+ if (ratio < threshold) {
316
+ findings.push({ id: 'low-contrast', snippet: `${ratio.toFixed(1)}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(effectiveBg)}` });
317
+ }
318
+ }
319
+
320
+ // AI palette: purple/violet on headings
321
+ if (hasChroma(textColor, 50)) {
322
+ const hue = getHue(textColor);
323
+ if (hue >= 260 && hue <= 310 && (['h1', 'h2', 'h3'].includes(tag) || fontSize >= 20)) {
324
+ findings.push({ id: 'ai-color-palette', snippet: `Purple/violet text (${colorToHex(textColor)}) on heading` });
325
+ }
326
+ }
327
+ }
328
+
329
+ // Gradient text
330
+ if (bgClip === 'text' && bgImage && bgImage.includes('gradient')) {
331
+ findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
332
+ }
333
+
334
+ // Tailwind class checks
335
+ if (classList) {
336
+ const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
337
+ if (/\bbg-black\b/.test(classStr)) {
338
+ findings.push({ id: 'pure-black-white', snippet: 'bg-black' });
339
+ }
340
+
341
+ const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
342
+ const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
343
+ if (grayMatch && colorBgMatch) {
344
+ findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
345
+ }
346
+
347
+ if (/\bbg-clip-text\b/.test(classStr) && /\bbg-gradient-to-/.test(classStr)) {
348
+ findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' });
349
+ }
350
+
351
+ const purpleText = classStr.match(/\btext-(?:purple|violet|indigo)-\d+\b/);
352
+ if (purpleText && (['h1', 'h2', 'h3'].includes(tag) || /\btext-(?:[2-9]xl)\b/.test(classStr))) {
353
+ findings.push({ id: 'ai-color-palette', snippet: `${purpleText[0]} on heading` });
354
+ }
355
+
356
+ if (/\bfrom-(?:purple|violet|indigo)-\d+\b/.test(classStr) && /\bto-(?:purple|violet|indigo|blue|cyan|pink|fuchsia)-\d+\b/.test(classStr)) {
357
+ findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet gradient (Tailwind)' });
358
+ }
359
+ }
360
+
361
+ return findings;
362
+ }
363
+
364
+ function isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg) {
365
+ if (!hasShadow && !hasBorder) return false;
366
+ return hasRadius || hasBg;
367
+ }
368
+
369
+ const LAYOUT_TRANSITION_PROPS = new Set([
370
+ 'width', 'height', 'padding', 'margin',
371
+ 'max-height', 'max-width', 'min-height', 'min-width',
372
+ 'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
373
+ 'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
374
+ ]);
375
+
376
+ function checkMotion(opts) {
377
+ const { tag, transitionProperty, animationName, timingFunctions, classList } = opts;
378
+ if (SAFE_TAGS.has(tag)) return [];
379
+ const findings = [];
380
+
381
+ // --- Bounce/elastic easing ---
382
+ if (animationName && animationName !== 'none' && /bounce|elastic|wobble|jiggle|spring/i.test(animationName)) {
383
+ findings.push({ id: 'bounce-easing', snippet: `animation: ${animationName}` });
384
+ }
385
+ if (classList && /\banimate-bounce\b/.test(classList)) {
386
+ findings.push({ id: 'bounce-easing', snippet: 'animate-bounce (Tailwind)' });
387
+ }
388
+
389
+ // Check timing functions for overshoot cubic-bezier (y values outside [0, 1])
390
+ if (timingFunctions) {
391
+ const bezierRe = /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g;
392
+ let m;
393
+ while ((m = bezierRe.exec(timingFunctions)) !== null) {
394
+ const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]);
395
+ if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) {
396
+ findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${m[1]}, ${m[2]}, ${m[3]}, ${m[4]})` });
397
+ break;
398
+ }
399
+ }
400
+ }
401
+
402
+ // --- Layout property transition ---
403
+ if (transitionProperty && transitionProperty !== 'all' && transitionProperty !== 'none') {
404
+ const props = transitionProperty.split(',').map(p => p.trim().toLowerCase());
405
+ const layoutFound = props.filter(p => LAYOUT_TRANSITION_PROPS.has(p));
406
+ if (layoutFound.length > 0) {
407
+ findings.push({ id: 'layout-transition', snippet: `transition: ${layoutFound.join(', ')}` });
408
+ }
409
+ }
410
+
411
+ return findings;
412
+ }
413
+
414
+ function checkGlow(opts) {
415
+ const { boxShadow, effectiveBg } = opts;
416
+ if (!boxShadow || boxShadow === 'none') return [];
417
+ if (!effectiveBg) return [];
418
+
419
+ // Only flag on dark backgrounds (luminance < 0.1)
420
+ const bgLum = relativeLuminance(effectiveBg);
421
+ if (bgLum >= 0.1) return [];
422
+
423
+ // Split multiple shadows (commas not inside parentheses)
424
+ const parts = boxShadow.split(/,(?![^(]*\))/);
425
+ for (const shadow of parts) {
426
+ const colorMatch = shadow.match(/rgba?\([^)]+\)/);
427
+ if (!colorMatch) continue;
428
+ const color = parseRgb(colorMatch[0]);
429
+ if (!color || !hasChroma(color, 30)) continue;
430
+
431
+ // Extract px values — in computed style: "color Xpx Ypx BLURpx [SPREADpx]"
432
+ const afterColor = shadow.substring(shadow.indexOf(colorMatch[0]) + colorMatch[0].length);
433
+ const beforeColor = shadow.substring(0, shadow.indexOf(colorMatch[0]));
434
+ const pxVals = [...beforeColor.matchAll(/([\d.]+)px/g), ...afterColor.matchAll(/([\d.]+)px/g)]
435
+ .map(m => parseFloat(m[1]));
436
+
437
+ // Third value is blur (offset-x, offset-y, blur, [spread])
438
+ if (pxVals.length >= 3 && pxVals[2] > 4) {
439
+ return [{ id: 'dark-glow', snippet: `Colored glow (${colorToHex(color)}) on dark background` }];
440
+ }
441
+ }
442
+
443
+ return [];
444
+ }
445
+
446
+ /**
447
+ * Regex-on-HTML checks shared between browser and Node page-level detection.
448
+ * These don't need DOM access, just the raw HTML string.
449
+ */
450
+ function checkHtmlPatterns(html) {
451
+ const findings = [];
452
+
453
+ // --- Color ---
454
+
455
+ // Pure black background
456
+ const pureBlackBgRe = /background(?:-color)?\s*:\s*(?:#000000|#000|rgb\(\s*0,\s*0,\s*0\s*\))\b/gi;
457
+ if (pureBlackBgRe.test(html)) {
458
+ findings.push({ id: 'pure-black-white', snippet: 'Pure #000 background' });
459
+ }
460
+
461
+ // AI color palette: purple/violet
462
+ const purpleHexRe = /#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9|6366f1|764ba2|667eea)\b/gi;
463
+ if (purpleHexRe.test(html)) {
464
+ const purpleTextRe = /(?:(?:^|;)\s*color\s*:\s*(?:.*?)(?:#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9))|gradient.*?#(?:7c3aed|8b5cf6|a855f7|764ba2|667eea))/gi;
465
+ if (purpleTextRe.test(html)) {
466
+ findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected' });
467
+ }
468
+ }
469
+
470
+ // Gradient text (background-clip: text + gradient)
471
+ const gradientRe = /(?:-webkit-)?background-clip\s*:\s*text/gi;
472
+ let gm;
473
+ while ((gm = gradientRe.exec(html)) !== null) {
474
+ const start = Math.max(0, gm.index - 200);
475
+ const context = html.substring(start, gm.index + gm[0].length + 200);
476
+ if (/gradient/i.test(context)) {
477
+ findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
478
+ break;
479
+ }
480
+ }
481
+ if (/\bbg-clip-text\b/.test(html) && /\bbg-gradient-to-/.test(html)) {
482
+ findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' });
483
+ }
484
+
485
+ // --- Layout ---
486
+
487
+ // Monotonous spacing
488
+ const spacingValues = [];
489
+ const spacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*(\d+)px/gi;
490
+ let sm;
491
+ while ((sm = spacingRe.exec(html)) !== null) {
492
+ const v = parseInt(sm[1], 10);
493
+ if (v > 0 && v < 200) spacingValues.push(v);
494
+ }
495
+ const gapRe = /gap\s*:\s*(\d+)px/gi;
496
+ while ((sm = gapRe.exec(html)) !== null) {
497
+ spacingValues.push(parseInt(sm[1], 10));
498
+ }
499
+ const twSpaceRe = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+)\b/g;
500
+ while ((sm = twSpaceRe.exec(html)) !== null) {
501
+ spacingValues.push(parseInt(sm[1], 10) * 4);
502
+ }
503
+ const remSpacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*([\d.]+)rem/gi;
504
+ while ((sm = remSpacingRe.exec(html)) !== null) {
505
+ const v = Math.round(parseFloat(sm[1]) * 16);
506
+ if (v > 0 && v < 200) spacingValues.push(v);
507
+ }
508
+ const roundedSpacing = spacingValues.map(v => Math.round(v / 4) * 4);
509
+ if (roundedSpacing.length >= 10) {
510
+ const counts = {};
511
+ for (const v of roundedSpacing) counts[v] = (counts[v] || 0) + 1;
512
+ const maxCount = Math.max(...Object.values(counts));
513
+ const dominantPct = maxCount / roundedSpacing.length;
514
+ const unique = [...new Set(roundedSpacing)].filter(v => v > 0);
515
+ if (dominantPct > 0.6 && unique.length <= 3) {
516
+ const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
517
+ findings.push({
518
+ id: 'monotonous-spacing',
519
+ snippet: `~${dominant}px used ${maxCount}/${roundedSpacing.length} times (${Math.round(dominantPct * 100)}%)`,
520
+ });
521
+ }
522
+ }
523
+
524
+ // --- Motion ---
525
+
526
+ // Bounce/elastic animation names
527
+ const bounceRe = /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi;
528
+ if (bounceRe.test(html)) {
529
+ findings.push({ id: 'bounce-easing', snippet: 'Bounce/elastic animation in CSS' });
530
+ }
531
+
532
+ // Overshoot cubic-bezier
533
+ const bezierRe = /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g;
534
+ let bm;
535
+ while ((bm = bezierRe.exec(html)) !== null) {
536
+ const y1 = parseFloat(bm[2]), y2 = parseFloat(bm[4]);
537
+ if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) {
538
+ findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})` });
539
+ break;
540
+ }
541
+ }
542
+
543
+ // Layout property transitions
544
+ const transRe = /transition(?:-property)?\s*:\s*([^;{}]+)/gi;
545
+ let tm;
546
+ while ((tm = transRe.exec(html)) !== null) {
547
+ const val = tm[1].toLowerCase();
548
+ if (/\ball\b/.test(val)) continue;
549
+ const found = val.match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
550
+ if (found) {
551
+ findings.push({ id: 'layout-transition', snippet: `transition: ${found.join(', ')}` });
552
+ break;
553
+ }
554
+ }
555
+
556
+ // --- Dark glow ---
557
+
558
+ const darkBgRe = /background(?:-color)?\s*:\s*(?:#(?:0[0-9a-f]|1[0-9a-f]|2[0-3])[0-9a-f]{4}\b|#(?:0|1)[0-9a-f]{2}\b|rgb\(\s*(\d{1,2})\s*,\s*(\d{1,2})\s*,\s*(\d{1,2})\s*\))/gi;
559
+ const twDarkBg = /\bbg-(?:gray|slate|zinc|neutral|stone)-(?:9\d{2}|800)\b/;
560
+ if (darkBgRe.test(html) || twDarkBg.test(html)) {
561
+ const shadowRe = /box-shadow\s*:\s*([^;{}]+)/gi;
562
+ let shm;
563
+ while ((shm = shadowRe.exec(html)) !== null) {
564
+ const val = shm[1];
565
+ const colorMatch = val.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
566
+ if (!colorMatch) continue;
567
+ const [r, g, b] = [+colorMatch[1], +colorMatch[2], +colorMatch[3]];
568
+ if ((Math.max(r, g, b) - Math.min(r, g, b)) < 30) continue;
569
+ const pxVals = [...val.matchAll(/(\d+)px|(?<![.\d])\b(0)\b(?![.\d])/g)].map(p => +(p[1] || p[2]));
570
+ if (pxVals.length >= 3 && pxVals[2] > 4) {
571
+ findings.push({ id: 'dark-glow', snippet: `Colored glow (rgb(${r},${g},${b})) on dark page` });
572
+ break;
573
+ }
574
+ }
575
+ }
576
+
577
+ return findings;
578
+ }
579
+
580
+ // ─── Section 4: resolveBackground (unified) ─────────────────────────────────
581
+
582
+ function resolveBackground(el, win) {
583
+ let current = el;
584
+ while (current && current.nodeType === 1) {
585
+ const style = IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
586
+
587
+ // If this element has a gradient background, it's opaque but we can't determine the color
588
+ const bgImage = style.backgroundImage || '';
589
+ if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
590
+ return null;
591
+ }
592
+
593
+ let bg = parseRgb(style.backgroundColor);
594
+ if (!IS_BROWSER && (!bg || bg.a < 0.1)) {
595
+ // jsdom doesn't decompose background shorthand — parse raw style attr
596
+ const rawStyle = current.getAttribute?.('style') || '';
597
+ const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
598
+ const inlineBg = bgMatch ? bgMatch[1].trim() : '';
599
+ // Check for gradient in inline style too
600
+ if (/gradient/i.test(inlineBg)) return null;
601
+ bg = parseRgb(inlineBg);
602
+ if (!bg && inlineBg) {
603
+ const hexMatch = inlineBg.match(/#([0-9a-f]{6}|[0-9a-f]{3})\b/i);
604
+ if (hexMatch) {
605
+ const h = hexMatch[1];
606
+ if (h.length === 6) {
607
+ bg = { r: parseInt(h.slice(0,2), 16), g: parseInt(h.slice(2,4), 16), b: parseInt(h.slice(4,6), 16), a: 1 };
608
+ } else {
609
+ bg = { r: parseInt(h[0]+h[0], 16), g: parseInt(h[1]+h[1], 16), b: parseInt(h[2]+h[2], 16), a: 1 };
610
+ }
611
+ }
612
+ }
613
+ }
614
+ if (bg && bg.a > 0.1) {
615
+ if (IS_BROWSER || bg.a >= 0.5) return bg;
616
+ }
617
+ current = current.parentElement;
618
+ }
619
+ return { r: 255, g: 255, b: 255 };
620
+ }
621
+
622
+ // ─── Section 5: Element Adapters ────────────────────────────────────────────
623
+
624
+ // Browser adapters — call getComputedStyle/getBoundingClientRect on live DOM
625
+
626
+ function checkElementBordersDOM(el) {
627
+ const tag = el.tagName.toLowerCase();
628
+ if (SAFE_TAGS.has(tag)) return [];
629
+ const rect = el.getBoundingClientRect();
630
+ if (rect.width < 20 || rect.height < 20) return [];
631
+ const style = getComputedStyle(el);
632
+ const sides = ['Top', 'Right', 'Bottom', 'Left'];
633
+ const widths = {}, colors = {};
634
+ for (const s of sides) {
635
+ widths[s] = parseFloat(style[`border${s}Width`]) || 0;
636
+ colors[s] = style[`border${s}Color`] || '';
637
+ }
638
+ return checkBorders(tag, widths, colors, parseFloat(style.borderRadius) || 0);
639
+ }
640
+
641
+ function checkElementColorsDOM(el) {
642
+ const tag = el.tagName.toLowerCase();
643
+ if (SAFE_TAGS.has(tag)) return [];
644
+ const rect = el.getBoundingClientRect();
645
+ if (rect.width < 10 || rect.height < 10) return [];
646
+ const style = getComputedStyle(el);
647
+ const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim());
648
+ return checkColors({
649
+ tag,
650
+ textColor: parseRgb(style.color),
651
+ bgColor: parseRgb(style.backgroundColor),
652
+ effectiveBg: resolveBackground(el),
653
+ fontSize: parseFloat(style.fontSize) || 16,
654
+ fontWeight: parseInt(style.fontWeight) || 400,
655
+ hasDirectText,
656
+ bgClip: style.webkitBackgroundClip || style.backgroundClip || '',
657
+ bgImage: style.backgroundImage || '',
658
+ classList: el.getAttribute('class') || '',
659
+ });
660
+ }
661
+
662
+ function checkElementMotionDOM(el) {
663
+ const tag = el.tagName.toLowerCase();
664
+ if (SAFE_TAGS.has(tag)) return [];
665
+ const style = getComputedStyle(el);
666
+ return checkMotion({
667
+ tag,
668
+ transitionProperty: style.transitionProperty || '',
669
+ animationName: style.animationName || '',
670
+ timingFunctions: [style.animationTimingFunction, style.transitionTimingFunction].filter(Boolean).join(' '),
671
+ classList: el.getAttribute('class') || '',
672
+ });
673
+ }
674
+
675
+ function checkElementGlowDOM(el) {
676
+ const tag = el.tagName.toLowerCase();
677
+ const style = getComputedStyle(el);
678
+ if (!style.boxShadow || style.boxShadow === 'none') return [];
679
+ // Use parent's background — glow radiates outward, so the surrounding context matters
680
+ // If resolveBackground returns null (gradient), try to infer from the gradient colors
681
+ let parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el);
682
+ if (!parentBg) {
683
+ // Gradient background — sample its colors to determine if it's dark
684
+ let cur = el.parentElement;
685
+ while (cur && cur.nodeType === 1) {
686
+ const bgImage = getComputedStyle(cur).backgroundImage || '';
687
+ const gradColors = parseGradientColors(bgImage);
688
+ if (gradColors.length > 0) {
689
+ // Average the gradient colors
690
+ const avg = { r: 0, g: 0, b: 0 };
691
+ for (const c of gradColors) { avg.r += c.r; avg.g += c.g; avg.b += c.b; }
692
+ avg.r = Math.round(avg.r / gradColors.length);
693
+ avg.g = Math.round(avg.g / gradColors.length);
694
+ avg.b = Math.round(avg.b / gradColors.length);
695
+ parentBg = avg;
696
+ break;
697
+ }
698
+ cur = cur.parentElement;
699
+ }
700
+ }
701
+ return checkGlow({ tag, boxShadow: style.boxShadow, effectiveBg: parentBg });
702
+ }
703
+
704
+ function checkElementAIPaletteDOM(el) {
705
+ const style = getComputedStyle(el);
706
+ const findings = [];
707
+
708
+ // Check gradient backgrounds for purple/violet or cyan
709
+ const bgImage = style.backgroundImage || '';
710
+ const gradColors = parseGradientColors(bgImage);
711
+ for (const c of gradColors) {
712
+ if (hasChroma(c, 50)) {
713
+ const hue = getHue(c);
714
+ if (hue >= 260 && hue <= 310) {
715
+ findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet gradient background' });
716
+ break;
717
+ }
718
+ if (hue >= 160 && hue <= 200) {
719
+ findings.push({ id: 'ai-color-palette', snippet: 'Cyan gradient background' });
720
+ break;
721
+ }
722
+ }
723
+ }
724
+
725
+ // Check for neon text (vivid cyan/purple color on dark background)
726
+ const textColor = parseRgb(style.color);
727
+ if (textColor && hasChroma(textColor, 80)) {
728
+ const hue = getHue(textColor);
729
+ const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
730
+ if (isAIPalette) {
731
+ const parentBg = el.parentElement ? resolveBackground(el.parentElement) : null;
732
+ // Also check gradient parents
733
+ let effectiveBg = parentBg;
734
+ if (!effectiveBg) {
735
+ let cur = el.parentElement;
736
+ while (cur && cur.nodeType === 1) {
737
+ const gi = getComputedStyle(cur).backgroundImage || '';
738
+ const gc = parseGradientColors(gi);
739
+ if (gc.length > 0) {
740
+ const avg = { r: 0, g: 0, b: 0 };
741
+ for (const c of gc) { avg.r += c.r; avg.g += c.g; avg.b += c.b; }
742
+ avg.r = Math.round(avg.r / gc.length);
743
+ avg.g = Math.round(avg.g / gc.length);
744
+ avg.b = Math.round(avg.b / gc.length);
745
+ effectiveBg = avg;
746
+ break;
747
+ }
748
+ cur = cur.parentElement;
749
+ }
750
+ }
751
+ if (effectiveBg && relativeLuminance(effectiveBg) < 0.1) {
752
+ const label = hue >= 260 ? 'Purple/violet' : 'Cyan';
753
+ findings.push({ id: 'ai-color-palette', snippet: `${label} neon text on dark background` });
754
+ }
755
+ }
756
+ }
757
+
758
+ return findings;
759
+ }
760
+
761
+ const QUALITY_TEXT_TAGS = new Set(['p', 'li', 'td', 'th', 'dd', 'blockquote', 'figcaption']);
762
+
763
+ function checkElementQualityDOM(el) {
764
+ const tag = el.tagName.toLowerCase();
765
+ // Skip browser extension injected elements
766
+ const elId = el.id || '';
767
+ if (elId.startsWith('claude-') || elId.startsWith('cic-')) return [];
768
+ const style = getComputedStyle(el);
769
+ const findings = [];
770
+
771
+ const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 10);
772
+ const textLen = el.textContent?.trim().length || 0;
773
+ const fontSize = parseFloat(style.fontSize) || 16;
774
+ const rect = el.getBoundingClientRect();
775
+
776
+ // --- Line length too long ---
777
+ // Only flag if text is long enough to actually fill the line (>80 chars)
778
+ if (hasDirectText && QUALITY_TEXT_TAGS.has(tag) && rect.width > 0 && textLen > 80) {
779
+ const charsPerLine = rect.width / (fontSize * 0.5);
780
+ if (charsPerLine > 85) {
781
+ findings.push({ id: 'line-length', snippet: `~${Math.round(charsPerLine)} chars/line (aim for <80)` });
782
+ }
783
+ }
784
+
785
+ // --- Cramped padding (skip small elements like labels/badges) ---
786
+ if (hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) {
787
+ const borders = {
788
+ top: parseFloat(style.borderTopWidth) || 0,
789
+ right: parseFloat(style.borderRightWidth) || 0,
790
+ bottom: parseFloat(style.borderBottomWidth) || 0,
791
+ left: parseFloat(style.borderLeftWidth) || 0,
792
+ };
793
+ // Need at least 2 borders (a container), or a non-transparent background
794
+ const borderCount = Object.values(borders).filter(w => w > 0).length;
795
+ const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)';
796
+ if (borderCount >= 2 || hasBg) {
797
+ // Only check padding on sides that have borders or where bg creates containment
798
+ const paddings = [];
799
+ if (hasBg || borders.top > 0) paddings.push(parseFloat(style.paddingTop) || 0);
800
+ if (hasBg || borders.right > 0) paddings.push(parseFloat(style.paddingRight) || 0);
801
+ if (hasBg || borders.bottom > 0) paddings.push(parseFloat(style.paddingBottom) || 0);
802
+ if (hasBg || borders.left > 0) paddings.push(parseFloat(style.paddingLeft) || 0);
803
+ if (paddings.length > 0) {
804
+ const minPad = Math.min(...paddings);
805
+ if (minPad < 8) {
806
+ findings.push({ id: 'cramped-padding', snippet: `${minPad}px padding (need >=8px)` });
807
+ }
808
+ }
809
+ }
810
+ }
811
+
812
+ // --- Tight line height ---
813
+ if (hasDirectText && textLen > 50 && !['h1','h2','h3','h4','h5','h6'].includes(tag)) {
814
+ const lineHeight = parseFloat(style.lineHeight);
815
+ if (lineHeight && lineHeight !== NaN) {
816
+ const ratio = lineHeight / fontSize;
817
+ if (ratio < 1.3 && ratio > 0) {
818
+ findings.push({ id: 'tight-leading', snippet: `line-height ${ratio.toFixed(2)}x (need >=1.3)` });
819
+ }
820
+ }
821
+ }
822
+
823
+
824
+ // --- Justified text (without hyphens) ---
825
+ if (hasDirectText && style.textAlign === 'justify') {
826
+ const hyphens = style.hyphens || style.webkitHyphens || '';
827
+ if (hyphens !== 'auto') {
828
+ findings.push({ id: 'justified-text', snippet: 'text-align: justify without hyphens: auto' });
829
+ }
830
+ }
831
+
832
+ // --- Tiny body text ---
833
+ if (hasDirectText && textLen > 20 && fontSize < 12) {
834
+ if (!['sub', 'sup', 'code', 'kbd', 'samp', 'var'].includes(tag)) {
835
+ findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` });
836
+ }
837
+ }
838
+
839
+ // --- All-caps body text ---
840
+ if (hasDirectText && textLen > 30 && style.textTransform === 'uppercase') {
841
+ if (!['h1','h2','h3','h4','h5','h6'].includes(tag)) {
842
+ findings.push({ id: 'all-caps-body', snippet: `text-transform: uppercase on ${textLen} chars of body text` });
843
+ }
844
+ }
845
+
846
+ // --- Wide letter spacing on body text ---
847
+ if (hasDirectText && textLen > 20) {
848
+ const tracking = parseFloat(style.letterSpacing);
849
+ if (tracking > 0 && style.textTransform !== 'uppercase') {
850
+ const trackingEm = tracking / fontSize;
851
+ if (trackingEm > 0.05) {
852
+ findings.push({ id: 'wide-tracking', snippet: `letter-spacing: ${trackingEm.toFixed(2)}em on body text` });
853
+ }
854
+ }
855
+ }
856
+
857
+ return findings;
858
+ }
859
+
860
+ function checkPageQualityDOM() {
861
+ const findings = [];
862
+
863
+ // --- Skipped heading levels ---
864
+ const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
865
+ let prevLevel = 0;
866
+ for (const h of headings) {
867
+ const level = parseInt(h.tagName[1]);
868
+ if (prevLevel > 0 && level > prevLevel + 1) {
869
+ findings.push({ type: 'skipped-heading', detail: `h${prevLevel} followed by h${level} (missing h${prevLevel + 1})` });
870
+ }
871
+ prevLevel = level;
872
+ }
873
+
874
+ return findings;
875
+ }
876
+
877
+ // Node adapters — take pre-extracted jsdom computed style
878
+
879
+ function checkElementBorders(tag, style) {
880
+ const sides = ['Top', 'Right', 'Bottom', 'Left'];
881
+ const widths = {}, colors = {};
882
+ for (const s of sides) {
883
+ widths[s] = parseFloat(style[`border${s}Width`]) || 0;
884
+ colors[s] = style[`border${s}Color`] || '';
885
+ }
886
+ return checkBorders(tag, widths, colors, parseFloat(style.borderRadius) || 0);
887
+ }
888
+
889
+ function checkElementColors(el, style, tag, window) {
890
+ const hasText = el.textContent?.trim().length > 0;
891
+ const hasDirectText = hasText && [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim());
892
+
893
+ return checkColors({
894
+ tag,
895
+ textColor: parseRgb(style.color),
896
+ bgColor: parseRgb(style.backgroundColor),
897
+ effectiveBg: resolveBackground(el, window),
898
+ fontSize: parseFloat(style.fontSize) || 16,
899
+ fontWeight: parseInt(style.fontWeight) || 400,
900
+ hasDirectText,
901
+ bgClip: style.webkitBackgroundClip || style.backgroundClip || '',
902
+ bgImage: style.backgroundImage || '',
903
+ classList: el.getAttribute?.('class') || el.className || '',
904
+ });
905
+ }
906
+
907
+ function checkElementMotion(tag, style) {
908
+ return checkMotion({
909
+ tag,
910
+ transitionProperty: style.transitionProperty || '',
911
+ animationName: style.animationName || '',
912
+ timingFunctions: [style.animationTimingFunction, style.transitionTimingFunction].filter(Boolean).join(' '),
913
+ classList: '',
914
+ });
915
+ }
916
+
917
+ function checkElementGlow(tag, style, effectiveBg) {
918
+ if (!style.boxShadow || style.boxShadow === 'none') return [];
919
+ return checkGlow({ tag, boxShadow: style.boxShadow, effectiveBg });
920
+ }
921
+
922
+ // ─── Section 6: Page-Level Checks ───────────────────────────────────────────
923
+
924
+ // Browser page-level checks — use document/getComputedStyle globals
925
+
926
+ function checkTypography() {
927
+ const findings = [];
928
+ const fonts = new Set();
929
+ const overusedFound = new Set();
930
+
931
+ for (const sheet of document.styleSheets) {
932
+ let rules;
933
+ try { rules = sheet.cssRules || sheet.rules; } catch { continue; }
934
+ if (!rules) continue;
935
+ for (const rule of rules) {
936
+ if (rule.type !== 1) continue;
937
+ const ff = rule.style?.fontFamily;
938
+ if (!ff) continue;
939
+ const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
940
+ const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
941
+ if (primary) {
942
+ fonts.add(primary);
943
+ if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
944
+ }
945
+ }
946
+ }
947
+
948
+ const html = document.documentElement.outerHTML;
949
+ const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
950
+ let m;
951
+ while ((m = gfRe.exec(html)) !== null) {
952
+ for (const f of m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase())) {
953
+ fonts.add(f);
954
+ if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
955
+ }
956
+ }
957
+
958
+ for (const font of overusedFound) {
959
+ findings.push({ type: 'overused-font', detail: `Primary font: ${font}` });
960
+ }
961
+ if (fonts.size === 1 && document.querySelectorAll('*').length >= 20) {
962
+ findings.push({ type: 'single-font', detail: `only font used is ${[...fonts][0]}` });
963
+ }
964
+
965
+ const sizes = new Set();
966
+ for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) {
967
+ const fs = parseFloat(getComputedStyle(el).fontSize);
968
+ if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10);
969
+ }
970
+ if (sizes.size >= 3) {
971
+ const sorted = [...sizes].sort((a, b) => a - b);
972
+ const ratio = sorted[sorted.length - 1] / sorted[0];
973
+ if (ratio < 2.0) {
974
+ findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` });
975
+ }
976
+ }
977
+
978
+ return findings;
979
+ }
980
+
981
+ function isCardLikeDOM(el) {
982
+ const tag = el.tagName.toLowerCase();
983
+ if (SAFE_TAGS.has(tag) || ['input','select','textarea','img','video','canvas','picture'].includes(tag)) return false;
984
+ const style = getComputedStyle(el);
985
+ const cls = el.getAttribute('class') || '';
986
+ const hasShadow = (style.boxShadow && style.boxShadow !== 'none') || /\bshadow(?:-sm|-md|-lg|-xl|-2xl)?\b/.test(cls);
987
+ const hasBorder = /\bborder\b/.test(cls);
988
+ const hasRadius = parseFloat(style.borderRadius) > 0 || /\brounded(?:-sm|-md|-lg|-xl|-2xl|-full)?\b/.test(cls);
989
+ const hasBg = (style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)') || /\bbg-(?:white|gray-\d+|slate-\d+)\b/.test(cls);
990
+ return isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg);
991
+ }
992
+
993
+ function checkLayout() {
994
+ const findings = [];
995
+ const flaggedEls = new Set();
996
+
997
+ for (const el of document.querySelectorAll('*')) {
998
+ if (!isCardLikeDOM(el) || flaggedEls.has(el)) continue;
999
+ const cls = el.getAttribute('class') || '';
1000
+ const style = getComputedStyle(el);
1001
+ if (style.position === 'absolute' || style.position === 'fixed') continue;
1002
+ if (/\b(?:dropdown|popover|tooltip|menu|modal|dialog)\b/i.test(cls)) continue;
1003
+ if ((el.textContent?.trim().length || 0) < 10) continue;
1004
+ const rect = el.getBoundingClientRect();
1005
+ if (rect.width < 50 || rect.height < 30) continue;
1006
+
1007
+ let parent = el.parentElement;
1008
+ while (parent) {
1009
+ if (isCardLikeDOM(parent)) { flaggedEls.add(el); break; }
1010
+ parent = parent.parentElement;
1011
+ }
1012
+ }
1013
+
1014
+ for (const el of flaggedEls) {
1015
+ let isAncestor = false;
1016
+ for (const other of flaggedEls) {
1017
+ if (other !== el && el.contains(other)) { isAncestor = true; break; }
1018
+ }
1019
+ if (!isAncestor) findings.push({ type: 'nested-cards', detail: 'Card inside card', el });
1020
+ }
1021
+
1022
+ return findings;
1023
+ }
1024
+
1025
+ // Node page-level checks — take document/window as parameters
1026
+
1027
+ function checkPageTypography(doc, win) {
1028
+ const findings = [];
1029
+
1030
+ const fonts = new Set();
1031
+ const overusedFound = new Set();
1032
+
1033
+ for (const sheet of doc.styleSheets) {
1034
+ let rules;
1035
+ try { rules = sheet.cssRules || sheet.rules; } catch { continue; }
1036
+ if (!rules) continue;
1037
+ for (const rule of rules) {
1038
+ if (rule.type !== 1) continue;
1039
+ const ff = rule.style?.fontFamily;
1040
+ if (!ff) continue;
1041
+ const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
1042
+ const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
1043
+ if (primary) {
1044
+ fonts.add(primary);
1045
+ if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
1046
+ }
1047
+ }
1048
+ }
1049
+
1050
+ // Check Google Fonts links in HTML
1051
+ const html = doc.documentElement?.outerHTML || '';
1052
+ const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
1053
+ let m;
1054
+ while ((m = gfRe.exec(html)) !== null) {
1055
+ const families = m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase());
1056
+ for (const f of families) {
1057
+ fonts.add(f);
1058
+ if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
1059
+ }
1060
+ }
1061
+
1062
+ // Also parse raw HTML/style content for font-family (jsdom may not expose all via CSSOM)
1063
+ const ffRe = /font-family\s*:\s*([^;}]+)/gi;
1064
+ let fm;
1065
+ while ((fm = ffRe.exec(html)) !== null) {
1066
+ for (const f of fm[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) {
1067
+ if (f && !GENERIC_FONTS.has(f)) {
1068
+ fonts.add(f);
1069
+ if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
1070
+ }
1071
+ }
1072
+ }
1073
+
1074
+ for (const font of overusedFound) {
1075
+ findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` });
1076
+ }
1077
+
1078
+ // Single font
1079
+ if (fonts.size === 1) {
1080
+ const els = doc.querySelectorAll('*');
1081
+ if (els.length >= 20) {
1082
+ findings.push({ id: 'single-font', snippet: `only font used is ${[...fonts][0]}` });
1083
+ }
1084
+ }
1085
+
1086
+ // Flat type hierarchy
1087
+ const sizes = new Set();
1088
+ const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div');
1089
+ for (const el of textEls) {
1090
+ const fontSize = parseFloat(win.getComputedStyle(el).fontSize);
1091
+ // Filter out sub-8px values (jsdom doesn't resolve relative units properly)
1092
+ if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10);
1093
+ }
1094
+ if (sizes.size >= 3) {
1095
+ const sorted = [...sizes].sort((a, b) => a - b);
1096
+ const ratio = sorted[sorted.length - 1] / sorted[0];
1097
+ if (ratio < 2.0) {
1098
+ findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` });
1099
+ }
1100
+ }
1101
+
1102
+ return findings;
1103
+ }
1104
+
1105
+ function isCardLike(el, win) {
1106
+ const tag = el.tagName.toLowerCase();
1107
+ if (SAFE_TAGS.has(tag) || ['input', 'select', 'textarea', 'img', 'video', 'canvas', 'picture'].includes(tag)) return false;
1108
+
1109
+ const style = win.getComputedStyle(el);
1110
+ const rawStyle = el.getAttribute?.('style') || '';
1111
+ const cls = el.getAttribute?.('class') || '';
1112
+
1113
+ const hasShadow = (style.boxShadow && style.boxShadow !== 'none') ||
1114
+ /\bshadow(?:-sm|-md|-lg|-xl|-2xl)?\b/.test(cls) || /box-shadow/i.test(rawStyle);
1115
+ const hasBorder = /\bborder\b/.test(cls);
1116
+ const hasRadius = (parseFloat(style.borderRadius) || 0) > 0 ||
1117
+ /\brounded(?:-sm|-md|-lg|-xl|-2xl|-full)?\b/.test(cls) || /border-radius/i.test(rawStyle);
1118
+ const hasBg = /\bbg-(?:white|gray-\d+|slate-\d+)\b/.test(cls) ||
1119
+ /background(?:-color)?\s*:\s*(?!transparent)/i.test(rawStyle);
1120
+
1121
+ return isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg);
1122
+ }
1123
+
1124
+ function checkPageLayout(doc, win) {
1125
+ const findings = [];
1126
+
1127
+ // Nested cards
1128
+ const allEls = doc.querySelectorAll('*');
1129
+ const flaggedEls = new Set();
1130
+ for (const el of allEls) {
1131
+ if (!isCardLike(el, win)) continue;
1132
+ if (flaggedEls.has(el)) continue;
1133
+
1134
+ const tag = el.tagName.toLowerCase();
1135
+ const cls = el.getAttribute?.('class') || '';
1136
+ const rawStyle = el.getAttribute?.('style') || '';
1137
+
1138
+ if (['pre', 'code'].includes(tag)) continue;
1139
+ if (/\b(?:absolute|fixed)\b/.test(cls) || /position\s*:\s*(?:absolute|fixed)/i.test(rawStyle)) continue;
1140
+ if ((el.textContent?.trim().length || 0) < 10) continue;
1141
+ if (/\b(?:dropdown|popover|tooltip|menu|modal|dialog)\b/i.test(cls)) continue;
1142
+
1143
+ // Walk up to find card-like ancestor
1144
+ let parent = el.parentElement;
1145
+ while (parent) {
1146
+ if (isCardLike(parent, win)) {
1147
+ flaggedEls.add(el);
1148
+ break;
1149
+ }
1150
+ parent = parent.parentElement;
1151
+ }
1152
+ }
1153
+
1154
+ // Only report innermost nested cards
1155
+ for (const el of flaggedEls) {
1156
+ let isAncestorOfFlagged = false;
1157
+ for (const other of flaggedEls) {
1158
+ if (other !== el && el.contains(other)) {
1159
+ isAncestorOfFlagged = true;
1160
+ break;
1161
+ }
1162
+ }
1163
+ if (!isAncestorOfFlagged) {
1164
+ findings.push({ id: 'nested-cards', snippet: `Card inside card (${el.tagName.toLowerCase()})` });
1165
+ }
1166
+ }
1167
+
1168
+ // Everything centered
1169
+ const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, li, div, button');
1170
+ let centeredCount = 0;
1171
+ let totalText = 0;
1172
+ for (const el of textEls) {
1173
+ const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length >= 3);
1174
+ if (!hasDirectText) continue;
1175
+ totalText++;
1176
+
1177
+ let cur = el;
1178
+ let isCentered = false;
1179
+ while (cur && cur.nodeType === 1) {
1180
+ const rawStyle = cur.getAttribute?.('style') || '';
1181
+ const cls = cur.getAttribute?.('class') || '';
1182
+ if (/text-align\s*:\s*center/i.test(rawStyle) || /\btext-center\b/.test(cls)) {
1183
+ isCentered = true;
1184
+ break;
1185
+ }
1186
+ if (cur.tagName === 'BODY') break;
1187
+ cur = cur.parentElement;
1188
+ }
1189
+ if (isCentered) centeredCount++;
1190
+ }
1191
+
1192
+ if (totalText >= 5 && centeredCount / totalText > 0.7) {
1193
+ findings.push({
1194
+ id: 'everything-centered',
1195
+ snippet: `${centeredCount}/${totalText} text elements centered (${Math.round(centeredCount / totalText * 100)}%)`,
1196
+ });
1197
+ }
1198
+
1199
+ return findings;
1200
+ }
1201
+
1202
+ // ─── Section 7: Browser UI (IS_BROWSER only) ────────────────────────────────
1203
+
1204
+ if (IS_BROWSER) {
1205
+ const LABEL_BG = 'oklch(55% 0.25 350)';
1206
+ const OUTLINE_COLOR = 'oklch(60% 0.25 350)';
1207
+
1208
+ // Inject hover styles via CSS (more reliable than JS event listeners)
1209
+ const styleEl = document.createElement('style');
1210
+ styleEl.textContent = `
1211
+ @keyframes impeccable-reveal {
1212
+ from { opacity: 0; outline-color: transparent; }
1213
+ to { opacity: 1; outline-color: ${OUTLINE_COLOR}; }
1214
+ }
1215
+ .impeccable-overlay:not(.impeccable-banner) {
1216
+ pointer-events: none;
1217
+ outline: 2px solid ${OUTLINE_COLOR};
1218
+ border-radius: 4px;
1219
+ transition: outline-color 0.3s ease;
1220
+ animation: impeccable-reveal 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
1221
+ animation-play-state: paused;
1222
+ }
1223
+ .impeccable-overlay.impeccable-visible {
1224
+ animation-play-state: running;
1225
+ }
1226
+ .impeccable-overlay.impeccable-hover {
1227
+ outline-color: rgba(0,0,0,0.85);
1228
+ z-index: 100001 !important;
1229
+ }
1230
+ .impeccable-label-name,
1231
+ .impeccable-label-detail {
1232
+ transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
1233
+ }
1234
+ .impeccable-label-detail {
1235
+ position: absolute; top: 100%; left: 0;
1236
+ }
1237
+ .impeccable-overlay.impeccable-hover .impeccable-label-name,
1238
+ .impeccable-overlay.impeccable-hover .impeccable-label-detail {
1239
+ transform: translateY(-100%);
1240
+ }
1241
+ .impeccable-hidden .impeccable-overlay:not(.impeccable-banner) {
1242
+ display: none !important;
1243
+ }
1244
+ `;
1245
+ (document.head || document.documentElement).appendChild(styleEl);
1246
+
1247
+ const overlays = [];
1248
+ const TYPE_LABELS = {};
1249
+ for (const ap of ANTIPATTERNS) {
1250
+ TYPE_LABELS[ap.id] = ap.name.toLowerCase().substring(0, 26);
1251
+ }
1252
+
1253
+ function isInFixedContext(el) {
1254
+ let p = el;
1255
+ while (p && p !== document.body) {
1256
+ if (getComputedStyle(p).position === 'fixed') return true;
1257
+ p = p.parentElement;
1258
+ }
1259
+ return false;
1260
+ }
1261
+
1262
+ function positionOverlay(overlay) {
1263
+ const el = overlay._targetEl;
1264
+ if (!el) return;
1265
+ const rect = el.getBoundingClientRect();
1266
+ if (overlay._isFixed) {
1267
+ // Viewport-relative coords for fixed targets
1268
+ overlay.style.top = `${rect.top - 2}px`;
1269
+ overlay.style.left = `${rect.left - 2}px`;
1270
+ } else {
1271
+ // Document-relative coords for normal targets
1272
+ overlay.style.top = `${rect.top + scrollY - 2}px`;
1273
+ overlay.style.left = `${rect.left + scrollX - 2}px`;
1274
+ }
1275
+ overlay.style.width = `${rect.width + 4}px`;
1276
+ overlay.style.height = `${rect.height + 4}px`;
1277
+ }
1278
+
1279
+ function repositionOverlays() {
1280
+ for (const o of overlays) {
1281
+ if (!o._targetEl || o.classList.contains('impeccable-banner')) continue;
1282
+ positionOverlay(o);
1283
+ }
1284
+ }
1285
+
1286
+ let resizeRAF;
1287
+ const onResize = () => {
1288
+ cancelAnimationFrame(resizeRAF);
1289
+ resizeRAF = requestAnimationFrame(repositionOverlays);
1290
+ };
1291
+ window.addEventListener('resize', onResize);
1292
+
1293
+ // Track target element visibility via IntersectionObserver.
1294
+ // Uses a huge rootMargin so all *rendered* elements count as intersecting,
1295
+ // while display:none / closed <details> / hidden modals etc. do not.
1296
+ // This is event-driven -- no polling needed.
1297
+ let overlayIndex = 0;
1298
+ const visibilityObserver = new IntersectionObserver((entries) => {
1299
+ for (const entry of entries) {
1300
+ const overlay = entry.target._impeccableOverlay;
1301
+ if (!overlay) continue;
1302
+ if (entry.isIntersecting) {
1303
+ overlay.style.display = '';
1304
+ positionOverlay(overlay);
1305
+ if (!overlay._revealed) {
1306
+ overlay._revealed = true;
1307
+ overlay.style.animationDelay = `${(overlay._staggerIndex || 0) * 80}ms`;
1308
+ requestAnimationFrame(() => overlay.classList.add('impeccable-visible'));
1309
+ }
1310
+ } else {
1311
+ overlay.style.display = 'none';
1312
+ }
1313
+ }
1314
+ }, { rootMargin: '99999px' });
1315
+
1316
+ // Reposition overlays after CSS transitions end (e.g. reveal animations).
1317
+ // Listens at document level so it catches transitions on ancestor elements
1318
+ // (the transform may be on a parent, not the flagged element itself).
1319
+ document.addEventListener('transitionend', (e) => {
1320
+ if (e.propertyName !== 'transform') return;
1321
+ for (const o of overlays) {
1322
+ if (!o._targetEl || o.classList.contains('impeccable-banner') || o.style.display === 'none') continue;
1323
+ if (e.target === o._targetEl || e.target.contains(o._targetEl)) {
1324
+ positionOverlay(o);
1325
+ }
1326
+ }
1327
+ });
1328
+
1329
+ const highlight = function(el, findings) {
1330
+ const fixed = isInFixedContext(el);
1331
+ const rect = el.getBoundingClientRect();
1332
+ const outline = document.createElement('div');
1333
+ outline.className = 'impeccable-overlay';
1334
+ outline._targetEl = el;
1335
+ outline._isFixed = fixed;
1336
+ Object.assign(outline.style, {
1337
+ position: fixed ? 'fixed' : 'absolute',
1338
+ top: fixed ? `${rect.top - 2}px` : `${rect.top + scrollY - 2}px`,
1339
+ left: fixed ? `${rect.left - 2}px` : `${rect.left + scrollX - 2}px`,
1340
+ width: `${rect.width + 4}px`, height: `${rect.height + 4}px`,
1341
+ zIndex: '99999', boxSizing: 'border-box',
1342
+ });
1343
+
1344
+ const typeText = findings.map(f => TYPE_LABELS[f.type || f.id] || f.type || f.id).join(', ');
1345
+ const detailText = findings.map(f => f.detail || f.snippet).join(' | ');
1346
+
1347
+ const label = document.createElement('div');
1348
+ label.className = 'impeccable-label';
1349
+ Object.assign(label.style, {
1350
+ position: 'absolute', top: '-22px', left: '0',
1351
+ clipPath: 'inset(0 -999px)',
1352
+ });
1353
+
1354
+ const rowBase = {
1355
+ padding: '2px 8px', borderRadius: '3px', whiteSpace: 'nowrap',
1356
+ fontSize: '11px', fontWeight: '600', letterSpacing: '0.02em',
1357
+ color: 'white', lineHeight: '16px',
1358
+ };
1359
+
1360
+ const nameRow = document.createElement('div');
1361
+ nameRow.className = 'impeccable-label-name';
1362
+ nameRow.textContent = typeText;
1363
+ Object.assign(nameRow.style, { ...rowBase, background: LABEL_BG, fontFamily: 'system-ui, sans-serif' });
1364
+ label.appendChild(nameRow);
1365
+
1366
+ const detailRow = document.createElement('div');
1367
+ detailRow.className = 'impeccable-label-detail';
1368
+ detailRow.textContent = detailText;
1369
+ Object.assign(detailRow.style, { ...rowBase, background: 'rgba(0,0,0,0.85)', fontFamily: 'ui-monospace, monospace', fontWeight: '400' });
1370
+ label.appendChild(detailRow);
1371
+
1372
+ outline.appendChild(label);
1373
+
1374
+ // Start hidden; the IntersectionObserver will show it once the target is rendered
1375
+ outline.style.display = 'none';
1376
+ outline._staggerIndex = overlayIndex++;
1377
+ el._impeccableOverlay = outline;
1378
+ visibilityObserver.observe(el);
1379
+
1380
+ // Drive hover state from the target element so pointer events pass through
1381
+ el.addEventListener('mouseenter', () => outline.classList.add('impeccable-hover'));
1382
+ el.addEventListener('mouseleave', () => outline.classList.remove('impeccable-hover'));
1383
+
1384
+ document.body.appendChild(outline);
1385
+ overlays.push(outline);
1386
+ };
1387
+
1388
+ const showPageBanner = function(findings) {
1389
+ if (!findings.length) return;
1390
+ const banner = document.createElement('div');
1391
+ banner.className = 'impeccable-overlay impeccable-banner';
1392
+ Object.assign(banner.style, {
1393
+ position: 'fixed', top: '0', left: '0', right: '0', zIndex: '100000',
1394
+ background: LABEL_BG, color: 'white',
1395
+ fontFamily: 'system-ui, sans-serif', fontSize: '13px',
1396
+ display: 'flex', alignItems: 'center', pointerEvents: 'auto',
1397
+ height: '36px', overflow: 'hidden', maxWidth: '100vw',
1398
+ transform: 'translateY(-100%)',
1399
+ transition: 'transform 0.4s cubic-bezier(0.16, 1, 0.3, 1)',
1400
+ });
1401
+ requestAnimationFrame(() => requestAnimationFrame(() => {
1402
+ banner.style.transform = 'translateY(0)';
1403
+ }));
1404
+
1405
+ // Scrollable findings area
1406
+ const scrollArea = document.createElement('div');
1407
+ Object.assign(scrollArea.style, {
1408
+ flex: '1', minWidth: '0', overflowX: 'auto', overflowY: 'hidden',
1409
+ display: 'flex', gap: '8px', alignItems: 'center',
1410
+ padding: '0 12px', scrollSnapType: 'x mandatory',
1411
+ scrollbarWidth: 'none',
1412
+ });
1413
+ for (const f of findings) {
1414
+ const tag = document.createElement('span');
1415
+ tag.textContent = `${TYPE_LABELS[f.type] || f.type}: ${f.detail}`;
1416
+ Object.assign(tag.style, {
1417
+ background: 'rgba(255,255,255,0.15)', padding: '2px 8px',
1418
+ borderRadius: '3px', fontSize: '12px', fontFamily: 'ui-monospace, monospace',
1419
+ whiteSpace: 'nowrap', flexShrink: '0', scrollSnapAlign: 'start',
1420
+ });
1421
+ scrollArea.appendChild(tag);
1422
+ }
1423
+ banner.appendChild(scrollArea);
1424
+
1425
+ // Controls area (always visible on the right)
1426
+ const controls = document.createElement('div');
1427
+ Object.assign(controls.style, {
1428
+ display: 'flex', alignItems: 'center', gap: '2px',
1429
+ padding: '0 8px', flexShrink: '0',
1430
+ });
1431
+
1432
+ // Toggle visibility button
1433
+ const toggle = document.createElement('button');
1434
+ toggle.textContent = '\u25C9'; // circle with dot (visible state)
1435
+ toggle.title = 'Toggle overlay visibility';
1436
+ Object.assign(toggle.style, {
1437
+ background: 'none', border: 'none',
1438
+ color: 'white', fontSize: '16px', cursor: 'pointer', padding: '0 4px',
1439
+ opacity: '0.85', transition: 'opacity 0.15s',
1440
+ });
1441
+ let overlaysVisible = true;
1442
+ toggle.addEventListener('click', () => {
1443
+ overlaysVisible = !overlaysVisible;
1444
+ document.body.classList.toggle('impeccable-hidden', !overlaysVisible);
1445
+ toggle.textContent = overlaysVisible ? '\u25C9' : '\u25CB'; // filled vs empty circle
1446
+ toggle.style.opacity = overlaysVisible ? '0.85' : '0.5';
1447
+ });
1448
+ controls.appendChild(toggle);
1449
+
1450
+ // Close button
1451
+ const close = document.createElement('button');
1452
+ close.textContent = '\u00d7';
1453
+ close.title = 'Dismiss banner';
1454
+ Object.assign(close.style, {
1455
+ background: 'none', border: 'none',
1456
+ color: 'white', fontSize: '18px', cursor: 'pointer', padding: '0 4px',
1457
+ });
1458
+ close.addEventListener('click', () => banner.remove());
1459
+ controls.appendChild(close);
1460
+
1461
+ banner.appendChild(controls);
1462
+ document.body.appendChild(banner);
1463
+ overlays.push(banner);
1464
+ };
1465
+
1466
+ const printSummary = function(allFindings) {
1467
+ if (allFindings.length === 0) {
1468
+ console.log('%c[impeccable] No anti-patterns found.', 'color: #22c55e; font-weight: bold');
1469
+ return;
1470
+ }
1471
+ console.group(
1472
+ `%c[impeccable] ${allFindings.length} anti-pattern${allFindings.length === 1 ? '' : 's'} found`,
1473
+ 'color: oklch(60% 0.25 350); font-weight: bold'
1474
+ );
1475
+ for (const { el, findings } of allFindings) {
1476
+ for (const f of findings) {
1477
+ console.log(`%c${f.type || f.id}%c ${f.detail || f.snippet}`,
1478
+ 'color: oklch(55% 0.25 350); font-weight: bold', 'color: inherit', el);
1479
+ }
1480
+ }
1481
+ console.groupEnd();
1482
+ };
1483
+
1484
+ const scan = function() {
1485
+ for (const o of overlays) o.remove();
1486
+ overlays.length = 0;
1487
+ visibilityObserver.disconnect();
1488
+ const allFindings = [];
1489
+
1490
+ for (const el of document.querySelectorAll('*')) {
1491
+ if (el.classList.contains('impeccable-overlay') ||
1492
+ el.classList.contains('impeccable-label') ||
1493
+ el.classList.contains('impeccable-tooltip')) continue;
1494
+ // Skip browser extension elements (Claude, etc.)
1495
+ const elId = el.id || '';
1496
+ if (elId.startsWith('claude-') || elId.startsWith('cic-')) continue;
1497
+ // Skip html/body -- page-level findings go in the banner, not a full-page overlay
1498
+ if (el === document.body || el === document.documentElement) continue;
1499
+
1500
+ const findings = [
1501
+ ...checkElementBordersDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
1502
+ ...checkElementColorsDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
1503
+ ...checkElementMotionDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
1504
+ ...checkElementGlowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
1505
+ ...checkElementAIPaletteDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
1506
+ ...checkElementQualityDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
1507
+ ];
1508
+
1509
+ if (findings.length > 0) {
1510
+ highlight(el, findings);
1511
+ allFindings.push({ el, findings });
1512
+ }
1513
+ }
1514
+
1515
+ const pageLevelFindings = [];
1516
+
1517
+ const typoFindings = checkTypography();
1518
+ if (typoFindings.length > 0) {
1519
+ pageLevelFindings.push(...typoFindings);
1520
+ allFindings.push({ el: document.body, findings: typoFindings });
1521
+ }
1522
+
1523
+ const layoutFindings = checkLayout();
1524
+ for (const f of layoutFindings) {
1525
+ const el = f.el || document.body;
1526
+ delete f.el;
1527
+ // Merge into existing overlay if this element already has one
1528
+ const existing = el._impeccableOverlay;
1529
+ if (existing) {
1530
+ const nameRow = existing.querySelector('.impeccable-label-name');
1531
+ const detailRow = existing.querySelector('.impeccable-label-detail');
1532
+ const newType = TYPE_LABELS[f.type] || f.type;
1533
+ if (nameRow) nameRow.textContent += ', ' + newType;
1534
+ if (detailRow) detailRow.textContent += ' | ' + (f.detail || '');
1535
+ } else {
1536
+ highlight(el, [f]);
1537
+ }
1538
+ allFindings.push({ el, findings: [f] });
1539
+ }
1540
+
1541
+ // Page-level quality checks (headings, etc.)
1542
+ const qualityFindings = checkPageQualityDOM();
1543
+ if (qualityFindings.length > 0) {
1544
+ pageLevelFindings.push(...qualityFindings);
1545
+ allFindings.push({ el: document.body, findings: qualityFindings });
1546
+ }
1547
+
1548
+ // Regex-on-HTML checks (shared with Node)
1549
+ const htmlPatternFindings = checkHtmlPatterns(document.documentElement.outerHTML);
1550
+ if (htmlPatternFindings.length > 0) {
1551
+ const mapped = htmlPatternFindings.map(f => ({ type: f.id, detail: f.snippet }));
1552
+ pageLevelFindings.push(...mapped);
1553
+ allFindings.push({ el: document.body, findings: mapped });
1554
+ }
1555
+
1556
+ if (pageLevelFindings.length > 0) {
1557
+ showPageBanner(pageLevelFindings);
1558
+ }
1559
+
1560
+ printSummary(allFindings);
1561
+ return allFindings;
1562
+ };
1563
+
1564
+ if (document.readyState === 'loading') {
1565
+ document.addEventListener('DOMContentLoaded', () => setTimeout(scan, 100));
1566
+ } else {
1567
+ setTimeout(scan, 100);
1568
+ }
1569
+
1570
+ window.impeccableScan = scan;
1571
+ }
1572
+
1573
+ // ─── Section 8: Node Engine ─────────────────────────────────────────────────
1574
+ // @browser-strip-start
1575
+
1576
+ function getAP(id) {
1577
+ return ANTIPATTERNS.find(a => a.id === id);
1578
+ }
1579
+
1580
+ function finding(id, filePath, snippet, line = 0) {
1581
+ const ap = getAP(id);
1582
+ return { antipattern: id, name: ap.name, description: ap.description, file: filePath, line, snippet };
1583
+ }
1584
+
1585
+ /** Check if content looks like a full page (not a component/partial) */
1586
+ function isFullPage(content) {
1587
+ const stripped = content.replace(/<!--[\s\S]*?-->/g, '');
1588
+ return /<!doctype\s|<html[\s>]|<head[\s>]/i.test(stripped);
1589
+ }
1590
+
1591
+ // ---------------------------------------------------------------------------
1592
+ // jsdom detection (default for HTML files)
1593
+ // ---------------------------------------------------------------------------
1594
+
1595
+ async function detectHtml(filePath) {
1596
+ let JSDOM;
1597
+ try {
1598
+ ({ JSDOM } = await import('jsdom'));
1599
+ } catch {
1600
+ const content = fs.readFileSync(filePath, 'utf-8');
1601
+ return detectText(content, filePath);
1602
+ }
1603
+
1604
+ const html = fs.readFileSync(filePath, 'utf-8');
1605
+ const resolvedPath = path.resolve(filePath);
1606
+ const fileDir = path.dirname(resolvedPath);
1607
+
1608
+ // Inline linked local stylesheets so jsdom can see them
1609
+ let processedHtml = html;
1610
+ const linkRes = [
1611
+ /<link[^>]+rel=["']stylesheet["'][^>]*href=["']([^"']+)["'][^>]*>/gi,
1612
+ /<link[^>]+href=["']([^"']+)["'][^>]*rel=["']stylesheet["'][^>]*>/gi,
1613
+ ];
1614
+ for (const re of linkRes) {
1615
+ let m;
1616
+ while ((m = re.exec(html)) !== null) {
1617
+ const href = m[1];
1618
+ if (/^(https?:)?\/\//.test(href)) continue;
1619
+ const cssPath = path.resolve(fileDir, href);
1620
+ try {
1621
+ const css = fs.readFileSync(cssPath, 'utf-8');
1622
+ processedHtml = processedHtml.replace(m[0], `<style>/* ${href} */\n${css}\n</style>`);
1623
+ } catch { /* skip unreadable */ }
1624
+ }
1625
+ }
1626
+
1627
+ const dom = new JSDOM(processedHtml, {
1628
+ url: `file://${resolvedPath}`,
1629
+ });
1630
+ const { window } = dom;
1631
+ const { document } = window;
1632
+
1633
+ const findings = [];
1634
+
1635
+ // Element-level checks (borders + colors + motion)
1636
+ for (const el of document.querySelectorAll('*')) {
1637
+ const tag = el.tagName.toLowerCase();
1638
+ const style = window.getComputedStyle(el);
1639
+ for (const f of checkElementBorders(tag, style)) {
1640
+ findings.push(finding(f.id, filePath, f.snippet));
1641
+ }
1642
+ for (const f of checkElementColors(el, style, tag, window)) {
1643
+ findings.push(finding(f.id, filePath, f.snippet));
1644
+ }
1645
+ for (const f of checkElementGlow(tag, style, resolveBackground(el.parentElement || el, window))) {
1646
+ findings.push(finding(f.id, filePath, f.snippet));
1647
+ }
1648
+ for (const f of checkElementMotion(tag, style)) {
1649
+ findings.push(finding(f.id, filePath, f.snippet));
1650
+ }
1651
+ }
1652
+
1653
+ // Page-level checks (only for full pages, not partials)
1654
+ if (isFullPage(html)) {
1655
+ for (const f of checkPageTypography(document, window)) {
1656
+ findings.push(finding(f.id, filePath, f.snippet));
1657
+ }
1658
+ for (const f of checkPageLayout(document, window)) {
1659
+ findings.push(finding(f.id, filePath, f.snippet));
1660
+ }
1661
+ for (const f of checkHtmlPatterns(html)) {
1662
+ findings.push(finding(f.id, filePath, f.snippet));
1663
+ }
1664
+ }
1665
+
1666
+ window.close();
1667
+ return findings;
1668
+ }
1669
+
1670
+ // ---------------------------------------------------------------------------
1671
+ // Puppeteer detection (for URLs)
1672
+ // ---------------------------------------------------------------------------
1673
+
1674
+ async function detectUrl(url) {
1675
+ let puppeteer;
1676
+ try {
1677
+ puppeteer = await import('puppeteer');
1678
+ } catch {
1679
+ throw new Error('puppeteer is required for URL scanning. Install: npm install puppeteer');
1680
+ }
1681
+
1682
+ // Read the browser detection script — reuse it instead of reimplementing
1683
+ const browserScriptPath = path.resolve(
1684
+ path.dirname(new URL(import.meta.url).pathname),
1685
+ 'detect-antipatterns-browser.js'
1686
+ );
1687
+ let browserScript;
1688
+ try {
1689
+ browserScript = fs.readFileSync(browserScriptPath, 'utf-8');
1690
+ } catch {
1691
+ throw new Error(`Browser script not found at ${browserScriptPath}`);
1692
+ }
1693
+
1694
+ const browser = await puppeteer.default.launch({ headless: true });
1695
+ const page = await browser.newPage();
1696
+ await page.setViewport({ width: 1280, height: 800 });
1697
+ await page.goto(url, { waitUntil: 'networkidle0', timeout: 30000 });
1698
+
1699
+ // Inject the browser detection script and collect results
1700
+ await page.evaluate(browserScript);
1701
+ const results = await page.evaluate(() => {
1702
+ if (!window.impeccableScan) return [];
1703
+ const allFindings = window.impeccableScan();
1704
+ return allFindings.flatMap(({ findings }) =>
1705
+ findings.map(f => ({ id: f.type, snippet: f.detail }))
1706
+ );
1707
+ });
1708
+
1709
+ await browser.close();
1710
+ return results.map(f => finding(f.id, url, f.snippet));
1711
+ }
1712
+
1713
+ // ---------------------------------------------------------------------------
1714
+ // Regex fallback (non-HTML files: CSS, JSX, TSX, etc.)
1715
+ // ---------------------------------------------------------------------------
1716
+
1717
+ const hasRounded = (line) => /\brounded(?:-\w+)?\b/.test(line);
1718
+ const hasBorderRadius = (line) => /border-radius/i.test(line);
1719
+ const isSafeElement = (line) => /<(?:blockquote|nav[\s>]|pre[\s>]|code[\s>]|a\s|input[\s>]|span[\s>])/i.test(line);
1720
+
1721
+ function isNeutralBorderColor(str) {
1722
+ const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i);
1723
+ if (!m) return false;
1724
+ const c = m[1].toLowerCase();
1725
+ if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true;
1726
+ const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/);
1727
+ if (hex) {
1728
+ const [r, g, b] = [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)];
1729
+ return (Math.max(r, g, b) - Math.min(r, g, b)) < 30;
1730
+ }
1731
+ const shex = c.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/);
1732
+ if (shex) {
1733
+ const [r, g, b] = [parseInt(shex[1] + shex[1], 16), parseInt(shex[2] + shex[2], 16), parseInt(shex[3] + shex[3], 16)];
1734
+ return (Math.max(r, g, b) - Math.min(r, g, b)) < 30;
1735
+ }
1736
+ return false;
1737
+ }
1738
+
1739
+ const REGEX_MATCHERS = [
1740
+ // --- Side-tab ---
1741
+ { id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
1742
+ test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 1 : n >= 4; },
1743
+ fmt: (m) => m[0] },
1744
+ { id: 'side-tab', regex: /border-(?:left|right)\s*:\s*(\d+)px\s+solid[^;]*/gi,
1745
+ test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 1 : n >= 3; },
1746
+ fmt: (m) => m[0].replace(/\s*;?\s*$/, '') },
1747
+ { id: 'side-tab', regex: /border-(?:left|right)-width\s*:\s*(\d+)px/gi,
1748
+ test: (m, line) => !isSafeElement(line) && +m[1] >= 3,
1749
+ fmt: (m) => m[0] },
1750
+ { id: 'side-tab', regex: /border-inline-(?:start|end)\s*:\s*(\d+)px\s+solid/gi,
1751
+ test: (m, line) => !isSafeElement(line) && +m[1] >= 3,
1752
+ fmt: (m) => m[0] },
1753
+ { id: 'side-tab', regex: /border-inline-(?:start|end)-width\s*:\s*(\d+)px/gi,
1754
+ test: (m, line) => !isSafeElement(line) && +m[1] >= 3,
1755
+ fmt: (m) => m[0] },
1756
+ { id: 'side-tab', regex: /border(?:Left|Right)\s*[:=]\s*["'`](\d+)px\s+solid/g,
1757
+ test: (m) => +m[1] >= 3,
1758
+ fmt: (m) => m[0] },
1759
+ // --- Border accent on rounded ---
1760
+ { id: 'border-accent-on-rounded', regex: /\bborder-[tb]-(\d+)\b/g,
1761
+ test: (m, line) => hasRounded(line) && +m[1] >= 1,
1762
+ fmt: (m) => m[0] },
1763
+ { id: 'border-accent-on-rounded', regex: /border-(?:top|bottom)\s*:\s*(\d+)px\s+solid/gi,
1764
+ test: (m, line) => +m[1] >= 3 && hasBorderRadius(line),
1765
+ fmt: (m) => m[0] },
1766
+ // --- Overused font ---
1767
+ { id: 'overused-font', regex: /font-family\s*:\s*['"]?(Inter|Roboto|Open Sans|Lato|Montserrat|Arial|Helvetica)\b/gi,
1768
+ test: () => true,
1769
+ fmt: (m) => m[0] },
1770
+ { id: 'overused-font', regex: /fonts\.googleapis\.com\/css2?\?family=(Inter|Roboto|Open\+Sans|Lato|Montserrat)\b/gi,
1771
+ test: () => true,
1772
+ fmt: (m) => `Google Fonts: ${m[1].replace(/\+/g, ' ')}` },
1773
+ // --- Pure black background ---
1774
+ { id: 'pure-black-white', regex: /background(?:-color)?\s*:\s*(#000000|#000|rgb\(0,\s*0,\s*0\))\b/gi,
1775
+ test: () => true,
1776
+ fmt: (m) => m[0] },
1777
+ // --- Gradient text ---
1778
+ { id: 'gradient-text', regex: /background-clip\s*:\s*text|-webkit-background-clip\s*:\s*text/gi,
1779
+ test: (m, line) => /gradient/i.test(line),
1780
+ fmt: () => 'background-clip: text + gradient' },
1781
+ // --- Gradient text (Tailwind) ---
1782
+ { id: 'gradient-text', regex: /\bbg-clip-text\b/g,
1783
+ test: (m, line) => /\bbg-gradient-to-/i.test(line),
1784
+ fmt: () => 'bg-clip-text + bg-gradient' },
1785
+ // --- Tailwind pure black background ---
1786
+ { id: 'pure-black-white', regex: /\bbg-black\b/g,
1787
+ test: () => true,
1788
+ fmt: (m) => m[0] },
1789
+ // --- Tailwind gray on colored bg ---
1790
+ { id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
1791
+ test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
1792
+ fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
1793
+ // --- Tailwind AI palette ---
1794
+ { id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
1795
+ test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
1796
+ fmt: (m) => `${m[0]} on heading` },
1797
+ { id: 'ai-color-palette', regex: /\bfrom-(?:purple|violet|indigo)-(\d+)\b/g,
1798
+ test: (m, line) => /\bto-(?:purple|violet|indigo|blue|cyan|pink|fuchsia)-\d+\b/.test(line),
1799
+ fmt: (m) => `${m[0]} gradient` },
1800
+ // --- Bounce/elastic easing ---
1801
+ { id: 'bounce-easing', regex: /\banimate-bounce\b/g,
1802
+ test: () => true,
1803
+ fmt: () => 'animate-bounce (Tailwind)' },
1804
+ { id: 'bounce-easing', regex: /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi,
1805
+ test: () => true,
1806
+ fmt: (m) => m[0] },
1807
+ { id: 'bounce-easing', regex: /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g,
1808
+ test: (m) => {
1809
+ const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]);
1810
+ return y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1;
1811
+ },
1812
+ fmt: (m) => `cubic-bezier(${m[1]}, ${m[2]}, ${m[3]}, ${m[4]})` },
1813
+ // --- Layout property transition ---
1814
+ { id: 'layout-transition', regex: /transition\s*:\s*([^;{}]+)/gi,
1815
+ test: (m) => {
1816
+ const val = m[1].toLowerCase();
1817
+ if (/\ball\b/.test(val)) return false;
1818
+ return /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding\b|\bmargin\b/.test(val);
1819
+ },
1820
+ fmt: (m) => {
1821
+ const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
1822
+ return `transition: ${found ? found.join(', ') : m[1].trim()}`;
1823
+ } },
1824
+ { id: 'layout-transition', regex: /transition-property\s*:\s*([^;{}]+)/gi,
1825
+ test: (m) => {
1826
+ const val = m[1].toLowerCase();
1827
+ if (/\ball\b/.test(val)) return false;
1828
+ return /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding\b|\bmargin\b/.test(val);
1829
+ },
1830
+ fmt: (m) => {
1831
+ const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
1832
+ return `transition-property: ${found ? found.join(', ') : m[1].trim()}`;
1833
+ } },
1834
+ ];
1835
+
1836
+ const REGEX_ANALYZERS = [
1837
+ // Single font
1838
+ (content, filePath) => {
1839
+ const fontFamilyRe = /font-family\s*:\s*([^;}]+)/gi;
1840
+ const fonts = new Set();
1841
+ let m;
1842
+ while ((m = fontFamilyRe.exec(content)) !== null) {
1843
+ for (const f of m[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) {
1844
+ if (f && !GENERIC_FONTS.has(f)) fonts.add(f);
1845
+ }
1846
+ }
1847
+ const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
1848
+ while ((m = gfRe.exec(content)) !== null) {
1849
+ for (const f of m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase())) fonts.add(f);
1850
+ }
1851
+ if (fonts.size !== 1 || content.split('\n').length < 20) return [];
1852
+ const name = [...fonts][0];
1853
+ const lines = content.split('\n');
1854
+ let line = 1;
1855
+ for (let i = 0; i < lines.length; i++) { if (lines[i].toLowerCase().includes(name)) { line = i + 1; break; } }
1856
+ return [finding('single-font', filePath, `only font used is ${name}`, line)];
1857
+ },
1858
+ // Flat type hierarchy
1859
+ (content, filePath) => {
1860
+ const sizes = new Set();
1861
+ const REM = 16;
1862
+ let m;
1863
+ const sizeRe = /font-size\s*:\s*([\d.]+)(px|rem|em)\b/gi;
1864
+ while ((m = sizeRe.exec(content)) !== null) {
1865
+ const px = m[2] === 'px' ? +m[1] : +m[1] * REM;
1866
+ if (px > 0 && px < 200) sizes.add(Math.round(px * 10) / 10);
1867
+ }
1868
+ const clampRe = /font-size\s*:\s*clamp\(\s*([\d.]+)(px|rem|em)\s*,\s*[^,]+,\s*([\d.]+)(px|rem|em)\s*\)/gi;
1869
+ while ((m = clampRe.exec(content)) !== null) {
1870
+ sizes.add(Math.round((m[2] === 'px' ? +m[1] : +m[1] * REM) * 10) / 10);
1871
+ sizes.add(Math.round((m[4] === 'px' ? +m[3] : +m[3] * REM) * 10) / 10);
1872
+ }
1873
+ const TW = { 'text-xs': 12, 'text-sm': 14, 'text-base': 16, 'text-lg': 18, 'text-xl': 20, 'text-2xl': 24, 'text-3xl': 30, 'text-4xl': 36, 'text-5xl': 48, 'text-6xl': 60, 'text-7xl': 72, 'text-8xl': 96, 'text-9xl': 128 };
1874
+ for (const [cls, px] of Object.entries(TW)) { if (new RegExp(`\\b${cls}\\b`).test(content)) sizes.add(px); }
1875
+ if (sizes.size < 3) return [];
1876
+ const sorted = [...sizes].sort((a, b) => a - b);
1877
+ const ratio = sorted[sorted.length - 1] / sorted[0];
1878
+ if (ratio >= 2.0) return [];
1879
+ const lines = content.split('\n');
1880
+ let line = 1;
1881
+ for (let i = 0; i < lines.length; i++) { if (/font-size/i.test(lines[i]) || /\btext-(?:xs|sm|base|lg|xl|\d)/i.test(lines[i])) { line = i + 1; break; } }
1882
+ return [finding('flat-type-hierarchy', filePath, `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)`, line)];
1883
+ },
1884
+ // Monotonous spacing (regex)
1885
+ (content, filePath) => {
1886
+ const vals = [];
1887
+ let m;
1888
+ const pxRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*(\d+)px/gi;
1889
+ while ((m = pxRe.exec(content)) !== null) { const v = +m[1]; if (v > 0 && v < 200) vals.push(v); }
1890
+ const remRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*([\d.]+)rem/gi;
1891
+ while ((m = remRe.exec(content)) !== null) { const v = Math.round(parseFloat(m[1]) * 16); if (v > 0 && v < 200) vals.push(v); }
1892
+ const gapRe = /gap\s*:\s*(\d+)px/gi;
1893
+ while ((m = gapRe.exec(content)) !== null) vals.push(+m[1]);
1894
+ const twRe = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+)\b/g;
1895
+ while ((m = twRe.exec(content)) !== null) vals.push(+m[1] * 4);
1896
+ const rounded = vals.map(v => Math.round(v / 4) * 4);
1897
+ if (rounded.length < 10) return [];
1898
+ const counts = {};
1899
+ for (const v of rounded) counts[v] = (counts[v] || 0) + 1;
1900
+ const maxCount = Math.max(...Object.values(counts));
1901
+ const pct = maxCount / rounded.length;
1902
+ const unique = [...new Set(rounded)].filter(v => v > 0);
1903
+ if (pct <= 0.6 || unique.length > 3) return [];
1904
+ const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
1905
+ return [finding('monotonous-spacing', filePath, `~${dominant}px used ${maxCount}/${rounded.length} times (${Math.round(pct * 100)}%)`)];
1906
+ },
1907
+ // Everything centered (regex)
1908
+ (content, filePath) => {
1909
+ const lines = content.split('\n');
1910
+ let centered = 0, total = 0;
1911
+ for (const line of lines) {
1912
+ if (/<(?:h[1-6]|p|div|li|button)\b[^>]*>/i.test(line) && line.trim().length > 20) {
1913
+ total++;
1914
+ if (/text-align\s*:\s*center/i.test(line) || /\btext-center\b/.test(line)) centered++;
1915
+ }
1916
+ }
1917
+ if (total < 5 || centered / total <= 0.7) return [];
1918
+ return [finding('everything-centered', filePath, `${centered}/${total} text elements centered (${Math.round(centered / total * 100)}%)`)];
1919
+ },
1920
+ // Dark glow (page-level: dark bg + colored box-shadow with blur)
1921
+ (content, filePath) => {
1922
+ // Check if page has a dark background
1923
+ const darkBgRe = /background(?:-color)?\s*:\s*(?:#(?:0[0-9a-f]|1[0-9a-f]|2[0-3])[0-9a-f]{4}\b|#(?:0|1)[0-9a-f]{2}\b|rgb\(\s*(\d{1,2})\s*,\s*(\d{1,2})\s*,\s*(\d{1,2})\s*\))/gi;
1924
+ const twDarkBg = /\bbg-(?:gray|slate|zinc|neutral|stone)-(?:9\d{2}|800)\b/;
1925
+ const hasDarkBg = darkBgRe.test(content) || twDarkBg.test(content);
1926
+ if (!hasDarkBg) return [];
1927
+
1928
+ // Check for colored box-shadow with blur > 4px
1929
+ const shadowRe = /box-shadow\s*:\s*([^;{}]+)/gi;
1930
+ let m;
1931
+ while ((m = shadowRe.exec(content)) !== null) {
1932
+ const val = m[1];
1933
+ const colorMatch = val.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
1934
+ if (!colorMatch) continue;
1935
+ const [r, g, b] = [+colorMatch[1], +colorMatch[2], +colorMatch[3]];
1936
+ if ((Math.max(r, g, b) - Math.min(r, g, b)) < 30) continue; // skip gray
1937
+ // Check blur: look for pattern like "0 0 20px" (third number > 4)
1938
+ const pxVals = [...val.matchAll(/(\d+)px|(?<![.\d])\b(0)\b(?![.\d])/g)].map(p => +(p[1] || p[2]));
1939
+ if (pxVals.length >= 3 && pxVals[2] > 4) {
1940
+ const lines = content.substring(0, m.index).split('\n');
1941
+ return [finding('dark-glow', filePath, `Colored glow (rgb(${r},${g},${b})) on dark page`, lines.length)];
1942
+ }
1943
+ }
1944
+ return [];
1945
+ },
1946
+ ];
1947
+
1948
+ // ---------------------------------------------------------------------------
1949
+ // Style block extraction (Vue/Svelte <style> blocks)
1950
+ // ---------------------------------------------------------------------------
1951
+
1952
+ function extractStyleBlocks(content, ext) {
1953
+ ext = ext.toLowerCase();
1954
+ if (ext !== '.vue' && ext !== '.svelte') return [];
1955
+ const blocks = [];
1956
+ const re = /<style[^>]*>([\s\S]*?)<\/style>/gi;
1957
+ let m;
1958
+ while ((m = re.exec(content)) !== null) {
1959
+ const before = content.substring(0, m.index);
1960
+ const startLine = before.split('\n').length + 1;
1961
+ blocks.push({ content: m[1], startLine });
1962
+ }
1963
+ return blocks;
1964
+ }
1965
+
1966
+ // ---------------------------------------------------------------------------
1967
+ // CSS-in-JS extraction (styled-components, emotion)
1968
+ // ---------------------------------------------------------------------------
1969
+
1970
+ const CSS_IN_JS_EXTENSIONS = new Set(['.js', '.ts', '.jsx', '.tsx']);
1971
+
1972
+ function extractCSSinJS(content, ext) {
1973
+ ext = ext.toLowerCase();
1974
+ if (!CSS_IN_JS_EXTENSIONS.has(ext)) return [];
1975
+ const blocks = [];
1976
+ const re = /(?:styled(?:\.\w+|\([^)]+\))|css)\s*`([\s\S]*?)`/g;
1977
+ let m;
1978
+ while ((m = re.exec(content)) !== null) {
1979
+ const before = content.substring(0, m.index);
1980
+ const startLine = before.split('\n').length;
1981
+ blocks.push({ content: m[1], startLine });
1982
+ }
1983
+ return blocks;
1984
+ }
1985
+
1986
+ function runRegexMatchers(lines, filePath, lineOffset = 0, blockContext = null) {
1987
+ const findings = [];
1988
+ for (const matcher of REGEX_MATCHERS) {
1989
+ for (let i = 0; i < lines.length; i++) {
1990
+ const line = lines[i];
1991
+ matcher.regex.lastIndex = 0;
1992
+ let m;
1993
+ while ((m = matcher.regex.exec(line)) !== null) {
1994
+ // For extracted blocks, use nearby lines as context for multi-line CSS patterns
1995
+ const context = blockContext
1996
+ ? lines.slice(Math.max(0, i - 3), Math.min(lines.length, i + 4)).join(' ')
1997
+ : line;
1998
+ if (matcher.test(m, context)) {
1999
+ findings.push(finding(matcher.id, filePath, matcher.fmt(m, context), i + 1 + lineOffset));
2000
+ }
2001
+ }
2002
+ }
2003
+ }
2004
+ return findings;
2005
+ }
2006
+
2007
+ function detectText(content, filePath) {
2008
+ const findings = [];
2009
+ const lines = content.split('\n');
2010
+ const ext = filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : '';
2011
+
2012
+ // Run regex matchers on the full file content (catches Tailwind classes, inline styles)
2013
+ // Enable block context for CSS files where related properties span multiple lines
2014
+ const cssLike = new Set(['.css', '.scss', '.less']);
2015
+ findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null));
2016
+
2017
+ // Extract and scan <style> blocks from Vue/Svelte SFCs
2018
+ const styleBlocks = extractStyleBlocks(content, ext);
2019
+ for (const block of styleBlocks) {
2020
+ const blockLines = block.content.split('\n');
2021
+ findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true));
2022
+ }
2023
+
2024
+ // Extract and scan CSS-in-JS template literals
2025
+ const cssJsBlocks = extractCSSinJS(content, ext);
2026
+ for (const block of cssJsBlocks) {
2027
+ const blockLines = block.content.split('\n');
2028
+ findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true));
2029
+ }
2030
+
2031
+ // Deduplicate findings (same antipattern + similar snippet, within 2 lines)
2032
+ const deduped = [];
2033
+ for (const f of findings) {
2034
+ const isDupe = deduped.some(d =>
2035
+ d.antipattern === f.antipattern &&
2036
+ d.snippet === f.snippet &&
2037
+ Math.abs(d.line - f.line) <= 2
2038
+ );
2039
+ if (!isDupe) deduped.push(f);
2040
+ }
2041
+
2042
+ // Page-level analyzers only run on full pages
2043
+ if (isFullPage(content)) {
2044
+ for (const analyzer of REGEX_ANALYZERS) {
2045
+ deduped.push(...analyzer(content, filePath));
2046
+ }
2047
+ }
2048
+
2049
+ return deduped;
2050
+ }
2051
+
2052
+ // ---------------------------------------------------------------------------
2053
+ // File walker
2054
+ // ---------------------------------------------------------------------------
2055
+
2056
+ const SKIP_DIRS = new Set([
2057
+ 'node_modules', '.git', 'dist', 'build', '.next', '.nuxt', '.output',
2058
+ '.svelte-kit', '__pycache__', '.turbo', '.vercel',
2059
+ ]);
2060
+
2061
+ const SCANNABLE_EXTENSIONS = new Set([
2062
+ '.html', '.htm', '.css', '.scss', '.less',
2063
+ '.jsx', '.tsx', '.js', '.ts',
2064
+ '.vue', '.svelte', '.astro',
2065
+ ]);
2066
+
2067
+ const HTML_EXTENSIONS = new Set(['.html', '.htm']);
2068
+
2069
+ function walkDir(dir) {
2070
+ const files = [];
2071
+ let entries;
2072
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
2073
+ for (const entry of entries) {
2074
+ if (SKIP_DIRS.has(entry.name)) continue;
2075
+ const full = path.join(dir, entry.name);
2076
+ if (entry.isDirectory()) files.push(...walkDir(full));
2077
+ else if (SCANNABLE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) files.push(full);
2078
+ }
2079
+ return files;
2080
+ }
2081
+
2082
+ // ---------------------------------------------------------------------------
2083
+ // Output formatting
2084
+ // ---------------------------------------------------------------------------
2085
+
2086
+ function formatFindings(findings, jsonMode) {
2087
+ if (jsonMode) return JSON.stringify(findings, null, 2);
2088
+
2089
+ const grouped = {};
2090
+ for (const f of findings) {
2091
+ if (!grouped[f.file]) grouped[f.file] = [];
2092
+ grouped[f.file].push(f);
2093
+ }
2094
+ const out = [];
2095
+ for (const [file, items] of Object.entries(grouped)) {
2096
+ const importNote = items[0]?.importedBy?.length ? ` (imported by ${items[0].importedBy.join(', ')})` : '';
2097
+ out.push(`\n${file}${importNote}`);
2098
+ for (const item of items) {
2099
+ out.push(` ${item.line ? `line ${item.line}: ` : ''}[${item.antipattern}] ${item.snippet}`);
2100
+ out.push(` → ${item.description}`);
2101
+ }
2102
+ }
2103
+ out.push(`\n${findings.length} anti-pattern${findings.length === 1 ? '' : 's'} found.`);
2104
+ return out.join('\n');
2105
+ }
2106
+
2107
+ // ---------------------------------------------------------------------------
2108
+ // Stdin handling
2109
+ // ---------------------------------------------------------------------------
2110
+
2111
+ async function handleStdin() {
2112
+ const chunks = [];
2113
+ for await (const chunk of process.stdin) chunks.push(chunk);
2114
+ const input = Buffer.concat(chunks).toString('utf-8');
2115
+ try {
2116
+ const parsed = JSON.parse(input);
2117
+ const fp = parsed?.tool_input?.file_path;
2118
+ if (fp && fs.existsSync(fp)) {
2119
+ return HTML_EXTENSIONS.has(path.extname(fp).toLowerCase())
2120
+ ? detectHtml(fp) : detectText(fs.readFileSync(fp, 'utf-8'), fp);
2121
+ }
2122
+ } catch { /* not JSON */ }
2123
+ return detectText(input, '<stdin>');
2124
+ }
2125
+
2126
+ // ---------------------------------------------------------------------------
2127
+ // Import graph (multi-file awareness)
2128
+ // ---------------------------------------------------------------------------
2129
+
2130
+ function resolveImport(specifier, fromDir, fileSet) {
2131
+ if (!/^[./]/.test(specifier)) return null; // skip bare specifiers
2132
+ const base = path.resolve(fromDir, specifier);
2133
+ if (fileSet.has(base)) return base;
2134
+ for (const ext of SCANNABLE_EXTENSIONS) {
2135
+ const withExt = base + ext;
2136
+ if (fileSet.has(withExt)) return withExt;
2137
+ }
2138
+ // index file convention
2139
+ for (const ext of SCANNABLE_EXTENSIONS) {
2140
+ const indexFile = path.join(base, 'index' + ext);
2141
+ if (fileSet.has(indexFile)) return indexFile;
2142
+ }
2143
+ return null;
2144
+ }
2145
+
2146
+ function buildImportGraph(files) {
2147
+ const fileSet = new Set(files);
2148
+ const graph = new Map();
2149
+
2150
+ for (const file of files) {
2151
+ const content = fs.readFileSync(file, 'utf-8');
2152
+ const dir = path.dirname(file);
2153
+ const imports = new Set();
2154
+
2155
+ // ES imports: import ... from '...' and import '...'
2156
+ const esRe = /import\s+(?:[\s\S]*?from\s+)?['"]([^'"]+)['"]/g;
2157
+ let m;
2158
+ while ((m = esRe.exec(content)) !== null) {
2159
+ const resolved = resolveImport(m[1], dir, fileSet);
2160
+ if (resolved) imports.add(resolved);
2161
+ }
2162
+
2163
+ // CSS @import
2164
+ const cssRe = /@import\s+(?:url\(\s*)?['"]?([^'");\s]+)['"]?\s*\)?/g;
2165
+ while ((m = cssRe.exec(content)) !== null) {
2166
+ const resolved = resolveImport(m[1], dir, fileSet);
2167
+ if (resolved) imports.add(resolved);
2168
+ }
2169
+
2170
+ // SCSS @use / @forward
2171
+ const scssRe = /@(?:use|forward)\s+['"]([^'"]+)['"]/g;
2172
+ while ((m = scssRe.exec(content)) !== null) {
2173
+ const resolved = resolveImport(m[1], dir, fileSet);
2174
+ if (resolved) imports.add(resolved);
2175
+ }
2176
+
2177
+ graph.set(file, imports);
2178
+ }
2179
+ return graph;
2180
+ }
2181
+
2182
+ // ---------------------------------------------------------------------------
2183
+ // Framework dev server detection
2184
+ // ---------------------------------------------------------------------------
2185
+
2186
+ const FRAMEWORK_CONFIGS = [
2187
+ { name: 'Next.js', files: ['next.config.js', 'next.config.mjs', 'next.config.ts'], defaultPort: 3000,
2188
+ portRe: /port\s*[:=]\s*(\d+)/,
2189
+ fingerprint: { header: 'x-powered-by', value: /next/i } },
2190
+ { name: 'SvelteKit', files: ['svelte.config.js', 'svelte.config.ts'], defaultPort: 5173,
2191
+ portRe: /port\s*[:=]\s*(\d+)/,
2192
+ fingerprint: { header: 'x-sveltekit-page', value: null } },
2193
+ { name: 'Nuxt', files: ['nuxt.config.js', 'nuxt.config.ts'], defaultPort: 3000,
2194
+ portRe: /port\s*[:=]\s*(\d+)/,
2195
+ fingerprint: { header: 'x-powered-by', value: /nuxt/i } },
2196
+ { name: 'Vite', files: ['vite.config.js', 'vite.config.ts', 'vite.config.mjs'], defaultPort: 5173,
2197
+ portRe: /port\s*[:=]\s*(\d+)/,
2198
+ fingerprint: { body: /@vite\/client/ } },
2199
+ { name: 'Astro', files: ['astro.config.js', 'astro.config.ts', 'astro.config.mjs'], defaultPort: 4321,
2200
+ portRe: /port\s*[:=]\s*(\d+)/,
2201
+ fingerprint: { body: /astro/i } },
2202
+ { name: 'Angular', files: ['angular.json'], defaultPort: 4200,
2203
+ portRe: /"port"\s*:\s*(\d+)/,
2204
+ fingerprint: { body: /ng-version/i } },
2205
+ { name: 'Remix', files: ['remix.config.js', 'remix.config.ts'], defaultPort: 3000,
2206
+ portRe: /port\s*[:=]\s*(\d+)/,
2207
+ fingerprint: { header: 'x-powered-by', value: /remix/i } },
2208
+ ];
2209
+
2210
+ function detectFrameworkConfig(dir) {
2211
+ let entries;
2212
+ try { entries = fs.readdirSync(dir); } catch { return null; }
2213
+ const entrySet = new Set(entries);
2214
+
2215
+ for (const cfg of FRAMEWORK_CONFIGS) {
2216
+ const match = cfg.files.find(f => entrySet.has(f));
2217
+ if (!match) continue;
2218
+
2219
+ const configPath = path.join(dir, match);
2220
+ let port = cfg.defaultPort;
2221
+ try {
2222
+ const content = fs.readFileSync(configPath, 'utf-8');
2223
+ const portMatch = content.match(cfg.portRe);
2224
+ if (portMatch) port = parseInt(portMatch[1], 10);
2225
+ } catch { /* use default */ }
2226
+
2227
+ return { name: cfg.name, port, configPath, fingerprint: cfg.fingerprint };
2228
+ }
2229
+ return null;
2230
+ }
2231
+
2232
+ /**
2233
+ * Check if a port is listening and optionally verify it matches the expected framework.
2234
+ * Returns { listening: true, matched: true/false } or { listening: false }.
2235
+ */
2236
+ async function isPortListening(port, fingerprint = null) {
2237
+ if (!fingerprint) {
2238
+ // Simple TCP probe fallback
2239
+ const net = await import('node:net');
2240
+ return new Promise((resolve) => {
2241
+ const sock = net.default.createConnection({ port, host: '127.0.0.1' });
2242
+ sock.setTimeout(500);
2243
+ sock.on('connect', () => { sock.destroy(); resolve({ listening: true, matched: true }); });
2244
+ sock.on('error', () => resolve({ listening: false }));
2245
+ sock.on('timeout', () => { sock.destroy(); resolve({ listening: false }); });
2246
+ });
2247
+ }
2248
+
2249
+ // HTTP probe with fingerprint matching
2250
+ try {
2251
+ const controller = new AbortController();
2252
+ const timeout = setTimeout(() => controller.abort(), 2000);
2253
+ const res = await fetch(`http://localhost:${port}/`, { signal: controller.signal, redirect: 'follow' });
2254
+ clearTimeout(timeout);
2255
+
2256
+ // Check header fingerprint
2257
+ if (fingerprint.header) {
2258
+ const val = res.headers.get(fingerprint.header);
2259
+ if (val && (!fingerprint.value || fingerprint.value.test(val))) {
2260
+ return { listening: true, matched: true };
2261
+ }
2262
+ }
2263
+
2264
+ // Check body fingerprint
2265
+ if (fingerprint.body) {
2266
+ const body = await res.text();
2267
+ if (fingerprint.body.test(body)) {
2268
+ return { listening: true, matched: true };
2269
+ }
2270
+ }
2271
+
2272
+ // Port is listening but doesn't match the expected framework
2273
+ return { listening: true, matched: false };
2274
+ } catch {
2275
+ return { listening: false };
2276
+ }
2277
+ }
2278
+
2279
+ // ---------------------------------------------------------------------------
2280
+ // CLI
2281
+ // ---------------------------------------------------------------------------
2282
+
2283
+ async function confirm(question) {
2284
+ const rl = (await import('node:readline')).default.createInterface({
2285
+ input: process.stdin, output: process.stderr,
2286
+ });
2287
+ return new Promise((resolve) => {
2288
+ rl.question(`${question} [Y/n] `, (answer) => {
2289
+ rl.close();
2290
+ resolve(!answer || /^y(es)?$/i.test(answer.trim()));
2291
+ });
2292
+ });
2293
+ }
2294
+
2295
+ function printUsage() {
2296
+ console.log(`Usage: impeccable detect [options] [file-or-dir-or-url...]
2297
+
2298
+ Scan files or URLs for UI anti-patterns and design quality issues.
2299
+
2300
+ Options:
2301
+ --fast Regex-only mode (skip jsdom, faster but misses linked stylesheets)
2302
+ --json Output results as JSON
2303
+ --help Show this help message
2304
+
2305
+ Detection modes:
2306
+ HTML files jsdom with computed styles (default, catches linked CSS)
2307
+ Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.)
2308
+ URLs Puppeteer full browser rendering (auto-detected)
2309
+ --fast Forces regex for all files
2310
+
2311
+ Examples:
2312
+ impeccable detect src/
2313
+ impeccable detect index.html
2314
+ impeccable detect https://example.com
2315
+ impeccable detect --fast --json .`);
2316
+ }
2317
+
2318
+ async function main() {
2319
+ const args = process.argv.slice(2);
2320
+ const jsonMode = args.includes('--json');
2321
+ const helpMode = args.includes('--help');
2322
+ const fastMode = args.includes('--fast');
2323
+ const targets = args.filter(a => !a.startsWith('--'));
2324
+
2325
+ if (helpMode) { printUsage(); process.exit(0); }
2326
+
2327
+ let allFindings = [];
2328
+
2329
+ if (!process.stdin.isTTY && targets.length === 0) {
2330
+ allFindings = await handleStdin();
2331
+ } else {
2332
+ const paths = targets.length > 0 ? targets : [process.cwd()];
2333
+
2334
+ for (const target of paths) {
2335
+ if (/^https?:\/\//i.test(target)) {
2336
+ try { allFindings.push(...await detectUrl(target)); }
2337
+ catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
2338
+ continue;
2339
+ }
2340
+
2341
+ const resolved = path.resolve(target);
2342
+ let stat;
2343
+ try { stat = fs.statSync(resolved); }
2344
+ catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
2345
+
2346
+ if (stat.isDirectory()) {
2347
+ // Check for framework dev server config (skip in JSON mode to avoid polluting output)
2348
+ if (!jsonMode) {
2349
+ const fwConfig = detectFrameworkConfig(resolved);
2350
+ if (fwConfig) {
2351
+ const probe = await isPortListening(fwConfig.port, fwConfig.fingerprint);
2352
+ if (probe.listening && probe.matched) {
2353
+ process.stderr.write(
2354
+ `\n${fwConfig.name} dev server detected on localhost:${fwConfig.port}.\n` +
2355
+ `For more accurate results, scan the running site:\n` +
2356
+ ` npx impeccable detect http://localhost:${fwConfig.port}\n\n`
2357
+ );
2358
+ } else if (probe.listening && !probe.matched) {
2359
+ process.stderr.write(
2360
+ `\n${fwConfig.name} project detected (${path.basename(fwConfig.configPath)}).\n` +
2361
+ `Port ${fwConfig.port} is in use by another service. Start the ${fwConfig.name} dev server and scan via URL for best results.\n\n`
2362
+ );
2363
+ } else {
2364
+ process.stderr.write(
2365
+ `\n${fwConfig.name} project detected (${path.basename(fwConfig.configPath)}).\n` +
2366
+ `Start the dev server and scan via URL for best results:\n` +
2367
+ ` npx impeccable detect http://localhost:${fwConfig.port}\n\n`
2368
+ );
2369
+ }
2370
+ }
2371
+ }
2372
+
2373
+ const files = walkDir(resolved);
2374
+ const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
2375
+
2376
+ // Warn and confirm if scanning many files (jsdom is slow per HTML file)
2377
+ if (files.length > 50 && process.stdin.isTTY && !jsonMode) {
2378
+ process.stderr.write(
2379
+ `\nFound ${files.length} files (${htmlCount} HTML) in ${target}.\n` +
2380
+ `Scanning may take a while${htmlCount > 10 ? ' (jsdom processes each HTML file individually)' : ''}.\n` +
2381
+ `Use --fast to skip jsdom, or target a specific subdirectory.\n`
2382
+ );
2383
+ const ok = await confirm('Continue?');
2384
+ if (!ok) { process.stderr.write('Aborted.\n'); process.exit(0); }
2385
+ }
2386
+
2387
+ // Build import graph for multi-file awareness
2388
+ const graph = buildImportGraph(files);
2389
+ // Build reverse map: file -> set of files that import it
2390
+ const importedByMap = new Map();
2391
+ for (const [importer, imports] of graph) {
2392
+ for (const imported of imports) {
2393
+ if (!importedByMap.has(imported)) importedByMap.set(imported, new Set());
2394
+ importedByMap.get(imported).add(importer);
2395
+ }
2396
+ }
2397
+
2398
+ for (const file of files) {
2399
+ const ext = path.extname(file).toLowerCase();
2400
+ let fileFindings;
2401
+ if (!fastMode && HTML_EXTENSIONS.has(ext)) {
2402
+ fileFindings = await detectHtml(file);
2403
+ } else {
2404
+ fileFindings = detectText(fs.readFileSync(file, 'utf-8'), file);
2405
+ }
2406
+ // Annotate findings with import context
2407
+ const importers = importedByMap.get(file);
2408
+ if (importers && importers.size > 0) {
2409
+ const importerNames = [...importers].map(f => path.basename(f));
2410
+ for (const f of fileFindings) {
2411
+ f.importedBy = importerNames;
2412
+ }
2413
+ }
2414
+ allFindings.push(...fileFindings);
2415
+ }
2416
+ } else if (stat.isFile()) {
2417
+ const ext = path.extname(resolved).toLowerCase();
2418
+ if (!fastMode && HTML_EXTENSIONS.has(ext)) {
2419
+ allFindings.push(...await detectHtml(resolved));
2420
+ } else {
2421
+ allFindings.push(...detectText(fs.readFileSync(resolved, 'utf-8'), resolved));
2422
+ }
2423
+ }
2424
+ }
2425
+ }
2426
+
2427
+ if (allFindings.length > 0) {
2428
+ process.stderr.write(formatFindings(allFindings, jsonMode) + '\n');
2429
+ process.exit(2);
2430
+ }
2431
+ if (jsonMode) process.stdout.write('[]\n');
2432
+ process.exit(0);
2433
+ }
2434
+
2435
+ // ---------------------------------------------------------------------------
2436
+ // Live detection server
2437
+ // ---------------------------------------------------------------------------
2438
+
2439
+ async function findOpenPort(start = 8400) {
2440
+ const net = await import('node:net');
2441
+ return new Promise((resolve) => {
2442
+ const server = net.default.createServer();
2443
+ server.listen(start, '127.0.0.1', () => {
2444
+ const port = server.address().port;
2445
+ server.close(() => resolve(port));
2446
+ });
2447
+ server.on('error', () => resolve(findOpenPort(start + 1)));
2448
+ });
2449
+ }
2450
+
2451
+ async function liveCli() {
2452
+ const args = process.argv.slice(2);
2453
+ const helpMode = args.includes('--help');
2454
+ const portArg = args.find(a => a.startsWith('--port='));
2455
+ const requestedPort = portArg ? parseInt(portArg.split('=')[1], 10) : null;
2456
+
2457
+ if (helpMode) {
2458
+ console.log(`Usage: impeccable-detect live [options]
2459
+
2460
+ Start a local server that serves the browser detection overlay script.
2461
+ Inject the script into any page to scan for anti-patterns in real time.
2462
+
2463
+ Options:
2464
+ --port=PORT Use a specific port (default: auto-detect unused port)
2465
+ --help Show this help message
2466
+
2467
+ The server provides:
2468
+ /detect.js The detection overlay script (inject via <script> tag)
2469
+ /scan Trigger a scan and return JSON results`);
2470
+ process.exit(0);
2471
+ }
2472
+
2473
+ const http = await import('node:http');
2474
+ const scriptPath = path.join(path.dirname(new URL(import.meta.url).pathname), 'detect-antipatterns-browser.js');
2475
+
2476
+ let browserScript;
2477
+ try {
2478
+ browserScript = fs.readFileSync(scriptPath, 'utf-8');
2479
+ } catch {
2480
+ process.stderr.write('Error: Browser script not found. Run `npm run build:browser` first.\n');
2481
+ process.exit(1);
2482
+ }
2483
+
2484
+ const port = requestedPort || await findOpenPort();
2485
+
2486
+ const server = http.default.createServer((req, res) => {
2487
+ // CORS headers for cross-origin injection
2488
+ res.setHeader('Access-Control-Allow-Origin', '*');
2489
+ res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
2490
+ if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
2491
+
2492
+ if (req.url === '/detect.js' || req.url === '/') {
2493
+ res.writeHead(200, { 'Content-Type': 'application/javascript' });
2494
+ res.end(browserScript);
2495
+ } else if (req.url === '/health') {
2496
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2497
+ res.end(JSON.stringify({ status: 'ok', version: '2.0.2' }));
2498
+ } else {
2499
+ res.writeHead(404);
2500
+ res.end('Not found');
2501
+ }
2502
+ });
2503
+
2504
+ server.listen(port, '127.0.0.1', () => {
2505
+ const url = `http://localhost:${port}`;
2506
+ console.log(`Impeccable live detection server running on ${url}\n`);
2507
+ console.log(`Inject into any page:`);
2508
+ console.log(` const s = document.createElement('script');`);
2509
+ console.log(` s.src = '${url}/detect.js';`);
2510
+ console.log(` document.head.appendChild(s);\n`);
2511
+ console.log(`Or add to HTML:`);
2512
+ console.log(` <script src="${url}/detect.js"><\/script>\n`);
2513
+ console.log(`Press Ctrl+C to stop.`);
2514
+ });
2515
+
2516
+ // Graceful shutdown
2517
+ process.on('SIGINT', () => { server.close(); process.exit(0); });
2518
+ process.on('SIGTERM', () => { server.close(); process.exit(0); });
2519
+ }
2520
+
2521
+ // ---------------------------------------------------------------------------
2522
+ // Entry point
2523
+ // ---------------------------------------------------------------------------
2524
+
2525
+ if (!IS_BROWSER) {
2526
+ const isMainModule = process.argv[1]?.endsWith('detect-antipatterns.mjs') ||
2527
+ process.argv[1]?.endsWith('detect-antipatterns.mjs/');
2528
+ if (isMainModule) main();
2529
+ }
2530
+
2531
+ // @browser-strip-end
2532
+
2533
+ // ─── Section 9: Exports ─────────────────────────────────────────────────────
2534
+ // @browser-strip-start
2535
+
2536
+ export {
2537
+ ANTIPATTERNS, SAFE_TAGS, OVERUSED_FONTS, GENERIC_FONTS,
2538
+ checkElementBorders, checkElementMotion, checkElementGlow, checkPageTypography, checkPageLayout, isNeutralColor, isFullPage,
2539
+ detectHtml, detectUrl, detectText,
2540
+ walkDir, formatFindings, SCANNABLE_EXTENSIONS, SKIP_DIRS,
2541
+ extractStyleBlocks, extractCSSinJS,
2542
+ buildImportGraph, resolveImport,
2543
+ detectFrameworkConfig, isPortListening, FRAMEWORK_CONFIGS,
2544
+ main as detectCli,
2545
+ liveCli,
2546
+ };
2547
+
2548
+ // @browser-strip-end