@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,1579 @@
1
+ /**
2
+ * Anti-Pattern Browser Detector for Impeccable
3
+ * GENERATED — do not edit. Source: detect-antipatterns.mjs
4
+ * Rebuild: node scripts/build-browser-detector.js
5
+ *
6
+ * Usage: <script src="detect-antipatterns-browser.js"></script>
7
+ * Re-scan: window.impeccableScan()
8
+ */
9
+ (function () {
10
+ if (typeof window === 'undefined') return;
11
+
12
+ /**
13
+ * Anti-Pattern Detector for Impeccable
14
+ *
15
+ * Universal file — auto-detects environment (browser vs Node) and adapts.
16
+ *
17
+ * Node usage:
18
+ * node detect-antipatterns.mjs [file-or-dir...] # jsdom for HTML, regex for rest
19
+ * node detect-antipatterns.mjs https://... # Puppeteer (auto)
20
+ * node detect-antipatterns.mjs --fast [files...] # regex-only (skip jsdom)
21
+ * node detect-antipatterns.mjs --json # JSON output
22
+ *
23
+ * Browser usage:
24
+ * <script src="detect-antipatterns-browser.js"></script>
25
+ * Re-scan: window.impeccableScan()
26
+ *
27
+ * Exit codes: 0 = clean, 2 = findings
28
+ */
29
+
30
+ // ─── Environment ────────────────────────────────────────────────────────────
31
+
32
+ const IS_BROWSER = true;
33
+ const IS_NODE = !IS_BROWSER;
34
+
35
+
36
+ // ─── Section 1: Constants ───────────────────────────────────────────────────
37
+
38
+ const SAFE_TAGS = new Set([
39
+ 'blockquote', 'nav', 'a', 'input', 'textarea', 'select',
40
+ 'pre', 'code', 'span', 'th', 'td', 'tr', 'li', 'label',
41
+ 'button', 'hr', 'html', 'head', 'body', 'script', 'style',
42
+ 'link', 'meta', 'title', 'br', 'img', 'svg', 'path', 'circle',
43
+ 'rect', 'line', 'polyline', 'polygon', 'g', 'defs', 'use',
44
+ ]);
45
+
46
+ const OVERUSED_FONTS = new Set([
47
+ 'inter', 'roboto', 'open sans', 'lato', 'montserrat', 'arial', 'helvetica',
48
+ ]);
49
+
50
+ const GENERIC_FONTS = new Set([
51
+ 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy',
52
+ 'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
53
+ '-apple-system', 'blinkmacsystemfont', 'segoe ui',
54
+ 'inherit', 'initial', 'unset', 'revert',
55
+ ]);
56
+
57
+ const ANTIPATTERNS = [
58
+ {
59
+ id: 'side-tab',
60
+ name: 'Side-tab accent border',
61
+ description:
62
+ '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.',
63
+ },
64
+ {
65
+ id: 'border-accent-on-rounded',
66
+ name: 'Border accent on rounded element',
67
+ description:
68
+ 'Thick accent border on a rounded card — the border clashes with the rounded corners. Remove the border or the border-radius.',
69
+ },
70
+ {
71
+ id: 'overused-font',
72
+ name: 'Overused font',
73
+ description:
74
+ 'Inter, Roboto, Open Sans, Lato, Montserrat, and Arial are used on millions of sites. Choose a distinctive font that gives your interface personality.',
75
+ },
76
+ {
77
+ id: 'single-font',
78
+ name: 'Single font for everything',
79
+ description:
80
+ 'Only one font family is used for the entire page. Pair a distinctive display font with a refined body font to create typographic hierarchy.',
81
+ },
82
+ {
83
+ id: 'flat-type-hierarchy',
84
+ name: 'Flat type hierarchy',
85
+ description:
86
+ '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).',
87
+ },
88
+ {
89
+ id: 'pure-black-white',
90
+ name: 'Pure black background',
91
+ description:
92
+ '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.',
93
+ },
94
+ {
95
+ id: 'gray-on-color',
96
+ name: 'Gray text on colored background',
97
+ description:
98
+ 'Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.',
99
+ },
100
+ {
101
+ id: 'low-contrast',
102
+ name: 'Low contrast text',
103
+ description:
104
+ '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.',
105
+ },
106
+ {
107
+ id: 'gradient-text',
108
+ name: 'Gradient text',
109
+ description:
110
+ 'Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.',
111
+ },
112
+ {
113
+ id: 'ai-color-palette',
114
+ name: 'AI color palette',
115
+ description:
116
+ 'Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.',
117
+ },
118
+ {
119
+ id: 'nested-cards',
120
+ name: 'Nested cards',
121
+ description:
122
+ 'Cards inside cards create visual noise and excessive depth. Flatten the hierarchy — use spacing, typography, and dividers instead of nesting containers.',
123
+ },
124
+ {
125
+ id: 'monotonous-spacing',
126
+ name: 'Monotonous spacing',
127
+ description:
128
+ 'The same spacing value used everywhere — no rhythm, no variation. Use tight groupings for related items and generous separations between sections.',
129
+ },
130
+ {
131
+ id: 'everything-centered',
132
+ name: 'Everything centered',
133
+ description:
134
+ 'Every text element is center-aligned. Left-aligned text with asymmetric layouts feels more designed. Center only hero sections and CTAs.',
135
+ },
136
+ {
137
+ id: 'bounce-easing',
138
+ name: 'Bounce or elastic easing',
139
+ description:
140
+ 'Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.',
141
+ },
142
+ {
143
+ id: 'layout-transition',
144
+ name: 'Layout property animation',
145
+ description:
146
+ 'Animating width, height, padding, or margin causes layout thrash and janky performance. Use transform and opacity instead, or grid-template-rows for height animations.',
147
+ },
148
+ {
149
+ id: 'dark-glow',
150
+ name: 'Dark mode with glowing accents',
151
+ description:
152
+ '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.',
153
+ },
154
+ {
155
+ id: 'line-length',
156
+ name: 'Line length too long',
157
+ description:
158
+ '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.',
159
+ },
160
+ {
161
+ id: 'cramped-padding',
162
+ name: 'Cramped padding',
163
+ description:
164
+ 'Text is too close to the edge of its container. Add at least 8px (ideally 12-16px) of padding inside bordered or colored containers.',
165
+ },
166
+ {
167
+ id: 'tight-leading',
168
+ name: 'Tight line height',
169
+ description:
170
+ '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.',
171
+ },
172
+ {
173
+ id: 'skipped-heading',
174
+ name: 'Skipped heading level',
175
+ description:
176
+ '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.',
177
+ },
178
+ {
179
+ id: 'justified-text',
180
+ name: 'Justified text',
181
+ description:
182
+ '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.',
183
+ },
184
+ {
185
+ id: 'tiny-text',
186
+ name: 'Tiny body text',
187
+ description:
188
+ 'Body text below 12px is hard to read, especially on high-DPI screens. Use at least 14px for body content, 16px is ideal.',
189
+ },
190
+ {
191
+ id: 'all-caps-body',
192
+ name: 'All-caps body text',
193
+ description:
194
+ '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.',
195
+ },
196
+ {
197
+ id: 'wide-tracking',
198
+ name: 'Wide letter spacing on body text',
199
+ description:
200
+ 'Letter spacing above 0.05em on body text disrupts natural character groupings and slows reading. Reserve wide tracking for short uppercase labels only.',
201
+ },
202
+ ];
203
+
204
+ // ─── Section 2: Color Utilities ─────────────────────────────────────────────
205
+
206
+ function isNeutralColor(color) {
207
+ if (!color || color === 'transparent') return true;
208
+ const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
209
+ if (!m) return true;
210
+ return (Math.max(+m[1], +m[2], +m[3]) - Math.min(+m[1], +m[2], +m[3])) < 30;
211
+ }
212
+
213
+ function parseRgb(color) {
214
+ if (!color || color === 'transparent') return null;
215
+ const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
216
+ if (!m) return null;
217
+ return { r: +m[1], g: +m[2], b: +m[3], a: m[4] !== undefined ? +m[4] : 1 };
218
+ }
219
+
220
+ function relativeLuminance({ r, g, b }) {
221
+ const [rs, gs, bs] = [r / 255, g / 255, b / 255].map(c =>
222
+ c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
223
+ );
224
+ return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
225
+ }
226
+
227
+ function contrastRatio(c1, c2) {
228
+ const l1 = relativeLuminance(c1);
229
+ const l2 = relativeLuminance(c2);
230
+ return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
231
+ }
232
+
233
+ function parseGradientColors(bgImage) {
234
+ if (!bgImage || !bgImage.includes('gradient')) return [];
235
+ return [...bgImage.matchAll(/rgba?\([^)]+\)/g)]
236
+ .map(m => parseRgb(m[0]))
237
+ .filter(Boolean);
238
+ }
239
+
240
+ function hasChroma(c, threshold = 30) {
241
+ if (!c) return false;
242
+ return (Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b)) >= threshold;
243
+ }
244
+
245
+ function getHue(c) {
246
+ if (!c) return 0;
247
+ const r = c.r / 255, g = c.g / 255, b = c.b / 255;
248
+ const max = Math.max(r, g, b), min = Math.min(r, g, b);
249
+ if (max === min) return 0;
250
+ const d = max - min;
251
+ let h;
252
+ if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
253
+ else if (max === g) h = ((b - r) / d + 2) / 6;
254
+ else h = ((r - g) / d + 4) / 6;
255
+ return Math.round(h * 360);
256
+ }
257
+
258
+ function colorToHex(c) {
259
+ if (!c) return '?';
260
+ return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');
261
+ }
262
+
263
+ // ─── Section 3: Pure Detection ──────────────────────────────────────────────
264
+
265
+ function checkBorders(tag, widths, colors, radius) {
266
+ if (SAFE_TAGS.has(tag)) return [];
267
+ const findings = [];
268
+ const sides = ['Top', 'Right', 'Bottom', 'Left'];
269
+
270
+ for (const side of sides) {
271
+ const w = widths[side];
272
+ if (w < 1 || isNeutralColor(colors[side])) continue;
273
+
274
+ const otherSides = sides.filter(s => s !== side);
275
+ const maxOther = Math.max(...otherSides.map(s => widths[s]));
276
+ if (!(w >= 2 && (maxOther <= 1 || w >= maxOther * 2))) continue;
277
+
278
+ const sn = side.toLowerCase();
279
+ const isSide = side === 'Left' || side === 'Right';
280
+
281
+ if (isSide) {
282
+ if (radius > 0) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` });
283
+ else if (w >= 3) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px` });
284
+ } else {
285
+ if (radius > 0 && w >= 2) findings.push({ id: 'border-accent-on-rounded', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` });
286
+ }
287
+ }
288
+
289
+ return findings;
290
+ }
291
+
292
+ function checkColors(opts) {
293
+ const { tag, textColor, bgColor, effectiveBg, fontSize, fontWeight, hasDirectText, bgClip, bgImage, classList } = opts;
294
+ if (SAFE_TAGS.has(tag)) return [];
295
+ const findings = [];
296
+
297
+ // Pure black background (only solid or near-solid, not semi-transparent overlays)
298
+ if (bgColor && bgColor.a >= 0.9 && bgColor.r === 0 && bgColor.g === 0 && bgColor.b === 0) {
299
+ findings.push({ id: 'pure-black-white', snippet: '#000000 background' });
300
+ }
301
+
302
+ if (hasDirectText && textColor) {
303
+ // Skip background-dependent checks if we can't determine the background (e.g. gradient)
304
+ if (effectiveBg) {
305
+ // Gray on colored background
306
+ const textLum = relativeLuminance(textColor);
307
+ const isGray = !hasChroma(textColor, 20) && textLum > 0.05 && textLum < 0.85;
308
+ if (isGray && hasChroma(effectiveBg, 40)) {
309
+ findings.push({ id: 'gray-on-color', snippet: `text ${colorToHex(textColor)} on bg ${colorToHex(effectiveBg)}` });
310
+ }
311
+
312
+ // Low contrast (WCAG AA)
313
+ const ratio = contrastRatio(textColor, effectiveBg);
314
+ const isHeading = ['h1', 'h2', 'h3'].includes(tag);
315
+ const isLargeText = fontSize >= 18 || (fontSize >= 14 && fontWeight >= 700) || isHeading;
316
+ const threshold = isLargeText ? 3.0 : 4.5;
317
+ if (ratio < threshold) {
318
+ findings.push({ id: 'low-contrast', snippet: `${ratio.toFixed(1)}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(effectiveBg)}` });
319
+ }
320
+ }
321
+
322
+ // AI palette: purple/violet on headings
323
+ if (hasChroma(textColor, 50)) {
324
+ const hue = getHue(textColor);
325
+ if (hue >= 260 && hue <= 310 && (['h1', 'h2', 'h3'].includes(tag) || fontSize >= 20)) {
326
+ findings.push({ id: 'ai-color-palette', snippet: `Purple/violet text (${colorToHex(textColor)}) on heading` });
327
+ }
328
+ }
329
+ }
330
+
331
+ // Gradient text
332
+ if (bgClip === 'text' && bgImage && bgImage.includes('gradient')) {
333
+ findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
334
+ }
335
+
336
+ // Tailwind class checks
337
+ if (classList) {
338
+ const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
339
+ if (/\bbg-black\b/.test(classStr)) {
340
+ findings.push({ id: 'pure-black-white', snippet: 'bg-black' });
341
+ }
342
+
343
+ const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
344
+ const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
345
+ if (grayMatch && colorBgMatch) {
346
+ findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
347
+ }
348
+
349
+ if (/\bbg-clip-text\b/.test(classStr) && /\bbg-gradient-to-/.test(classStr)) {
350
+ findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' });
351
+ }
352
+
353
+ const purpleText = classStr.match(/\btext-(?:purple|violet|indigo)-\d+\b/);
354
+ if (purpleText && (['h1', 'h2', 'h3'].includes(tag) || /\btext-(?:[2-9]xl)\b/.test(classStr))) {
355
+ findings.push({ id: 'ai-color-palette', snippet: `${purpleText[0]} on heading` });
356
+ }
357
+
358
+ if (/\bfrom-(?:purple|violet|indigo)-\d+\b/.test(classStr) && /\bto-(?:purple|violet|indigo|blue|cyan|pink|fuchsia)-\d+\b/.test(classStr)) {
359
+ findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet gradient (Tailwind)' });
360
+ }
361
+ }
362
+
363
+ return findings;
364
+ }
365
+
366
+ function isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg) {
367
+ if (!hasShadow && !hasBorder) return false;
368
+ return hasRadius || hasBg;
369
+ }
370
+
371
+ const LAYOUT_TRANSITION_PROPS = new Set([
372
+ 'width', 'height', 'padding', 'margin',
373
+ 'max-height', 'max-width', 'min-height', 'min-width',
374
+ 'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
375
+ 'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
376
+ ]);
377
+
378
+ function checkMotion(opts) {
379
+ const { tag, transitionProperty, animationName, timingFunctions, classList } = opts;
380
+ if (SAFE_TAGS.has(tag)) return [];
381
+ const findings = [];
382
+
383
+ // --- Bounce/elastic easing ---
384
+ if (animationName && animationName !== 'none' && /bounce|elastic|wobble|jiggle|spring/i.test(animationName)) {
385
+ findings.push({ id: 'bounce-easing', snippet: `animation: ${animationName}` });
386
+ }
387
+ if (classList && /\banimate-bounce\b/.test(classList)) {
388
+ findings.push({ id: 'bounce-easing', snippet: 'animate-bounce (Tailwind)' });
389
+ }
390
+
391
+ // Check timing functions for overshoot cubic-bezier (y values outside [0, 1])
392
+ if (timingFunctions) {
393
+ const bezierRe = /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g;
394
+ let m;
395
+ while ((m = bezierRe.exec(timingFunctions)) !== null) {
396
+ const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]);
397
+ if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) {
398
+ findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${m[1]}, ${m[2]}, ${m[3]}, ${m[4]})` });
399
+ break;
400
+ }
401
+ }
402
+ }
403
+
404
+ // --- Layout property transition ---
405
+ if (transitionProperty && transitionProperty !== 'all' && transitionProperty !== 'none') {
406
+ const props = transitionProperty.split(',').map(p => p.trim().toLowerCase());
407
+ const layoutFound = props.filter(p => LAYOUT_TRANSITION_PROPS.has(p));
408
+ if (layoutFound.length > 0) {
409
+ findings.push({ id: 'layout-transition', snippet: `transition: ${layoutFound.join(', ')}` });
410
+ }
411
+ }
412
+
413
+ return findings;
414
+ }
415
+
416
+ function checkGlow(opts) {
417
+ const { boxShadow, effectiveBg } = opts;
418
+ if (!boxShadow || boxShadow === 'none') return [];
419
+ if (!effectiveBg) return [];
420
+
421
+ // Only flag on dark backgrounds (luminance < 0.1)
422
+ const bgLum = relativeLuminance(effectiveBg);
423
+ if (bgLum >= 0.1) return [];
424
+
425
+ // Split multiple shadows (commas not inside parentheses)
426
+ const parts = boxShadow.split(/,(?![^(]*\))/);
427
+ for (const shadow of parts) {
428
+ const colorMatch = shadow.match(/rgba?\([^)]+\)/);
429
+ if (!colorMatch) continue;
430
+ const color = parseRgb(colorMatch[0]);
431
+ if (!color || !hasChroma(color, 30)) continue;
432
+
433
+ // Extract px values — in computed style: "color Xpx Ypx BLURpx [SPREADpx]"
434
+ const afterColor = shadow.substring(shadow.indexOf(colorMatch[0]) + colorMatch[0].length);
435
+ const beforeColor = shadow.substring(0, shadow.indexOf(colorMatch[0]));
436
+ const pxVals = [...beforeColor.matchAll(/([\d.]+)px/g), ...afterColor.matchAll(/([\d.]+)px/g)]
437
+ .map(m => parseFloat(m[1]));
438
+
439
+ // Third value is blur (offset-x, offset-y, blur, [spread])
440
+ if (pxVals.length >= 3 && pxVals[2] > 4) {
441
+ return [{ id: 'dark-glow', snippet: `Colored glow (${colorToHex(color)}) on dark background` }];
442
+ }
443
+ }
444
+
445
+ return [];
446
+ }
447
+
448
+ /**
449
+ * Regex-on-HTML checks shared between browser and Node page-level detection.
450
+ * These don't need DOM access, just the raw HTML string.
451
+ */
452
+ function checkHtmlPatterns(html) {
453
+ const findings = [];
454
+
455
+ // --- Color ---
456
+
457
+ // Pure black background
458
+ const pureBlackBgRe = /background(?:-color)?\s*:\s*(?:#000000|#000|rgb\(\s*0,\s*0,\s*0\s*\))\b/gi;
459
+ if (pureBlackBgRe.test(html)) {
460
+ findings.push({ id: 'pure-black-white', snippet: 'Pure #000 background' });
461
+ }
462
+
463
+ // AI color palette: purple/violet
464
+ const purpleHexRe = /#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9|6366f1|764ba2|667eea)\b/gi;
465
+ if (purpleHexRe.test(html)) {
466
+ const purpleTextRe = /(?:(?:^|;)\s*color\s*:\s*(?:.*?)(?:#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9))|gradient.*?#(?:7c3aed|8b5cf6|a855f7|764ba2|667eea))/gi;
467
+ if (purpleTextRe.test(html)) {
468
+ findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected' });
469
+ }
470
+ }
471
+
472
+ // Gradient text (background-clip: text + gradient)
473
+ const gradientRe = /(?:-webkit-)?background-clip\s*:\s*text/gi;
474
+ let gm;
475
+ while ((gm = gradientRe.exec(html)) !== null) {
476
+ const start = Math.max(0, gm.index - 200);
477
+ const context = html.substring(start, gm.index + gm[0].length + 200);
478
+ if (/gradient/i.test(context)) {
479
+ findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
480
+ break;
481
+ }
482
+ }
483
+ if (/\bbg-clip-text\b/.test(html) && /\bbg-gradient-to-/.test(html)) {
484
+ findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' });
485
+ }
486
+
487
+ // --- Layout ---
488
+
489
+ // Monotonous spacing
490
+ const spacingValues = [];
491
+ const spacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*(\d+)px/gi;
492
+ let sm;
493
+ while ((sm = spacingRe.exec(html)) !== null) {
494
+ const v = parseInt(sm[1], 10);
495
+ if (v > 0 && v < 200) spacingValues.push(v);
496
+ }
497
+ const gapRe = /gap\s*:\s*(\d+)px/gi;
498
+ while ((sm = gapRe.exec(html)) !== null) {
499
+ spacingValues.push(parseInt(sm[1], 10));
500
+ }
501
+ const twSpaceRe = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+)\b/g;
502
+ while ((sm = twSpaceRe.exec(html)) !== null) {
503
+ spacingValues.push(parseInt(sm[1], 10) * 4);
504
+ }
505
+ const remSpacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*([\d.]+)rem/gi;
506
+ while ((sm = remSpacingRe.exec(html)) !== null) {
507
+ const v = Math.round(parseFloat(sm[1]) * 16);
508
+ if (v > 0 && v < 200) spacingValues.push(v);
509
+ }
510
+ const roundedSpacing = spacingValues.map(v => Math.round(v / 4) * 4);
511
+ if (roundedSpacing.length >= 10) {
512
+ const counts = {};
513
+ for (const v of roundedSpacing) counts[v] = (counts[v] || 0) + 1;
514
+ const maxCount = Math.max(...Object.values(counts));
515
+ const dominantPct = maxCount / roundedSpacing.length;
516
+ const unique = [...new Set(roundedSpacing)].filter(v => v > 0);
517
+ if (dominantPct > 0.6 && unique.length <= 3) {
518
+ const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
519
+ findings.push({
520
+ id: 'monotonous-spacing',
521
+ snippet: `~${dominant}px used ${maxCount}/${roundedSpacing.length} times (${Math.round(dominantPct * 100)}%)`,
522
+ });
523
+ }
524
+ }
525
+
526
+ // --- Motion ---
527
+
528
+ // Bounce/elastic animation names
529
+ const bounceRe = /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi;
530
+ if (bounceRe.test(html)) {
531
+ findings.push({ id: 'bounce-easing', snippet: 'Bounce/elastic animation in CSS' });
532
+ }
533
+
534
+ // Overshoot cubic-bezier
535
+ const bezierRe = /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g;
536
+ let bm;
537
+ while ((bm = bezierRe.exec(html)) !== null) {
538
+ const y1 = parseFloat(bm[2]), y2 = parseFloat(bm[4]);
539
+ if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) {
540
+ findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})` });
541
+ break;
542
+ }
543
+ }
544
+
545
+ // Layout property transitions
546
+ const transRe = /transition(?:-property)?\s*:\s*([^;{}]+)/gi;
547
+ let tm;
548
+ while ((tm = transRe.exec(html)) !== null) {
549
+ const val = tm[1].toLowerCase();
550
+ if (/\ball\b/.test(val)) continue;
551
+ const found = val.match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
552
+ if (found) {
553
+ findings.push({ id: 'layout-transition', snippet: `transition: ${found.join(', ')}` });
554
+ break;
555
+ }
556
+ }
557
+
558
+ // --- Dark glow ---
559
+
560
+ 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;
561
+ const twDarkBg = /\bbg-(?:gray|slate|zinc|neutral|stone)-(?:9\d{2}|800)\b/;
562
+ if (darkBgRe.test(html) || twDarkBg.test(html)) {
563
+ const shadowRe = /box-shadow\s*:\s*([^;{}]+)/gi;
564
+ let shm;
565
+ while ((shm = shadowRe.exec(html)) !== null) {
566
+ const val = shm[1];
567
+ const colorMatch = val.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
568
+ if (!colorMatch) continue;
569
+ const [r, g, b] = [+colorMatch[1], +colorMatch[2], +colorMatch[3]];
570
+ if ((Math.max(r, g, b) - Math.min(r, g, b)) < 30) continue;
571
+ const pxVals = [...val.matchAll(/(\d+)px|(?<![.\d])\b(0)\b(?![.\d])/g)].map(p => +(p[1] || p[2]));
572
+ if (pxVals.length >= 3 && pxVals[2] > 4) {
573
+ findings.push({ id: 'dark-glow', snippet: `Colored glow (rgb(${r},${g},${b})) on dark page` });
574
+ break;
575
+ }
576
+ }
577
+ }
578
+
579
+ return findings;
580
+ }
581
+
582
+ // ─── Section 4: resolveBackground (unified) ─────────────────────────────────
583
+
584
+ function resolveBackground(el, win) {
585
+ let current = el;
586
+ while (current && current.nodeType === 1) {
587
+ const style = IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
588
+
589
+ // If this element has a gradient background, it's opaque but we can't determine the color
590
+ const bgImage = style.backgroundImage || '';
591
+ if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
592
+ return null;
593
+ }
594
+
595
+ let bg = parseRgb(style.backgroundColor);
596
+ if (!IS_BROWSER && (!bg || bg.a < 0.1)) {
597
+ // jsdom doesn't decompose background shorthand — parse raw style attr
598
+ const rawStyle = current.getAttribute?.('style') || '';
599
+ const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
600
+ const inlineBg = bgMatch ? bgMatch[1].trim() : '';
601
+ // Check for gradient in inline style too
602
+ if (/gradient/i.test(inlineBg)) return null;
603
+ bg = parseRgb(inlineBg);
604
+ if (!bg && inlineBg) {
605
+ const hexMatch = inlineBg.match(/#([0-9a-f]{6}|[0-9a-f]{3})\b/i);
606
+ if (hexMatch) {
607
+ const h = hexMatch[1];
608
+ if (h.length === 6) {
609
+ bg = { r: parseInt(h.slice(0,2), 16), g: parseInt(h.slice(2,4), 16), b: parseInt(h.slice(4,6), 16), a: 1 };
610
+ } else {
611
+ bg = { r: parseInt(h[0]+h[0], 16), g: parseInt(h[1]+h[1], 16), b: parseInt(h[2]+h[2], 16), a: 1 };
612
+ }
613
+ }
614
+ }
615
+ }
616
+ if (bg && bg.a > 0.1) {
617
+ if (IS_BROWSER || bg.a >= 0.5) return bg;
618
+ }
619
+ current = current.parentElement;
620
+ }
621
+ return { r: 255, g: 255, b: 255 };
622
+ }
623
+
624
+ // ─── Section 5: Element Adapters ────────────────────────────────────────────
625
+
626
+ // Browser adapters — call getComputedStyle/getBoundingClientRect on live DOM
627
+
628
+ function checkElementBordersDOM(el) {
629
+ const tag = el.tagName.toLowerCase();
630
+ if (SAFE_TAGS.has(tag)) return [];
631
+ const rect = el.getBoundingClientRect();
632
+ if (rect.width < 20 || rect.height < 20) return [];
633
+ const style = getComputedStyle(el);
634
+ const sides = ['Top', 'Right', 'Bottom', 'Left'];
635
+ const widths = {}, colors = {};
636
+ for (const s of sides) {
637
+ widths[s] = parseFloat(style[`border${s}Width`]) || 0;
638
+ colors[s] = style[`border${s}Color`] || '';
639
+ }
640
+ return checkBorders(tag, widths, colors, parseFloat(style.borderRadius) || 0);
641
+ }
642
+
643
+ function checkElementColorsDOM(el) {
644
+ const tag = el.tagName.toLowerCase();
645
+ if (SAFE_TAGS.has(tag)) return [];
646
+ const rect = el.getBoundingClientRect();
647
+ if (rect.width < 10 || rect.height < 10) return [];
648
+ const style = getComputedStyle(el);
649
+ const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim());
650
+ return checkColors({
651
+ tag,
652
+ textColor: parseRgb(style.color),
653
+ bgColor: parseRgb(style.backgroundColor),
654
+ effectiveBg: resolveBackground(el),
655
+ fontSize: parseFloat(style.fontSize) || 16,
656
+ fontWeight: parseInt(style.fontWeight) || 400,
657
+ hasDirectText,
658
+ bgClip: style.webkitBackgroundClip || style.backgroundClip || '',
659
+ bgImage: style.backgroundImage || '',
660
+ classList: el.getAttribute('class') || '',
661
+ });
662
+ }
663
+
664
+ function checkElementMotionDOM(el) {
665
+ const tag = el.tagName.toLowerCase();
666
+ if (SAFE_TAGS.has(tag)) return [];
667
+ const style = getComputedStyle(el);
668
+ return checkMotion({
669
+ tag,
670
+ transitionProperty: style.transitionProperty || '',
671
+ animationName: style.animationName || '',
672
+ timingFunctions: [style.animationTimingFunction, style.transitionTimingFunction].filter(Boolean).join(' '),
673
+ classList: el.getAttribute('class') || '',
674
+ });
675
+ }
676
+
677
+ function checkElementGlowDOM(el) {
678
+ const tag = el.tagName.toLowerCase();
679
+ const style = getComputedStyle(el);
680
+ if (!style.boxShadow || style.boxShadow === 'none') return [];
681
+ // Use parent's background — glow radiates outward, so the surrounding context matters
682
+ // If resolveBackground returns null (gradient), try to infer from the gradient colors
683
+ let parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el);
684
+ if (!parentBg) {
685
+ // Gradient background — sample its colors to determine if it's dark
686
+ let cur = el.parentElement;
687
+ while (cur && cur.nodeType === 1) {
688
+ const bgImage = getComputedStyle(cur).backgroundImage || '';
689
+ const gradColors = parseGradientColors(bgImage);
690
+ if (gradColors.length > 0) {
691
+ // Average the gradient colors
692
+ const avg = { r: 0, g: 0, b: 0 };
693
+ for (const c of gradColors) { avg.r += c.r; avg.g += c.g; avg.b += c.b; }
694
+ avg.r = Math.round(avg.r / gradColors.length);
695
+ avg.g = Math.round(avg.g / gradColors.length);
696
+ avg.b = Math.round(avg.b / gradColors.length);
697
+ parentBg = avg;
698
+ break;
699
+ }
700
+ cur = cur.parentElement;
701
+ }
702
+ }
703
+ return checkGlow({ tag, boxShadow: style.boxShadow, effectiveBg: parentBg });
704
+ }
705
+
706
+ function checkElementAIPaletteDOM(el) {
707
+ const style = getComputedStyle(el);
708
+ const findings = [];
709
+
710
+ // Check gradient backgrounds for purple/violet or cyan
711
+ const bgImage = style.backgroundImage || '';
712
+ const gradColors = parseGradientColors(bgImage);
713
+ for (const c of gradColors) {
714
+ if (hasChroma(c, 50)) {
715
+ const hue = getHue(c);
716
+ if (hue >= 260 && hue <= 310) {
717
+ findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet gradient background' });
718
+ break;
719
+ }
720
+ if (hue >= 160 && hue <= 200) {
721
+ findings.push({ id: 'ai-color-palette', snippet: 'Cyan gradient background' });
722
+ break;
723
+ }
724
+ }
725
+ }
726
+
727
+ // Check for neon text (vivid cyan/purple color on dark background)
728
+ const textColor = parseRgb(style.color);
729
+ if (textColor && hasChroma(textColor, 80)) {
730
+ const hue = getHue(textColor);
731
+ const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
732
+ if (isAIPalette) {
733
+ const parentBg = el.parentElement ? resolveBackground(el.parentElement) : null;
734
+ // Also check gradient parents
735
+ let effectiveBg = parentBg;
736
+ if (!effectiveBg) {
737
+ let cur = el.parentElement;
738
+ while (cur && cur.nodeType === 1) {
739
+ const gi = getComputedStyle(cur).backgroundImage || '';
740
+ const gc = parseGradientColors(gi);
741
+ if (gc.length > 0) {
742
+ const avg = { r: 0, g: 0, b: 0 };
743
+ for (const c of gc) { avg.r += c.r; avg.g += c.g; avg.b += c.b; }
744
+ avg.r = Math.round(avg.r / gc.length);
745
+ avg.g = Math.round(avg.g / gc.length);
746
+ avg.b = Math.round(avg.b / gc.length);
747
+ effectiveBg = avg;
748
+ break;
749
+ }
750
+ cur = cur.parentElement;
751
+ }
752
+ }
753
+ if (effectiveBg && relativeLuminance(effectiveBg) < 0.1) {
754
+ const label = hue >= 260 ? 'Purple/violet' : 'Cyan';
755
+ findings.push({ id: 'ai-color-palette', snippet: `${label} neon text on dark background` });
756
+ }
757
+ }
758
+ }
759
+
760
+ return findings;
761
+ }
762
+
763
+ const QUALITY_TEXT_TAGS = new Set(['p', 'li', 'td', 'th', 'dd', 'blockquote', 'figcaption']);
764
+
765
+ function checkElementQualityDOM(el) {
766
+ const tag = el.tagName.toLowerCase();
767
+ // Skip browser extension injected elements
768
+ const elId = el.id || '';
769
+ if (elId.startsWith('claude-') || elId.startsWith('cic-')) return [];
770
+ const style = getComputedStyle(el);
771
+ const findings = [];
772
+
773
+ const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 10);
774
+ const textLen = el.textContent?.trim().length || 0;
775
+ const fontSize = parseFloat(style.fontSize) || 16;
776
+ const rect = el.getBoundingClientRect();
777
+
778
+ // --- Line length too long ---
779
+ // Only flag if text is long enough to actually fill the line (>80 chars)
780
+ if (hasDirectText && QUALITY_TEXT_TAGS.has(tag) && rect.width > 0 && textLen > 80) {
781
+ const charsPerLine = rect.width / (fontSize * 0.5);
782
+ if (charsPerLine > 85) {
783
+ findings.push({ id: 'line-length', snippet: `~${Math.round(charsPerLine)} chars/line (aim for <80)` });
784
+ }
785
+ }
786
+
787
+ // --- Cramped padding (skip small elements like labels/badges) ---
788
+ if (hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) {
789
+ const borders = {
790
+ top: parseFloat(style.borderTopWidth) || 0,
791
+ right: parseFloat(style.borderRightWidth) || 0,
792
+ bottom: parseFloat(style.borderBottomWidth) || 0,
793
+ left: parseFloat(style.borderLeftWidth) || 0,
794
+ };
795
+ // Need at least 2 borders (a container), or a non-transparent background
796
+ const borderCount = Object.values(borders).filter(w => w > 0).length;
797
+ const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)';
798
+ if (borderCount >= 2 || hasBg) {
799
+ // Only check padding on sides that have borders or where bg creates containment
800
+ const paddings = [];
801
+ if (hasBg || borders.top > 0) paddings.push(parseFloat(style.paddingTop) || 0);
802
+ if (hasBg || borders.right > 0) paddings.push(parseFloat(style.paddingRight) || 0);
803
+ if (hasBg || borders.bottom > 0) paddings.push(parseFloat(style.paddingBottom) || 0);
804
+ if (hasBg || borders.left > 0) paddings.push(parseFloat(style.paddingLeft) || 0);
805
+ if (paddings.length > 0) {
806
+ const minPad = Math.min(...paddings);
807
+ if (minPad < 8) {
808
+ findings.push({ id: 'cramped-padding', snippet: `${minPad}px padding (need >=8px)` });
809
+ }
810
+ }
811
+ }
812
+ }
813
+
814
+ // --- Tight line height ---
815
+ if (hasDirectText && textLen > 50 && !['h1','h2','h3','h4','h5','h6'].includes(tag)) {
816
+ const lineHeight = parseFloat(style.lineHeight);
817
+ if (lineHeight && lineHeight !== NaN) {
818
+ const ratio = lineHeight / fontSize;
819
+ if (ratio < 1.3 && ratio > 0) {
820
+ findings.push({ id: 'tight-leading', snippet: `line-height ${ratio.toFixed(2)}x (need >=1.3)` });
821
+ }
822
+ }
823
+ }
824
+
825
+
826
+ // --- Justified text (without hyphens) ---
827
+ if (hasDirectText && style.textAlign === 'justify') {
828
+ const hyphens = style.hyphens || style.webkitHyphens || '';
829
+ if (hyphens !== 'auto') {
830
+ findings.push({ id: 'justified-text', snippet: 'text-align: justify without hyphens: auto' });
831
+ }
832
+ }
833
+
834
+ // --- Tiny body text ---
835
+ if (hasDirectText && textLen > 20 && fontSize < 12) {
836
+ if (!['sub', 'sup', 'code', 'kbd', 'samp', 'var'].includes(tag)) {
837
+ findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` });
838
+ }
839
+ }
840
+
841
+ // --- All-caps body text ---
842
+ if (hasDirectText && textLen > 30 && style.textTransform === 'uppercase') {
843
+ if (!['h1','h2','h3','h4','h5','h6'].includes(tag)) {
844
+ findings.push({ id: 'all-caps-body', snippet: `text-transform: uppercase on ${textLen} chars of body text` });
845
+ }
846
+ }
847
+
848
+ // --- Wide letter spacing on body text ---
849
+ if (hasDirectText && textLen > 20) {
850
+ const tracking = parseFloat(style.letterSpacing);
851
+ if (tracking > 0 && style.textTransform !== 'uppercase') {
852
+ const trackingEm = tracking / fontSize;
853
+ if (trackingEm > 0.05) {
854
+ findings.push({ id: 'wide-tracking', snippet: `letter-spacing: ${trackingEm.toFixed(2)}em on body text` });
855
+ }
856
+ }
857
+ }
858
+
859
+ return findings;
860
+ }
861
+
862
+ function checkPageQualityDOM() {
863
+ const findings = [];
864
+
865
+ // --- Skipped heading levels ---
866
+ const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
867
+ let prevLevel = 0;
868
+ for (const h of headings) {
869
+ const level = parseInt(h.tagName[1]);
870
+ if (prevLevel > 0 && level > prevLevel + 1) {
871
+ findings.push({ type: 'skipped-heading', detail: `h${prevLevel} followed by h${level} (missing h${prevLevel + 1})` });
872
+ }
873
+ prevLevel = level;
874
+ }
875
+
876
+ return findings;
877
+ }
878
+
879
+ // Node adapters — take pre-extracted jsdom computed style
880
+
881
+ function checkElementBorders(tag, style) {
882
+ const sides = ['Top', 'Right', 'Bottom', 'Left'];
883
+ const widths = {}, colors = {};
884
+ for (const s of sides) {
885
+ widths[s] = parseFloat(style[`border${s}Width`]) || 0;
886
+ colors[s] = style[`border${s}Color`] || '';
887
+ }
888
+ return checkBorders(tag, widths, colors, parseFloat(style.borderRadius) || 0);
889
+ }
890
+
891
+ function checkElementColors(el, style, tag, window) {
892
+ const hasText = el.textContent?.trim().length > 0;
893
+ const hasDirectText = hasText && [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim());
894
+
895
+ return checkColors({
896
+ tag,
897
+ textColor: parseRgb(style.color),
898
+ bgColor: parseRgb(style.backgroundColor),
899
+ effectiveBg: resolveBackground(el, window),
900
+ fontSize: parseFloat(style.fontSize) || 16,
901
+ fontWeight: parseInt(style.fontWeight) || 400,
902
+ hasDirectText,
903
+ bgClip: style.webkitBackgroundClip || style.backgroundClip || '',
904
+ bgImage: style.backgroundImage || '',
905
+ classList: el.getAttribute?.('class') || el.className || '',
906
+ });
907
+ }
908
+
909
+ function checkElementMotion(tag, style) {
910
+ return checkMotion({
911
+ tag,
912
+ transitionProperty: style.transitionProperty || '',
913
+ animationName: style.animationName || '',
914
+ timingFunctions: [style.animationTimingFunction, style.transitionTimingFunction].filter(Boolean).join(' '),
915
+ classList: '',
916
+ });
917
+ }
918
+
919
+ function checkElementGlow(tag, style, effectiveBg) {
920
+ if (!style.boxShadow || style.boxShadow === 'none') return [];
921
+ return checkGlow({ tag, boxShadow: style.boxShadow, effectiveBg });
922
+ }
923
+
924
+ // ─── Section 6: Page-Level Checks ───────────────────────────────────────────
925
+
926
+ // Browser page-level checks — use document/getComputedStyle globals
927
+
928
+ function checkTypography() {
929
+ const findings = [];
930
+ const fonts = new Set();
931
+ const overusedFound = new Set();
932
+
933
+ for (const sheet of document.styleSheets) {
934
+ let rules;
935
+ try { rules = sheet.cssRules || sheet.rules; } catch { continue; }
936
+ if (!rules) continue;
937
+ for (const rule of rules) {
938
+ if (rule.type !== 1) continue;
939
+ const ff = rule.style?.fontFamily;
940
+ if (!ff) continue;
941
+ const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
942
+ const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
943
+ if (primary) {
944
+ fonts.add(primary);
945
+ if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
946
+ }
947
+ }
948
+ }
949
+
950
+ const html = document.documentElement.outerHTML;
951
+ const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
952
+ let m;
953
+ while ((m = gfRe.exec(html)) !== null) {
954
+ for (const f of m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase())) {
955
+ fonts.add(f);
956
+ if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
957
+ }
958
+ }
959
+
960
+ for (const font of overusedFound) {
961
+ findings.push({ type: 'overused-font', detail: `Primary font: ${font}` });
962
+ }
963
+ if (fonts.size === 1 && document.querySelectorAll('*').length >= 20) {
964
+ findings.push({ type: 'single-font', detail: `only font used is ${[...fonts][0]}` });
965
+ }
966
+
967
+ const sizes = new Set();
968
+ for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) {
969
+ const fs = parseFloat(getComputedStyle(el).fontSize);
970
+ if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10);
971
+ }
972
+ if (sizes.size >= 3) {
973
+ const sorted = [...sizes].sort((a, b) => a - b);
974
+ const ratio = sorted[sorted.length - 1] / sorted[0];
975
+ if (ratio < 2.0) {
976
+ findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` });
977
+ }
978
+ }
979
+
980
+ return findings;
981
+ }
982
+
983
+ function isCardLikeDOM(el) {
984
+ const tag = el.tagName.toLowerCase();
985
+ if (SAFE_TAGS.has(tag) || ['input','select','textarea','img','video','canvas','picture'].includes(tag)) return false;
986
+ const style = getComputedStyle(el);
987
+ const cls = el.getAttribute('class') || '';
988
+ const hasShadow = (style.boxShadow && style.boxShadow !== 'none') || /\bshadow(?:-sm|-md|-lg|-xl|-2xl)?\b/.test(cls);
989
+ const hasBorder = /\bborder\b/.test(cls);
990
+ const hasRadius = parseFloat(style.borderRadius) > 0 || /\brounded(?:-sm|-md|-lg|-xl|-2xl|-full)?\b/.test(cls);
991
+ const hasBg = (style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)') || /\bbg-(?:white|gray-\d+|slate-\d+)\b/.test(cls);
992
+ return isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg);
993
+ }
994
+
995
+ function checkLayout() {
996
+ const findings = [];
997
+ const flaggedEls = new Set();
998
+
999
+ for (const el of document.querySelectorAll('*')) {
1000
+ if (!isCardLikeDOM(el) || flaggedEls.has(el)) continue;
1001
+ const cls = el.getAttribute('class') || '';
1002
+ const style = getComputedStyle(el);
1003
+ if (style.position === 'absolute' || style.position === 'fixed') continue;
1004
+ if (/\b(?:dropdown|popover|tooltip|menu|modal|dialog)\b/i.test(cls)) continue;
1005
+ if ((el.textContent?.trim().length || 0) < 10) continue;
1006
+ const rect = el.getBoundingClientRect();
1007
+ if (rect.width < 50 || rect.height < 30) continue;
1008
+
1009
+ let parent = el.parentElement;
1010
+ while (parent) {
1011
+ if (isCardLikeDOM(parent)) { flaggedEls.add(el); break; }
1012
+ parent = parent.parentElement;
1013
+ }
1014
+ }
1015
+
1016
+ for (const el of flaggedEls) {
1017
+ let isAncestor = false;
1018
+ for (const other of flaggedEls) {
1019
+ if (other !== el && el.contains(other)) { isAncestor = true; break; }
1020
+ }
1021
+ if (!isAncestor) findings.push({ type: 'nested-cards', detail: 'Card inside card', el });
1022
+ }
1023
+
1024
+ return findings;
1025
+ }
1026
+
1027
+ // Node page-level checks — take document/window as parameters
1028
+
1029
+ function checkPageTypography(doc, win) {
1030
+ const findings = [];
1031
+
1032
+ const fonts = new Set();
1033
+ const overusedFound = new Set();
1034
+
1035
+ for (const sheet of doc.styleSheets) {
1036
+ let rules;
1037
+ try { rules = sheet.cssRules || sheet.rules; } catch { continue; }
1038
+ if (!rules) continue;
1039
+ for (const rule of rules) {
1040
+ if (rule.type !== 1) continue;
1041
+ const ff = rule.style?.fontFamily;
1042
+ if (!ff) continue;
1043
+ const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
1044
+ const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
1045
+ if (primary) {
1046
+ fonts.add(primary);
1047
+ if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
1048
+ }
1049
+ }
1050
+ }
1051
+
1052
+ // Check Google Fonts links in HTML
1053
+ const html = doc.documentElement?.outerHTML || '';
1054
+ const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
1055
+ let m;
1056
+ while ((m = gfRe.exec(html)) !== null) {
1057
+ const families = m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase());
1058
+ for (const f of families) {
1059
+ fonts.add(f);
1060
+ if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
1061
+ }
1062
+ }
1063
+
1064
+ // Also parse raw HTML/style content for font-family (jsdom may not expose all via CSSOM)
1065
+ const ffRe = /font-family\s*:\s*([^;}]+)/gi;
1066
+ let fm;
1067
+ while ((fm = ffRe.exec(html)) !== null) {
1068
+ for (const f of fm[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) {
1069
+ if (f && !GENERIC_FONTS.has(f)) {
1070
+ fonts.add(f);
1071
+ if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
1072
+ }
1073
+ }
1074
+ }
1075
+
1076
+ for (const font of overusedFound) {
1077
+ findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` });
1078
+ }
1079
+
1080
+ // Single font
1081
+ if (fonts.size === 1) {
1082
+ const els = doc.querySelectorAll('*');
1083
+ if (els.length >= 20) {
1084
+ findings.push({ id: 'single-font', snippet: `only font used is ${[...fonts][0]}` });
1085
+ }
1086
+ }
1087
+
1088
+ // Flat type hierarchy
1089
+ const sizes = new Set();
1090
+ const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div');
1091
+ for (const el of textEls) {
1092
+ const fontSize = parseFloat(win.getComputedStyle(el).fontSize);
1093
+ // Filter out sub-8px values (jsdom doesn't resolve relative units properly)
1094
+ if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10);
1095
+ }
1096
+ if (sizes.size >= 3) {
1097
+ const sorted = [...sizes].sort((a, b) => a - b);
1098
+ const ratio = sorted[sorted.length - 1] / sorted[0];
1099
+ if (ratio < 2.0) {
1100
+ findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` });
1101
+ }
1102
+ }
1103
+
1104
+ return findings;
1105
+ }
1106
+
1107
+ function isCardLike(el, win) {
1108
+ const tag = el.tagName.toLowerCase();
1109
+ if (SAFE_TAGS.has(tag) || ['input', 'select', 'textarea', 'img', 'video', 'canvas', 'picture'].includes(tag)) return false;
1110
+
1111
+ const style = win.getComputedStyle(el);
1112
+ const rawStyle = el.getAttribute?.('style') || '';
1113
+ const cls = el.getAttribute?.('class') || '';
1114
+
1115
+ const hasShadow = (style.boxShadow && style.boxShadow !== 'none') ||
1116
+ /\bshadow(?:-sm|-md|-lg|-xl|-2xl)?\b/.test(cls) || /box-shadow/i.test(rawStyle);
1117
+ const hasBorder = /\bborder\b/.test(cls);
1118
+ const hasRadius = (parseFloat(style.borderRadius) || 0) > 0 ||
1119
+ /\brounded(?:-sm|-md|-lg|-xl|-2xl|-full)?\b/.test(cls) || /border-radius/i.test(rawStyle);
1120
+ const hasBg = /\bbg-(?:white|gray-\d+|slate-\d+)\b/.test(cls) ||
1121
+ /background(?:-color)?\s*:\s*(?!transparent)/i.test(rawStyle);
1122
+
1123
+ return isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg);
1124
+ }
1125
+
1126
+ function checkPageLayout(doc, win) {
1127
+ const findings = [];
1128
+
1129
+ // Nested cards
1130
+ const allEls = doc.querySelectorAll('*');
1131
+ const flaggedEls = new Set();
1132
+ for (const el of allEls) {
1133
+ if (!isCardLike(el, win)) continue;
1134
+ if (flaggedEls.has(el)) continue;
1135
+
1136
+ const tag = el.tagName.toLowerCase();
1137
+ const cls = el.getAttribute?.('class') || '';
1138
+ const rawStyle = el.getAttribute?.('style') || '';
1139
+
1140
+ if (['pre', 'code'].includes(tag)) continue;
1141
+ if (/\b(?:absolute|fixed)\b/.test(cls) || /position\s*:\s*(?:absolute|fixed)/i.test(rawStyle)) continue;
1142
+ if ((el.textContent?.trim().length || 0) < 10) continue;
1143
+ if (/\b(?:dropdown|popover|tooltip|menu|modal|dialog)\b/i.test(cls)) continue;
1144
+
1145
+ // Walk up to find card-like ancestor
1146
+ let parent = el.parentElement;
1147
+ while (parent) {
1148
+ if (isCardLike(parent, win)) {
1149
+ flaggedEls.add(el);
1150
+ break;
1151
+ }
1152
+ parent = parent.parentElement;
1153
+ }
1154
+ }
1155
+
1156
+ // Only report innermost nested cards
1157
+ for (const el of flaggedEls) {
1158
+ let isAncestorOfFlagged = false;
1159
+ for (const other of flaggedEls) {
1160
+ if (other !== el && el.contains(other)) {
1161
+ isAncestorOfFlagged = true;
1162
+ break;
1163
+ }
1164
+ }
1165
+ if (!isAncestorOfFlagged) {
1166
+ findings.push({ id: 'nested-cards', snippet: `Card inside card (${el.tagName.toLowerCase()})` });
1167
+ }
1168
+ }
1169
+
1170
+ // Everything centered
1171
+ const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, li, div, button');
1172
+ let centeredCount = 0;
1173
+ let totalText = 0;
1174
+ for (const el of textEls) {
1175
+ const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length >= 3);
1176
+ if (!hasDirectText) continue;
1177
+ totalText++;
1178
+
1179
+ let cur = el;
1180
+ let isCentered = false;
1181
+ while (cur && cur.nodeType === 1) {
1182
+ const rawStyle = cur.getAttribute?.('style') || '';
1183
+ const cls = cur.getAttribute?.('class') || '';
1184
+ if (/text-align\s*:\s*center/i.test(rawStyle) || /\btext-center\b/.test(cls)) {
1185
+ isCentered = true;
1186
+ break;
1187
+ }
1188
+ if (cur.tagName === 'BODY') break;
1189
+ cur = cur.parentElement;
1190
+ }
1191
+ if (isCentered) centeredCount++;
1192
+ }
1193
+
1194
+ if (totalText >= 5 && centeredCount / totalText > 0.7) {
1195
+ findings.push({
1196
+ id: 'everything-centered',
1197
+ snippet: `${centeredCount}/${totalText} text elements centered (${Math.round(centeredCount / totalText * 100)}%)`,
1198
+ });
1199
+ }
1200
+
1201
+ return findings;
1202
+ }
1203
+
1204
+ // ─── Section 7: Browser UI (IS_BROWSER only) ────────────────────────────────
1205
+
1206
+ if (IS_BROWSER) {
1207
+ const LABEL_BG = 'oklch(55% 0.25 350)';
1208
+ const OUTLINE_COLOR = 'oklch(60% 0.25 350)';
1209
+
1210
+ // Inject hover styles via CSS (more reliable than JS event listeners)
1211
+ const styleEl = document.createElement('style');
1212
+ styleEl.textContent = `
1213
+ @keyframes impeccable-reveal {
1214
+ from { opacity: 0; outline-color: transparent; }
1215
+ to { opacity: 1; outline-color: ${OUTLINE_COLOR}; }
1216
+ }
1217
+ .impeccable-overlay:not(.impeccable-banner) {
1218
+ pointer-events: none;
1219
+ outline: 2px solid ${OUTLINE_COLOR};
1220
+ border-radius: 4px;
1221
+ transition: outline-color 0.3s ease;
1222
+ animation: impeccable-reveal 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
1223
+ animation-play-state: paused;
1224
+ }
1225
+ .impeccable-overlay.impeccable-visible {
1226
+ animation-play-state: running;
1227
+ }
1228
+ .impeccable-overlay.impeccable-hover {
1229
+ outline-color: rgba(0,0,0,0.85);
1230
+ z-index: 100001 !important;
1231
+ }
1232
+ .impeccable-label-name,
1233
+ .impeccable-label-detail {
1234
+ transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
1235
+ }
1236
+ .impeccable-label-detail {
1237
+ position: absolute; top: 100%; left: 0;
1238
+ }
1239
+ .impeccable-overlay.impeccable-hover .impeccable-label-name,
1240
+ .impeccable-overlay.impeccable-hover .impeccable-label-detail {
1241
+ transform: translateY(-100%);
1242
+ }
1243
+ .impeccable-hidden .impeccable-overlay:not(.impeccable-banner) {
1244
+ display: none !important;
1245
+ }
1246
+ `;
1247
+ (document.head || document.documentElement).appendChild(styleEl);
1248
+
1249
+ const overlays = [];
1250
+ const TYPE_LABELS = {};
1251
+ for (const ap of ANTIPATTERNS) {
1252
+ TYPE_LABELS[ap.id] = ap.name.toLowerCase().substring(0, 26);
1253
+ }
1254
+
1255
+ function isInFixedContext(el) {
1256
+ let p = el;
1257
+ while (p && p !== document.body) {
1258
+ if (getComputedStyle(p).position === 'fixed') return true;
1259
+ p = p.parentElement;
1260
+ }
1261
+ return false;
1262
+ }
1263
+
1264
+ function positionOverlay(overlay) {
1265
+ const el = overlay._targetEl;
1266
+ if (!el) return;
1267
+ const rect = el.getBoundingClientRect();
1268
+ if (overlay._isFixed) {
1269
+ // Viewport-relative coords for fixed targets
1270
+ overlay.style.top = `${rect.top - 2}px`;
1271
+ overlay.style.left = `${rect.left - 2}px`;
1272
+ } else {
1273
+ // Document-relative coords for normal targets
1274
+ overlay.style.top = `${rect.top + scrollY - 2}px`;
1275
+ overlay.style.left = `${rect.left + scrollX - 2}px`;
1276
+ }
1277
+ overlay.style.width = `${rect.width + 4}px`;
1278
+ overlay.style.height = `${rect.height + 4}px`;
1279
+ }
1280
+
1281
+ function repositionOverlays() {
1282
+ for (const o of overlays) {
1283
+ if (!o._targetEl || o.classList.contains('impeccable-banner')) continue;
1284
+ positionOverlay(o);
1285
+ }
1286
+ }
1287
+
1288
+ let resizeRAF;
1289
+ const onResize = () => {
1290
+ cancelAnimationFrame(resizeRAF);
1291
+ resizeRAF = requestAnimationFrame(repositionOverlays);
1292
+ };
1293
+ window.addEventListener('resize', onResize);
1294
+
1295
+ // Track target element visibility via IntersectionObserver.
1296
+ // Uses a huge rootMargin so all *rendered* elements count as intersecting,
1297
+ // while display:none / closed <details> / hidden modals etc. do not.
1298
+ // This is event-driven -- no polling needed.
1299
+ let overlayIndex = 0;
1300
+ const visibilityObserver = new IntersectionObserver((entries) => {
1301
+ for (const entry of entries) {
1302
+ const overlay = entry.target._impeccableOverlay;
1303
+ if (!overlay) continue;
1304
+ if (entry.isIntersecting) {
1305
+ overlay.style.display = '';
1306
+ positionOverlay(overlay);
1307
+ if (!overlay._revealed) {
1308
+ overlay._revealed = true;
1309
+ overlay.style.animationDelay = `${(overlay._staggerIndex || 0) * 80}ms`;
1310
+ requestAnimationFrame(() => overlay.classList.add('impeccable-visible'));
1311
+ }
1312
+ } else {
1313
+ overlay.style.display = 'none';
1314
+ }
1315
+ }
1316
+ }, { rootMargin: '99999px' });
1317
+
1318
+ // Reposition overlays after CSS transitions end (e.g. reveal animations).
1319
+ // Listens at document level so it catches transitions on ancestor elements
1320
+ // (the transform may be on a parent, not the flagged element itself).
1321
+ document.addEventListener('transitionend', (e) => {
1322
+ if (e.propertyName !== 'transform') return;
1323
+ for (const o of overlays) {
1324
+ if (!o._targetEl || o.classList.contains('impeccable-banner') || o.style.display === 'none') continue;
1325
+ if (e.target === o._targetEl || e.target.contains(o._targetEl)) {
1326
+ positionOverlay(o);
1327
+ }
1328
+ }
1329
+ });
1330
+
1331
+ const highlight = function(el, findings) {
1332
+ const fixed = isInFixedContext(el);
1333
+ const rect = el.getBoundingClientRect();
1334
+ const outline = document.createElement('div');
1335
+ outline.className = 'impeccable-overlay';
1336
+ outline._targetEl = el;
1337
+ outline._isFixed = fixed;
1338
+ Object.assign(outline.style, {
1339
+ position: fixed ? 'fixed' : 'absolute',
1340
+ top: fixed ? `${rect.top - 2}px` : `${rect.top + scrollY - 2}px`,
1341
+ left: fixed ? `${rect.left - 2}px` : `${rect.left + scrollX - 2}px`,
1342
+ width: `${rect.width + 4}px`, height: `${rect.height + 4}px`,
1343
+ zIndex: '99999', boxSizing: 'border-box',
1344
+ });
1345
+
1346
+ const typeText = findings.map(f => TYPE_LABELS[f.type || f.id] || f.type || f.id).join(', ');
1347
+ const detailText = findings.map(f => f.detail || f.snippet).join(' | ');
1348
+
1349
+ const label = document.createElement('div');
1350
+ label.className = 'impeccable-label';
1351
+ Object.assign(label.style, {
1352
+ position: 'absolute', top: '-22px', left: '0',
1353
+ clipPath: 'inset(0 -999px)',
1354
+ });
1355
+
1356
+ const rowBase = {
1357
+ padding: '2px 8px', borderRadius: '3px', whiteSpace: 'nowrap',
1358
+ fontSize: '11px', fontWeight: '600', letterSpacing: '0.02em',
1359
+ color: 'white', lineHeight: '16px',
1360
+ };
1361
+
1362
+ const nameRow = document.createElement('div');
1363
+ nameRow.className = 'impeccable-label-name';
1364
+ nameRow.textContent = typeText;
1365
+ Object.assign(nameRow.style, { ...rowBase, background: LABEL_BG, fontFamily: 'system-ui, sans-serif' });
1366
+ label.appendChild(nameRow);
1367
+
1368
+ const detailRow = document.createElement('div');
1369
+ detailRow.className = 'impeccable-label-detail';
1370
+ detailRow.textContent = detailText;
1371
+ Object.assign(detailRow.style, { ...rowBase, background: 'rgba(0,0,0,0.85)', fontFamily: 'ui-monospace, monospace', fontWeight: '400' });
1372
+ label.appendChild(detailRow);
1373
+
1374
+ outline.appendChild(label);
1375
+
1376
+ // Start hidden; the IntersectionObserver will show it once the target is rendered
1377
+ outline.style.display = 'none';
1378
+ outline._staggerIndex = overlayIndex++;
1379
+ el._impeccableOverlay = outline;
1380
+ visibilityObserver.observe(el);
1381
+
1382
+ // Drive hover state from the target element so pointer events pass through
1383
+ el.addEventListener('mouseenter', () => outline.classList.add('impeccable-hover'));
1384
+ el.addEventListener('mouseleave', () => outline.classList.remove('impeccable-hover'));
1385
+
1386
+ document.body.appendChild(outline);
1387
+ overlays.push(outline);
1388
+ };
1389
+
1390
+ const showPageBanner = function(findings) {
1391
+ if (!findings.length) return;
1392
+ const banner = document.createElement('div');
1393
+ banner.className = 'impeccable-overlay impeccable-banner';
1394
+ Object.assign(banner.style, {
1395
+ position: 'fixed', top: '0', left: '0', right: '0', zIndex: '100000',
1396
+ background: LABEL_BG, color: 'white',
1397
+ fontFamily: 'system-ui, sans-serif', fontSize: '13px',
1398
+ display: 'flex', alignItems: 'center', pointerEvents: 'auto',
1399
+ height: '36px', overflow: 'hidden', maxWidth: '100vw',
1400
+ transform: 'translateY(-100%)',
1401
+ transition: 'transform 0.4s cubic-bezier(0.16, 1, 0.3, 1)',
1402
+ });
1403
+ requestAnimationFrame(() => requestAnimationFrame(() => {
1404
+ banner.style.transform = 'translateY(0)';
1405
+ }));
1406
+
1407
+ // Scrollable findings area
1408
+ const scrollArea = document.createElement('div');
1409
+ Object.assign(scrollArea.style, {
1410
+ flex: '1', minWidth: '0', overflowX: 'auto', overflowY: 'hidden',
1411
+ display: 'flex', gap: '8px', alignItems: 'center',
1412
+ padding: '0 12px', scrollSnapType: 'x mandatory',
1413
+ scrollbarWidth: 'none',
1414
+ });
1415
+ for (const f of findings) {
1416
+ const tag = document.createElement('span');
1417
+ tag.textContent = `${TYPE_LABELS[f.type] || f.type}: ${f.detail}`;
1418
+ Object.assign(tag.style, {
1419
+ background: 'rgba(255,255,255,0.15)', padding: '2px 8px',
1420
+ borderRadius: '3px', fontSize: '12px', fontFamily: 'ui-monospace, monospace',
1421
+ whiteSpace: 'nowrap', flexShrink: '0', scrollSnapAlign: 'start',
1422
+ });
1423
+ scrollArea.appendChild(tag);
1424
+ }
1425
+ banner.appendChild(scrollArea);
1426
+
1427
+ // Controls area (always visible on the right)
1428
+ const controls = document.createElement('div');
1429
+ Object.assign(controls.style, {
1430
+ display: 'flex', alignItems: 'center', gap: '2px',
1431
+ padding: '0 8px', flexShrink: '0',
1432
+ });
1433
+
1434
+ // Toggle visibility button
1435
+ const toggle = document.createElement('button');
1436
+ toggle.textContent = '\u25C9'; // circle with dot (visible state)
1437
+ toggle.title = 'Toggle overlay visibility';
1438
+ Object.assign(toggle.style, {
1439
+ background: 'none', border: 'none',
1440
+ color: 'white', fontSize: '16px', cursor: 'pointer', padding: '0 4px',
1441
+ opacity: '0.85', transition: 'opacity 0.15s',
1442
+ });
1443
+ let overlaysVisible = true;
1444
+ toggle.addEventListener('click', () => {
1445
+ overlaysVisible = !overlaysVisible;
1446
+ document.body.classList.toggle('impeccable-hidden', !overlaysVisible);
1447
+ toggle.textContent = overlaysVisible ? '\u25C9' : '\u25CB'; // filled vs empty circle
1448
+ toggle.style.opacity = overlaysVisible ? '0.85' : '0.5';
1449
+ });
1450
+ controls.appendChild(toggle);
1451
+
1452
+ // Close button
1453
+ const close = document.createElement('button');
1454
+ close.textContent = '\u00d7';
1455
+ close.title = 'Dismiss banner';
1456
+ Object.assign(close.style, {
1457
+ background: 'none', border: 'none',
1458
+ color: 'white', fontSize: '18px', cursor: 'pointer', padding: '0 4px',
1459
+ });
1460
+ close.addEventListener('click', () => banner.remove());
1461
+ controls.appendChild(close);
1462
+
1463
+ banner.appendChild(controls);
1464
+ document.body.appendChild(banner);
1465
+ overlays.push(banner);
1466
+ };
1467
+
1468
+ const printSummary = function(allFindings) {
1469
+ if (allFindings.length === 0) {
1470
+ console.log('%c[impeccable] No anti-patterns found.', 'color: #22c55e; font-weight: bold');
1471
+ return;
1472
+ }
1473
+ console.group(
1474
+ `%c[impeccable] ${allFindings.length} anti-pattern${allFindings.length === 1 ? '' : 's'} found`,
1475
+ 'color: oklch(60% 0.25 350); font-weight: bold'
1476
+ );
1477
+ for (const { el, findings } of allFindings) {
1478
+ for (const f of findings) {
1479
+ console.log(`%c${f.type || f.id}%c ${f.detail || f.snippet}`,
1480
+ 'color: oklch(55% 0.25 350); font-weight: bold', 'color: inherit', el);
1481
+ }
1482
+ }
1483
+ console.groupEnd();
1484
+ };
1485
+
1486
+ const scan = function() {
1487
+ for (const o of overlays) o.remove();
1488
+ overlays.length = 0;
1489
+ visibilityObserver.disconnect();
1490
+ const allFindings = [];
1491
+
1492
+ for (const el of document.querySelectorAll('*')) {
1493
+ if (el.classList.contains('impeccable-overlay') ||
1494
+ el.classList.contains('impeccable-label') ||
1495
+ el.classList.contains('impeccable-tooltip')) continue;
1496
+ // Skip browser extension elements (Claude, etc.)
1497
+ const elId = el.id || '';
1498
+ if (elId.startsWith('claude-') || elId.startsWith('cic-')) continue;
1499
+ // Skip html/body -- page-level findings go in the banner, not a full-page overlay
1500
+ if (el === document.body || el === document.documentElement) continue;
1501
+
1502
+ const findings = [
1503
+ ...checkElementBordersDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
1504
+ ...checkElementColorsDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
1505
+ ...checkElementMotionDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
1506
+ ...checkElementGlowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
1507
+ ...checkElementAIPaletteDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
1508
+ ...checkElementQualityDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
1509
+ ];
1510
+
1511
+ if (findings.length > 0) {
1512
+ highlight(el, findings);
1513
+ allFindings.push({ el, findings });
1514
+ }
1515
+ }
1516
+
1517
+ const pageLevelFindings = [];
1518
+
1519
+ const typoFindings = checkTypography();
1520
+ if (typoFindings.length > 0) {
1521
+ pageLevelFindings.push(...typoFindings);
1522
+ allFindings.push({ el: document.body, findings: typoFindings });
1523
+ }
1524
+
1525
+ const layoutFindings = checkLayout();
1526
+ for (const f of layoutFindings) {
1527
+ const el = f.el || document.body;
1528
+ delete f.el;
1529
+ // Merge into existing overlay if this element already has one
1530
+ const existing = el._impeccableOverlay;
1531
+ if (existing) {
1532
+ const nameRow = existing.querySelector('.impeccable-label-name');
1533
+ const detailRow = existing.querySelector('.impeccable-label-detail');
1534
+ const newType = TYPE_LABELS[f.type] || f.type;
1535
+ if (nameRow) nameRow.textContent += ', ' + newType;
1536
+ if (detailRow) detailRow.textContent += ' | ' + (f.detail || '');
1537
+ } else {
1538
+ highlight(el, [f]);
1539
+ }
1540
+ allFindings.push({ el, findings: [f] });
1541
+ }
1542
+
1543
+ // Page-level quality checks (headings, etc.)
1544
+ const qualityFindings = checkPageQualityDOM();
1545
+ if (qualityFindings.length > 0) {
1546
+ pageLevelFindings.push(...qualityFindings);
1547
+ allFindings.push({ el: document.body, findings: qualityFindings });
1548
+ }
1549
+
1550
+ // Regex-on-HTML checks (shared with Node)
1551
+ const htmlPatternFindings = checkHtmlPatterns(document.documentElement.outerHTML);
1552
+ if (htmlPatternFindings.length > 0) {
1553
+ const mapped = htmlPatternFindings.map(f => ({ type: f.id, detail: f.snippet }));
1554
+ pageLevelFindings.push(...mapped);
1555
+ allFindings.push({ el: document.body, findings: mapped });
1556
+ }
1557
+
1558
+ if (pageLevelFindings.length > 0) {
1559
+ showPageBanner(pageLevelFindings);
1560
+ }
1561
+
1562
+ printSummary(allFindings);
1563
+ return allFindings;
1564
+ };
1565
+
1566
+ if (document.readyState === 'loading') {
1567
+ document.addEventListener('DOMContentLoaded', () => setTimeout(scan, 100));
1568
+ } else {
1569
+ setTimeout(scan, 100);
1570
+ }
1571
+
1572
+ window.impeccableScan = scan;
1573
+ }
1574
+
1575
+ // ─── Section 8: Node Engine ─────────────────────────────────────────────────
1576
+
1577
+ // ─── Section 9: Exports ─────────────────────────────────────────────────────
1578
+
1579
+ })();