1#!/usr/bin/env node
2
3/**
4 * Anti-Pattern Detector for Impeccable
5 * Copyright (c) 2026 Paul Bakaus
6 * SPDX-License-Identifier: Apache-2.0
7 *
8 * Universal file — auto-detects environment (browser vs Node) and adapts.
9 *
10 * Node usage:
11 * node detect-antipatterns.mjs [file-or-dir...] # jsdom for HTML, regex for rest
12 * node detect-antipatterns.mjs https://... # Puppeteer (auto)
13 * node detect-antipatterns.mjs --fast [files...] # regex-only (skip jsdom)
14 * node detect-antipatterns.mjs --json # JSON output
15 *
16 * Browser usage:
17 * <script src="detect-antipatterns-browser.js"></script>
18 * Re-scan: window.impeccableScan()
19 *
20 * Exit codes: 0 = clean, 2 = findings
21 */
22
23// ─── Environment ────────────────────────────────────────────────────────────
24
25const IS_BROWSER = typeof window !== 'undefined';
26const IS_NODE = !IS_BROWSER;
27
28// @browser-strip-start
29let fs, path;
30if (!IS_BROWSER) {
31 fs = (await import('node:fs')).default;
32 path = (await import('node:path')).default;
33}
34// @browser-strip-end
35
36// ─── Section 1: Constants ───────────────────────────────────────────────────
37
38const 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// Per-check safe-tags override for the border (side-tab / border-accent)
47// rule. We intentionally re-allow <label> here because card-shaped clickable
48// labels (e.g. .checklist-item wrapping a checkbox + content) are one of the
49// canonical side-tab anti-pattern shapes and must be detected. The rule's
50// other preconditions (non-neutral color, width >= 2px on a single side,
51// radius > 0 or width >= 3, element size >= 20x20 in the browser path)
52// already filter out plain inline form labels so this does not introduce
53// false positives. See modern-color-borders.html for the test matrix.
54const BORDER_SAFE_TAGS = new Set(
55 [...SAFE_TAGS].filter(t => t !== 'label')
56);
57
58const OVERUSED_FONTS = new Set([
59 'inter', 'roboto', 'open sans', 'lato', 'montserrat', 'arial', 'helvetica',
60]);
61
62// Brand-associated fonts: don't flag these as "overused" on the brand's own domains.
63// Keys are font names, values are arrays of hostname suffixes where the font is allowed.
64const GOOGLE_DOMAINS = [
65 'google.com', 'youtube.com', 'android.com', 'chromium.org',
66 'chrome.com', 'web.dev', 'gstatic.com', 'firebase.google.com',
67];
68const BRAND_FONT_DOMAINS = {
69 'roboto': GOOGLE_DOMAINS,
70 'google sans': GOOGLE_DOMAINS,
71 'product sans': GOOGLE_DOMAINS,
72};
73
74function isBrandFontOnOwnDomain(font) {
75 if (typeof location === 'undefined') return false;
76 const allowed = BRAND_FONT_DOMAINS[font];
77 if (!allowed) return false;
78 const host = location.hostname.toLowerCase();
79 return allowed.some(suffix => host === suffix || host.endsWith('.' + suffix));
80}
81
82const GENERIC_FONTS = new Set([
83 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy',
84 'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
85 '-apple-system', 'blinkmacsystemfont', 'segoe ui',
86 'inherit', 'initial', 'unset', 'revert',
87]);
88
89const ANTIPATTERNS = [
90 // ── AI slop: tells that something was AI-generated ──
91 {
92 id: 'side-tab',
93 category: 'slop',
94 name: 'Side-tab accent border',
95 description:
96 '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.',
97 skillSection: 'Visual Details',
98 skillGuideline: 'colored accent stripe',
99 },
100 {
101 id: 'border-accent-on-rounded',
102 category: 'slop',
103 name: 'Border accent on rounded element',
104 description:
105 'Thick accent border on a rounded card — the border clashes with the rounded corners. Remove the border or the border-radius.',
106 skillSection: 'Visual Details',
107 skillGuideline: 'colored accent stripe',
108 },
109 {
110 id: 'overused-font',
111 category: 'slop',
112 name: 'Overused font',
113 description:
114 'Inter, Roboto, Open Sans, Lato, Montserrat, and Arial are used on millions of sites. Choose a distinctive font that gives your interface personality.',
115 skillSection: 'Typography',
116 skillGuideline: 'overused fonts like Inter',
117 },
118 {
119 id: 'single-font',
120 category: 'slop',
121 name: 'Single font for everything',
122 description:
123 'Only one font family is used for the entire page. Pair a distinctive display font with a refined body font to create typographic hierarchy.',
124 skillSection: 'Typography',
125 skillGuideline: 'only one font family for the entire page',
126 },
127 {
128 id: 'flat-type-hierarchy',
129 category: 'slop',
130 name: 'Flat type hierarchy',
131 description:
132 '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).',
133 skillSection: 'Typography',
134 skillGuideline: 'flat type hierarchy',
135 },
136 {
137 id: 'gradient-text',
138 category: 'slop',
139 name: 'Gradient text',
140 description:
141 'Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.',
142 skillSection: 'Color & Contrast',
143 skillGuideline: 'gradient text for',
144 },
145 {
146 id: 'ai-color-palette',
147 category: 'slop',
148 name: 'AI color palette',
149 description:
150 'Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.',
151 skillSection: 'Color & Contrast',
152 skillGuideline: 'AI color palette',
153 },
154 {
155 id: 'nested-cards',
156 category: 'slop',
157 name: 'Nested cards',
158 description:
159 'Cards inside cards create visual noise and excessive depth. Flatten the hierarchy — use spacing, typography, and dividers instead of nesting containers.',
160 skillSection: 'Layout & Space',
161 skillGuideline: 'Nest cards inside cards',
162 },
163 {
164 id: 'monotonous-spacing',
165 category: 'slop',
166 name: 'Monotonous spacing',
167 description:
168 'The same spacing value used everywhere — no rhythm, no variation. Use tight groupings for related items and generous separations between sections.',
169 skillSection: 'Layout & Space',
170 skillGuideline: 'same spacing everywhere',
171 },
172 {
173 id: 'everything-centered',
174 category: 'slop',
175 name: 'Everything centered',
176 description:
177 'Every text element is center-aligned. Left-aligned text with asymmetric layouts feels more designed. Center only hero sections and CTAs.',
178 skillSection: 'Layout & Space',
179 skillGuideline: 'Center everything',
180 },
181 {
182 id: 'bounce-easing',
183 category: 'slop',
184 name: 'Bounce or elastic easing',
185 description:
186 'Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.',
187 skillSection: 'Motion',
188 skillGuideline: 'bounce or elastic easing',
189 },
190 {
191 id: 'dark-glow',
192 category: 'slop',
193 name: 'Dark mode with glowing accents',
194 description:
195 '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.',
196 skillSection: 'Color & Contrast',
197 skillGuideline: 'dark mode with glowing accents',
198 },
199 {
200 id: 'icon-tile-stack',
201 category: 'slop',
202 name: 'Icon tile stacked above heading',
203 description:
204 'A small rounded-square icon container above a heading is the universal AI feature-card template — every generator outputs this exact shape. Try a side-by-side icon and heading, or let the icon sit in flow without its own container.',
205 skillSection: 'Typography',
206 skillGuideline: 'large icons with rounded corners above every heading',
207 },
208
209 // ── Quality: general design and accessibility issues ──
210 {
211 id: 'pure-black-white',
212 category: 'quality',
213 name: 'Pure black background',
214 description:
215 '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.',
216 skillSection: 'Color & Contrast',
217 skillGuideline: 'pure black (#000)',
218 },
219 {
220 id: 'gray-on-color',
221 category: 'quality',
222 name: 'Gray text on colored background',
223 description:
224 'Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.',
225 skillSection: 'Color & Contrast',
226 skillGuideline: 'gray text on colored backgrounds',
227 },
228 {
229 id: 'low-contrast',
230 category: 'quality',
231 name: 'Low contrast text',
232 description:
233 '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.',
234 },
235 {
236 id: 'layout-transition',
237 category: 'quality',
238 name: 'Layout property animation',
239 description:
240 'Animating width, height, padding, or margin causes layout thrash and janky performance. Use transform and opacity instead, or grid-template-rows for height animations.',
241 skillSection: 'Motion',
242 skillGuideline: 'Animate layout properties',
243 },
244 {
245 id: 'line-length',
246 category: 'quality',
247 name: 'Line length too long',
248 description:
249 '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.',
250 skillSection: 'Layout & Space',
251 skillGuideline: 'wrap beyond ~80 characters',
252 },
253 {
254 id: 'cramped-padding',
255 category: 'quality',
256 name: 'Cramped padding',
257 description:
258 'Text is too close to the edge of its container. Add at least 8px (ideally 12-16px) of padding inside bordered or colored containers.',
259 },
260 {
261 id: 'tight-leading',
262 category: 'quality',
263 name: 'Tight line height',
264 description:
265 '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.',
266 },
267 {
268 id: 'skipped-heading',
269 category: 'quality',
270 name: 'Skipped heading level',
271 description:
272 '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.',
273 },
274 {
275 id: 'justified-text',
276 category: 'quality',
277 name: 'Justified text',
278 description:
279 '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.',
280 },
281 {
282 id: 'tiny-text',
283 category: 'quality',
284 name: 'Tiny body text',
285 description:
286 'Body text below 12px is hard to read, especially on high-DPI screens. Use at least 14px for body content, 16px is ideal.',
287 },
288 {
289 id: 'all-caps-body',
290 category: 'quality',
291 name: 'All-caps body text',
292 description:
293 '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.',
294 skillSection: 'Typography',
295 skillGuideline: 'long body passages in uppercase',
296 },
297 {
298 id: 'wide-tracking',
299 category: 'quality',
300 name: 'Wide letter spacing on body text',
301 description:
302 'Letter spacing above 0.05em on body text disrupts natural character groupings and slows reading. Reserve wide tracking for short uppercase labels only.',
303 },
304];
305
306// ─── Section 2: Color Utilities ─────────────────────────────────────────────
307
308function isNeutralColor(color) {
309 if (!color || color === 'transparent') return true;
310
311 // rgb/rgba — use channel spread. Threshold 30 ≈ 11.7% of the 0–255 range.
312 const rgb = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
313 if (rgb) {
314 return (Math.max(+rgb[1], +rgb[2], +rgb[3]) - Math.min(+rgb[1], +rgb[2], +rgb[3])) < 30;
315 }
316
317 // oklch()/lch() — chroma is the second numeric component.
318 // oklch chroma is ~0–0.4 in sRGB gamut; >= 0.02 reads as tinted, not gray.
319 // lch chroma is ~0–150; >= 3 reads as tinted. jsdom emits both formats
320 // literally (it does NOT convert them to rgb).
321 const oklch = color.match(/oklch\(\s*[\d.%-]+\s+([\d.-]+)/i);
322 if (oklch) return parseFloat(oklch[1]) < 0.02;
323 const lch = color.match(/lch\(\s*[\d.%-]+\s+([\d.-]+)/i);
324 if (lch) return parseFloat(lch[1]) < 3;
325
326 // oklab()/lab() — a and b are signed axes; chroma = sqrt(a² + b²).
327 // oklab a/b are ~-0.4..0.4, threshold 0.02. lab a/b are ~-128..127, threshold 3.
328 const oklab = color.match(/oklab\(\s*[\d.%-]+\s+([\d.-]+)\s+([\d.-]+)/i);
329 if (oklab) {
330 const a = parseFloat(oklab[1]), b = parseFloat(oklab[2]);
331 return Math.hypot(a, b) < 0.02;
332 }
333 const lab = color.match(/lab\(\s*[\d.%-]+\s+([\d.-]+)\s+([\d.-]+)/i);
334 if (lab) {
335 const a = parseFloat(lab[1]), b = parseFloat(lab[2]);
336 return Math.hypot(a, b) < 3;
337 }
338
339 // hsl/hsla — saturation is the second numeric component (percent).
340 // Modern jsdom usually converts hsl() to rgb, but handle it directly for
341 // safety across versions and for any engine that preserves the format.
342 const hsl = color.match(/hsla?\(\s*[\d.-]+\s*,?\s*([\d.]+)%/i);
343 if (hsl) return parseFloat(hsl[1]) < 10;
344
345 // hwb(hue whiteness% blackness%) — a pixel is fully gray when
346 // whiteness + blackness >= 100; chroma-like saturation = 1 - (w+b)/100.
347 const hwb = color.match(/hwb\(\s*[\d.-]+\s+([\d.]+)%\s+([\d.]+)%/i);
348 if (hwb) {
349 const w = parseFloat(hwb[1]), b = parseFloat(hwb[2]);
350 return (1 - Math.min(100, w + b) / 100) < 0.1;
351 }
352
353 // Unknown / unrecognized format — err on the side of DETECTING rather
354 // than silently skipping. This is the opposite of the previous default,
355 // which was the root cause of the oklch bug.
356 return false;
357}
358
359function parseRgb(color) {
360 if (!color || color === 'transparent') return null;
361 const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
362 if (!m) return null;
363 return { r: +m[1], g: +m[2], b: +m[3], a: m[4] !== undefined ? +m[4] : 1 };
364}
365
366function relativeLuminance({ r, g, b }) {
367 const [rs, gs, bs] = [r / 255, g / 255, b / 255].map(c =>
368 c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
369 );
370 return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
371}
372
373function contrastRatio(c1, c2) {
374 const l1 = relativeLuminance(c1);
375 const l2 = relativeLuminance(c2);
376 return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
377}
378
379function parseGradientColors(bgImage) {
380 if (!bgImage || !bgImage.includes('gradient')) return [];
381 const colors = [];
382 for (const m of bgImage.matchAll(/rgba?\([^)]+\)/g)) {
383 const c = parseRgb(m[0]);
384 if (c) colors.push(c);
385 }
386 for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
387 const h = m[1];
388 if (h.length === 6) {
389 colors.push({ r: parseInt(h.slice(0,2),16), g: parseInt(h.slice(2,4),16), b: parseInt(h.slice(4,6),16), a: 1 });
390 } else {
391 colors.push({ r: parseInt(h[0]+h[0],16), g: parseInt(h[1]+h[1],16), b: parseInt(h[2]+h[2],16), a: 1 });
392 }
393 }
394 return colors;
395}
396
397function hasChroma(c, threshold = 30) {
398 if (!c) return false;
399 return (Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b)) >= threshold;
400}
401
402function getHue(c) {
403 if (!c) return 0;
404 const r = c.r / 255, g = c.g / 255, b = c.b / 255;
405 const max = Math.max(r, g, b), min = Math.min(r, g, b);
406 if (max === min) return 0;
407 const d = max - min;
408 let h;
409 if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
410 else if (max === g) h = ((b - r) / d + 2) / 6;
411 else h = ((r - g) / d + 4) / 6;
412 return Math.round(h * 360);
413}
414
415function colorToHex(c) {
416 if (!c) return '?';
417 return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');
418}
419
420// ─── Section 3: Pure Detection ──────────────────────────────────────────────
421
422function checkBorders(tag, widths, colors, radius) {
423 if (BORDER_SAFE_TAGS.has(tag)) return [];
424 const findings = [];
425 const sides = ['Top', 'Right', 'Bottom', 'Left'];
426
427 for (const side of sides) {
428 const w = widths[side];
429 if (w < 1 || isNeutralColor(colors[side])) continue;
430
431 const otherSides = sides.filter(s => s !== side);
432 const maxOther = Math.max(...otherSides.map(s => widths[s]));
433 if (!(w >= 2 && (maxOther <= 1 || w >= maxOther * 2))) continue;
434
435 const sn = side.toLowerCase();
436 const isSide = side === 'Left' || side === 'Right';
437
438 if (isSide) {
439 if (radius > 0) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` });
440 else if (w >= 3) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px` });
441 } else {
442 if (radius > 0 && w >= 2) findings.push({ id: 'border-accent-on-rounded', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` });
443 }
444 }
445
446 return findings;
447}
448
449// Returns true if the given text is composed entirely of emoji characters
450// (plus whitespace / variation selectors). Emojis render as multicolor glyphs
451// regardless of CSS `color`, so contrast checks against the element's text
452// color are meaningless for these nodes.
453const EMOJI_CHAR_RE = /[\u{1F1E6}-\u{1F1FF}\u{1F300}-\u{1F9FF}\u{1FA00}-\u{1FAFF}\u{2600}-\u{27BF}\u{2300}-\u{23FF}\u{FE0F}\u{200D}\u{1F3FB}-\u{1F3FF}]/u;
454const EMOJI_CHARS_GLOBAL = /[\u{1F1E6}-\u{1F1FF}\u{1F300}-\u{1F9FF}\u{1FA00}-\u{1FAFF}\u{2600}-\u{27BF}\u{2300}-\u{23FF}\u{FE0F}\u{200D}\u{1F3FB}-\u{1F3FF}]/gu;
455function isEmojiOnlyText(text) {
456 if (!text) return false;
457 if (!EMOJI_CHAR_RE.test(text)) return false;
458 return text.replace(EMOJI_CHARS_GLOBAL, '').trim() === '';
459}
460
461function checkColors(opts) {
462 const { tag, textColor, bgColor, effectiveBg, effectiveBgStops, fontSize, fontWeight, hasDirectText, isEmojiOnly, bgClip, bgImage, classList } = opts;
463 if (SAFE_TAGS.has(tag)) return [];
464 const findings = [];
465
466 // Pure black background (only solid or near-solid, not semi-transparent overlays)
467 if (bgColor && bgColor.a >= 0.9 && bgColor.r === 0 && bgColor.g === 0 && bgColor.b === 0) {
468 findings.push({ id: 'pure-black-white', snippet: '#000000 background' });
469 }
470
471 if (hasDirectText && textColor && !isEmojiOnly) {
472 // Run background-dependent checks against either a solid bg or, if the
473 // ancestor is a gradient, against every gradient stop (use the worst case).
474 const bgs = effectiveBg ? [effectiveBg] : (effectiveBgStops && effectiveBgStops.length ? effectiveBgStops : null);
475 if (bgs) {
476 // Gray on colored background — flag if every stop is chromatic
477 const textLum = relativeLuminance(textColor);
478 const isGray = !hasChroma(textColor, 20) && textLum > 0.05 && textLum < 0.85;
479 if (isGray && bgs.every(b => hasChroma(b, 40))) {
480 const bgLabel = effectiveBg ? colorToHex(effectiveBg) : `gradient(${bgs.map(colorToHex).join(', ')})`;
481 findings.push({ id: 'gray-on-color', snippet: `text ${colorToHex(textColor)} on bg ${bgLabel}` });
482 }
483
484 // Low contrast (WCAG AA) — worst case across all bg stops
485 const ratios = bgs.map(b => contrastRatio(textColor, b));
486 let worstIdx = 0;
487 for (let i = 1; i < ratios.length; i++) if (ratios[i] < ratios[worstIdx]) worstIdx = i;
488 const ratio = ratios[worstIdx];
489 const isHeading = ['h1', 'h2', 'h3'].includes(tag);
490 const isLargeText = fontSize >= 18 || (fontSize >= 14 && fontWeight >= 700) || isHeading;
491 const threshold = isLargeText ? 3.0 : 4.5;
492 if (ratio < threshold) {
493 findings.push({ id: 'low-contrast', snippet: `${ratio.toFixed(1)}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(bgs[worstIdx])}` });
494 }
495 }
496
497 // AI palette: purple/violet on headings
498 if (hasChroma(textColor, 50)) {
499 const hue = getHue(textColor);
500 if (hue >= 260 && hue <= 310 && (['h1', 'h2', 'h3'].includes(tag) || fontSize >= 20)) {
501 findings.push({ id: 'ai-color-palette', snippet: `Purple/violet text (${colorToHex(textColor)}) on heading` });
502 }
503 }
504 }
505
506 // Gradient text
507 if (bgClip === 'text' && bgImage && bgImage.includes('gradient')) {
508 findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
509 }
510
511 // Tailwind class checks
512 if (classList) {
513 const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
514 if (/\bbg-black\b(?!\/)/.test(classStr)) {
515 findings.push({ id: 'pure-black-white', snippet: 'bg-black' });
516 }
517
518 const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
519 const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
520 if (grayMatch && colorBgMatch) {
521 findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
522 }
523
524 if (/\bbg-clip-text\b/.test(classStr) && /\bbg-gradient-to-/.test(classStr)) {
525 findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' });
526 }
527
528 const purpleText = classStr.match(/\btext-(?:purple|violet|indigo)-\d+\b/);
529 if (purpleText && (['h1', 'h2', 'h3'].includes(tag) || /\btext-(?:[2-9]xl)\b/.test(classStr))) {
530 findings.push({ id: 'ai-color-palette', snippet: `${purpleText[0]} on heading` });
531 }
532
533 if (/\bfrom-(?:purple|violet|indigo)-\d+\b/.test(classStr) && /\bto-(?:purple|violet|indigo|blue|cyan|pink|fuchsia)-\d+\b/.test(classStr)) {
534 findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet gradient (Tailwind)' });
535 }
536 }
537
538 return findings;
539}
540
541function isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg) {
542 if (!hasShadow && !hasBorder) return false;
543 return hasRadius || hasBg;
544}
545
546const HEADING_TAGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
547
548// Pure check: given a heading and metrics about its previousElementSibling,
549// decide if the sibling is the canonical "icon-tile-stacked-above-heading" shape.
550//
551// Triggers when ALL of the following hold for the sibling:
552// • size 32–128px on both axes (not too small, not a hero image)
553// • aspect ratio 0.7–1.4 (squarish — excludes wide thumbnails / pill badges)
554// • has a non-transparent background-color, background-image, OR a visible border
555// (covers solid colors, white-with-border, gradients — anything that visually
556// defines a tile)
557// • border-radius < width/2 (excludes round avatars; rounded squares pass)
558// • contains an <svg> or icon-class <i> element that's smaller than the tile
559// • the tile sits above the heading (its bottom is above the heading's top)
560function checkIconTile(opts) {
561 const { headingTag, headingText, headingTop,
562 siblingTag, siblingWidth, siblingHeight, siblingBottom,
563 siblingBgColor, siblingBgImage, siblingBorderWidth, siblingBorderRadius,
564 hasIconChild, iconChildWidth } = opts;
565 if (!HEADING_TAGS.has(headingTag)) return [];
566 if (!siblingTag) return [];
567 // Don't recurse into nested headings (e.g. h2 above h3 in a section header)
568 if (HEADING_TAGS.has(siblingTag)) return [];
569
570 // Size window: 32–128px on each axis
571 if (!(siblingWidth >= 32 && siblingWidth <= 128)) return [];
572 if (!(siblingHeight >= 32 && siblingHeight <= 128)) return [];
573
574 // Squarish aspect ratio
575 const ratio = siblingWidth / siblingHeight;
576 if (ratio < 0.7 || ratio > 1.4) return [];
577
578 // Must have something that visually defines the tile
579 const bgVisible = (siblingBgColor && siblingBgColor.a > 0.1)
580 || (siblingBgImage && siblingBgImage !== 'none' && siblingBgImage !== '');
581 const borderVisible = siblingBorderWidth > 0;
582 if (!bgVisible && !borderVisible) return [];
583
584 // Exclude circles (avatars). Rounded squares pass.
585 if (siblingBorderRadius >= siblingWidth / 2) return [];
586
587 // Must contain an icon element smaller than the tile
588 if (!hasIconChild) return [];
589 if (iconChildWidth && iconChildWidth >= siblingWidth * 0.95) return [];
590
591 // Vertical stacking: tile must end above where the heading starts.
592 // (Allow the check to skip when both top/bottom are 0 — jsdom layout case.)
593 if (headingTop && siblingBottom && siblingBottom > headingTop + 4) return [];
594
595 const text = (headingText || '').trim().slice(0, 60);
596 return [{
597 id: 'icon-tile-stack',
598 snippet: `${Math.round(siblingWidth)}x${Math.round(siblingHeight)}px icon tile above ${headingTag} "${text}"`,
599 }];
600}
601
602const LAYOUT_TRANSITION_PROPS = new Set([
603 'width', 'height', 'padding', 'margin',
604 'max-height', 'max-width', 'min-height', 'min-width',
605 'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
606 'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
607]);
608
609function checkMotion(opts) {
610 const { tag, transitionProperty, animationName, timingFunctions, classList } = opts;
611 if (SAFE_TAGS.has(tag)) return [];
612 const findings = [];
613
614 // --- Bounce/elastic easing ---
615 if (animationName && animationName !== 'none' && /bounce|elastic|wobble|jiggle|spring/i.test(animationName)) {
616 findings.push({ id: 'bounce-easing', snippet: `animation: ${animationName}` });
617 }
618 if (classList && /\banimate-bounce\b/.test(classList)) {
619 findings.push({ id: 'bounce-easing', snippet: 'animate-bounce (Tailwind)' });
620 }
621
622 // Check timing functions for overshoot cubic-bezier (y values outside [0, 1])
623 if (timingFunctions) {
624 const bezierRe = /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g;
625 let m;
626 while ((m = bezierRe.exec(timingFunctions)) !== null) {
627 const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]);
628 if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) {
629 findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${m[1]}, ${m[2]}, ${m[3]}, ${m[4]})` });
630 break;
631 }
632 }
633 }
634
635 // --- Layout property transition ---
636 if (transitionProperty && transitionProperty !== 'all' && transitionProperty !== 'none') {
637 const props = transitionProperty.split(',').map(p => p.trim().toLowerCase());
638 const layoutFound = props.filter(p => LAYOUT_TRANSITION_PROPS.has(p));
639 if (layoutFound.length > 0) {
640 findings.push({ id: 'layout-transition', snippet: `transition: ${layoutFound.join(', ')}` });
641 }
642 }
643
644 return findings;
645}
646
647function checkGlow(opts) {
648 const { boxShadow, effectiveBg } = opts;
649 if (!boxShadow || boxShadow === 'none') return [];
650 if (!effectiveBg) return [];
651
652 // Only flag on dark backgrounds (luminance < 0.1)
653 const bgLum = relativeLuminance(effectiveBg);
654 if (bgLum >= 0.1) return [];
655
656 // Split multiple shadows (commas not inside parentheses)
657 const parts = boxShadow.split(/,(?![^(]*\))/);
658 for (const shadow of parts) {
659 const colorMatch = shadow.match(/rgba?\([^)]+\)/);
660 if (!colorMatch) continue;
661 const color = parseRgb(colorMatch[0]);
662 if (!color || !hasChroma(color, 30)) continue;
663
664 // Extract px values — in computed style: "color Xpx Ypx BLURpx [SPREADpx]"
665 const afterColor = shadow.substring(shadow.indexOf(colorMatch[0]) + colorMatch[0].length);
666 const beforeColor = shadow.substring(0, shadow.indexOf(colorMatch[0]));
667 const pxVals = [...beforeColor.matchAll(/([\d.]+)px/g), ...afterColor.matchAll(/([\d.]+)px/g)]
668 .map(m => parseFloat(m[1]));
669
670 // Third value is blur (offset-x, offset-y, blur, [spread])
671 if (pxVals.length >= 3 && pxVals[2] > 4) {
672 return [{ id: 'dark-glow', snippet: `Colored glow (${colorToHex(color)}) on dark background` }];
673 }
674 }
675
676 return [];
677}
678
679/**
680 * Regex-on-HTML checks shared between browser and Node page-level detection.
681 * These don't need DOM access, just the raw HTML string.
682 */
683function checkHtmlPatterns(html) {
684 const findings = [];
685
686 // --- Color ---
687
688 // Pure black background
689 const pureBlackBgRe = /background(?:-color)?\s*:\s*(?:#000000|#000|rgb\(\s*0,\s*0,\s*0\s*\))\b/gi;
690 if (pureBlackBgRe.test(html)) {
691 findings.push({ id: 'pure-black-white', snippet: 'Pure #000 background' });
692 }
693
694 // AI color palette: purple/violet
695 const purpleHexRe = /#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9|6366f1|764ba2|667eea)\b/gi;
696 if (purpleHexRe.test(html)) {
697 const purpleTextRe = /(?:(?:^|;)\s*color\s*:\s*(?:.*?)(?:#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9))|gradient.*?#(?:7c3aed|8b5cf6|a855f7|764ba2|667eea))/gi;
698 if (purpleTextRe.test(html)) {
699 findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected' });
700 }
701 }
702
703 // Gradient text (background-clip: text + gradient)
704 const gradientRe = /(?:-webkit-)?background-clip\s*:\s*text/gi;
705 let gm;
706 while ((gm = gradientRe.exec(html)) !== null) {
707 const start = Math.max(0, gm.index - 200);
708 const context = html.substring(start, gm.index + gm[0].length + 200);
709 if (/gradient/i.test(context)) {
710 findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
711 break;
712 }
713 }
714 if (/\bbg-clip-text\b/.test(html) && /\bbg-gradient-to-/.test(html)) {
715 findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' });
716 }
717
718 // --- Layout ---
719
720 // Monotonous spacing
721 const spacingValues = [];
722 const spacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*(\d+)px/gi;
723 let sm;
724 while ((sm = spacingRe.exec(html)) !== null) {
725 const v = parseInt(sm[1], 10);
726 if (v > 0 && v < 200) spacingValues.push(v);
727 }
728 const gapRe = /gap\s*:\s*(\d+)px/gi;
729 while ((sm = gapRe.exec(html)) !== null) {
730 spacingValues.push(parseInt(sm[1], 10));
731 }
732 const twSpaceRe = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+)\b/g;
733 while ((sm = twSpaceRe.exec(html)) !== null) {
734 spacingValues.push(parseInt(sm[1], 10) * 4);
735 }
736 const remSpacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*([\d.]+)rem/gi;
737 while ((sm = remSpacingRe.exec(html)) !== null) {
738 const v = Math.round(parseFloat(sm[1]) * 16);
739 if (v > 0 && v < 200) spacingValues.push(v);
740 }
741 const roundedSpacing = spacingValues.map(v => Math.round(v / 4) * 4);
742 if (roundedSpacing.length >= 10) {
743 const counts = {};
744 for (const v of roundedSpacing) counts[v] = (counts[v] || 0) + 1;
745 const maxCount = Math.max(...Object.values(counts));
746 const dominantPct = maxCount / roundedSpacing.length;
747 const unique = [...new Set(roundedSpacing)].filter(v => v > 0);
748 if (dominantPct > 0.6 && unique.length <= 3) {
749 const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
750 findings.push({
751 id: 'monotonous-spacing',
752 snippet: `~${dominant}px used ${maxCount}/${roundedSpacing.length} times (${Math.round(dominantPct * 100)}%)`,
753 });
754 }
755 }
756
757 // --- Motion ---
758
759 // Bounce/elastic animation names
760 const bounceRe = /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi;
761 if (bounceRe.test(html)) {
762 findings.push({ id: 'bounce-easing', snippet: 'Bounce/elastic animation in CSS' });
763 }
764
765 // Overshoot cubic-bezier
766 const bezierRe = /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g;
767 let bm;
768 while ((bm = bezierRe.exec(html)) !== null) {
769 const y1 = parseFloat(bm[2]), y2 = parseFloat(bm[4]);
770 if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) {
771 findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})` });
772 break;
773 }
774 }
775
776 // Layout property transitions
777 const transRe = /transition(?:-property)?\s*:\s*([^;{}]+)/gi;
778 let tm;
779 while ((tm = transRe.exec(html)) !== null) {
780 const val = tm[1].toLowerCase();
781 if (/\ball\b/.test(val)) continue;
782 const found = val.match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
783 if (found) {
784 findings.push({ id: 'layout-transition', snippet: `transition: ${found.join(', ')}` });
785 break;
786 }
787 }
788
789 // --- Dark glow ---
790
791 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;
792 const twDarkBg = /\bbg-(?:gray|slate|zinc|neutral|stone)-(?:9\d{2}|800)\b/;
793 if (darkBgRe.test(html) || twDarkBg.test(html)) {
794 const shadowRe = /box-shadow\s*:\s*([^;{}]+)/gi;
795 let shm;
796 while ((shm = shadowRe.exec(html)) !== null) {
797 const val = shm[1];
798 const colorMatch = val.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
799 if (!colorMatch) continue;
800 const [r, g, b] = [+colorMatch[1], +colorMatch[2], +colorMatch[3]];
801 if ((Math.max(r, g, b) - Math.min(r, g, b)) < 30) continue;
802 const pxVals = [...val.matchAll(/(\d+)px|(?<![.\d])\b(0)\b(?![.\d])/g)].map(p => +(p[1] || p[2]));
803 if (pxVals.length >= 3 && pxVals[2] > 4) {
804 findings.push({ id: 'dark-glow', snippet: `Colored glow (rgb(${r},${g},${b})) on dark page` });
805 break;
806 }
807 }
808 }
809
810 return findings;
811}
812
813// ─── Section 4: resolveBackground (unified) ─────────────────────────────────
814
815function resolveBackground(el, win) {
816 let current = el;
817 while (current && current.nodeType === 1) {
818 const style = IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
819
820 // If this element has a background-image (gradient or url), it's visually
821 // opaque but we can't determine the effective color — bail out so callers
822 // don't get a false solid-color answer.
823 const bgImage = style.backgroundImage || '';
824 if (bgImage && bgImage !== 'none' && (/gradient/i.test(bgImage) || /url\s*\(/i.test(bgImage))) {
825 return null;
826 }
827
828 let bg = parseRgb(style.backgroundColor);
829 if (!IS_BROWSER && (!bg || bg.a < 0.1)) {
830 // jsdom doesn't decompose background shorthand — parse raw style attr
831 const rawStyle = current.getAttribute?.('style') || '';
832 const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
833 const inlineBg = bgMatch ? bgMatch[1].trim() : '';
834 // Check for gradient or url() image in inline style too
835 if (/gradient/i.test(inlineBg) || /url\s*\(/i.test(inlineBg)) return null;
836 bg = parseRgb(inlineBg);
837 if (!bg && inlineBg) {
838 const hexMatch = inlineBg.match(/#([0-9a-f]{6}|[0-9a-f]{3})\b/i);
839 if (hexMatch) {
840 const h = hexMatch[1];
841 if (h.length === 6) {
842 bg = { r: parseInt(h.slice(0,2), 16), g: parseInt(h.slice(2,4), 16), b: parseInt(h.slice(4,6), 16), a: 1 };
843 } else {
844 bg = { r: parseInt(h[0]+h[0], 16), g: parseInt(h[1]+h[1], 16), b: parseInt(h[2]+h[2], 16), a: 1 };
845 }
846 }
847 }
848 }
849 if (bg && bg.a > 0.1) {
850 if (IS_BROWSER || bg.a >= 0.5) return bg;
851 }
852 current = current.parentElement;
853 }
854 return { r: 255, g: 255, b: 255 };
855}
856
857// Walk parents looking for a gradient background and return its color stops.
858// Used as a fallback when resolveBackground() returns null because the
859// effective background is a gradient (no single solid color to compare against).
860function resolveGradientStops(el, win) {
861 let current = el;
862 while (current && current.nodeType === 1) {
863 const style = IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
864 const bgImage = style.backgroundImage || '';
865 if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
866 const stops = parseGradientColors(bgImage);
867 if (stops.length > 0) return stops;
868 }
869 if (!IS_BROWSER) {
870 // jsdom doesn't decompose `background:` shorthand — peek at the raw inline style
871 const rawStyle = current.getAttribute?.('style') || '';
872 const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
873 if (bgMatch && /gradient/i.test(bgMatch[1])) {
874 const stops = parseGradientColors(bgMatch[1]);
875 if (stops.length > 0) return stops;
876 }
877 }
878 current = current.parentElement;
879 }
880 return null;
881}
882
883// ─── Section 5: Element Adapters ────────────────────────────────────────────
884
885// Browser adapters — call getComputedStyle/getBoundingClientRect on live DOM
886
887function checkElementBordersDOM(el) {
888 const tag = el.tagName.toLowerCase();
889 if (BORDER_SAFE_TAGS.has(tag)) return [];
890 const rect = el.getBoundingClientRect();
891 if (rect.width < 20 || rect.height < 20) return [];
892 const style = getComputedStyle(el);
893 const sides = ['Top', 'Right', 'Bottom', 'Left'];
894 const widths = {}, colors = {};
895 for (const s of sides) {
896 widths[s] = parseFloat(style[`border${s}Width`]) || 0;
897 colors[s] = style[`border${s}Color`] || '';
898 }
899 return checkBorders(tag, widths, colors, parseFloat(style.borderRadius) || 0);
900}
901
902function checkElementColorsDOM(el) {
903 const tag = el.tagName.toLowerCase();
904 if (SAFE_TAGS.has(tag)) return [];
905 const rect = el.getBoundingClientRect();
906 if (rect.width < 10 || rect.height < 10) return [];
907 const style = getComputedStyle(el);
908 const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
909 const hasDirectText = directText.trim().length > 0;
910 const effectiveBg = resolveBackground(el);
911 return checkColors({
912 tag,
913 textColor: parseRgb(style.color),
914 bgColor: parseRgb(style.backgroundColor),
915 effectiveBg,
916 effectiveBgStops: effectiveBg ? null : resolveGradientStops(el),
917 fontSize: parseFloat(style.fontSize) || 16,
918 fontWeight: parseInt(style.fontWeight) || 400,
919 hasDirectText,
920 isEmojiOnly: isEmojiOnlyText(directText),
921 bgClip: style.webkitBackgroundClip || style.backgroundClip || '',
922 bgImage: style.backgroundImage || '',
923 classList: el.getAttribute('class') || '',
924 });
925}
926
927function checkElementIconTileDOM(el) {
928 const tag = el.tagName.toLowerCase();
929 if (!HEADING_TAGS.has(tag)) return [];
930 const sibling = el.previousElementSibling;
931 if (!sibling) return [];
932
933 const sibRect = sibling.getBoundingClientRect();
934 const headRect = el.getBoundingClientRect();
935 const sibStyle = getComputedStyle(sibling);
936
937 // The tile may either contain an <svg>/<i> icon child, OR the tile itself
938 // may contain an emoji/symbol character directly as its only text content
939 // (the "card-icon" pattern from many AI-generated demos).
940 const iconChild = sibling.querySelector('svg, i[data-lucide], i[class*="fa-"], i[class*="icon"]');
941 const iconRect = iconChild?.getBoundingClientRect();
942 const sibDirectText = [...sibling.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
943 const hasInlineEmojiIcon = sibling.children.length === 0 && isEmojiOnlyText(sibDirectText);
944
945 return checkIconTile({
946 headingTag: tag,
947 headingText: el.textContent || '',
948 headingTop: headRect.top,
949 siblingTag: sibling.tagName.toLowerCase(),
950 siblingWidth: sibRect.width,
951 siblingHeight: sibRect.height,
952 siblingBottom: sibRect.bottom,
953 siblingBgColor: parseRgb(sibStyle.backgroundColor),
954 siblingBgImage: sibStyle.backgroundImage || '',
955 siblingBorderWidth: parseFloat(sibStyle.borderTopWidth) || 0,
956 siblingBorderRadius: parseFloat(sibStyle.borderRadius) || 0,
957 hasIconChild: !!iconChild || hasInlineEmojiIcon,
958 iconChildWidth: iconRect?.width || 0,
959 });
960}
961
962function checkElementMotionDOM(el) {
963 const tag = el.tagName.toLowerCase();
964 if (SAFE_TAGS.has(tag)) return [];
965 const style = getComputedStyle(el);
966 return checkMotion({
967 tag,
968 transitionProperty: style.transitionProperty || '',
969 animationName: style.animationName || '',
970 timingFunctions: [style.animationTimingFunction, style.transitionTimingFunction].filter(Boolean).join(' '),
971 classList: el.getAttribute('class') || '',
972 });
973}
974
975function checkElementGlowDOM(el) {
976 const tag = el.tagName.toLowerCase();
977 const style = getComputedStyle(el);
978 if (!style.boxShadow || style.boxShadow === 'none') return [];
979 // Use parent's background — glow radiates outward, so the surrounding context matters
980 // If resolveBackground returns null (gradient), try to infer from the gradient colors
981 let parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el);
982 if (!parentBg) {
983 // Gradient background — sample its colors to determine if it's dark
984 let cur = el.parentElement;
985 while (cur && cur.nodeType === 1) {
986 const bgImage = getComputedStyle(cur).backgroundImage || '';
987 const gradColors = parseGradientColors(bgImage);
988 if (gradColors.length > 0) {
989 // Average the gradient colors
990 const avg = { r: 0, g: 0, b: 0 };
991 for (const c of gradColors) { avg.r += c.r; avg.g += c.g; avg.b += c.b; }
992 avg.r = Math.round(avg.r / gradColors.length);
993 avg.g = Math.round(avg.g / gradColors.length);
994 avg.b = Math.round(avg.b / gradColors.length);
995 parentBg = avg;
996 break;
997 }
998 cur = cur.parentElement;
999 }
1000 }
1001 return checkGlow({ tag, boxShadow: style.boxShadow, effectiveBg: parentBg });
1002}
1003
1004function checkElementAIPaletteDOM(el) {
1005 const style = getComputedStyle(el);
1006 const findings = [];
1007
1008 // Check gradient backgrounds for purple/violet or cyan
1009 const bgImage = style.backgroundImage || '';
1010 const gradColors = parseGradientColors(bgImage);
1011 for (const c of gradColors) {
1012 if (hasChroma(c, 50)) {
1013 const hue = getHue(c);
1014 if (hue >= 260 && hue <= 310) {
1015 findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet gradient background' });
1016 break;
1017 }
1018 if (hue >= 160 && hue <= 200) {
1019 findings.push({ id: 'ai-color-palette', snippet: 'Cyan gradient background' });
1020 break;
1021 }
1022 }
1023 }
1024
1025 // Check for neon text (vivid cyan/purple color on dark background)
1026 const textColor = parseRgb(style.color);
1027 if (textColor && hasChroma(textColor, 80)) {
1028 const hue = getHue(textColor);
1029 const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
1030 if (isAIPalette) {
1031 const parentBg = el.parentElement ? resolveBackground(el.parentElement) : null;
1032 // Also check gradient parents
1033 let effectiveBg = parentBg;
1034 if (!effectiveBg) {
1035 let cur = el.parentElement;
1036 while (cur && cur.nodeType === 1) {
1037 const gi = getComputedStyle(cur).backgroundImage || '';
1038 const gc = parseGradientColors(gi);
1039 if (gc.length > 0) {
1040 const avg = { r: 0, g: 0, b: 0 };
1041 for (const c of gc) { avg.r += c.r; avg.g += c.g; avg.b += c.b; }
1042 avg.r = Math.round(avg.r / gc.length);
1043 avg.g = Math.round(avg.g / gc.length);
1044 avg.b = Math.round(avg.b / gc.length);
1045 effectiveBg = avg;
1046 break;
1047 }
1048 cur = cur.parentElement;
1049 }
1050 }
1051 if (effectiveBg && relativeLuminance(effectiveBg) < 0.1) {
1052 const label = hue >= 260 ? 'Purple/violet' : 'Cyan';
1053 findings.push({ id: 'ai-color-palette', snippet: `${label} neon text on dark background` });
1054 }
1055 }
1056 }
1057
1058 return findings;
1059}
1060
1061const QUALITY_TEXT_TAGS = new Set(['p', 'li', 'td', 'th', 'dd', 'blockquote', 'figcaption']);
1062
1063// Resolve a CSS font-size value to pixels by walking up the parent chain.
1064// Browsers resolve em/rem/% to px in getComputedStyle, but jsdom returns the
1065// specified value verbatim — so for the Node path we walk parents ourselves.
1066function resolveFontSizePx(el, win) {
1067 const chain = []; // raw font-size strings, leaf → root
1068 let cur = el;
1069 while (cur && cur.nodeType === 1) {
1070 const fs = (win ? win.getComputedStyle(cur) : getComputedStyle(cur)).fontSize;
1071 chain.push(fs || '');
1072 cur = cur.parentElement;
1073 }
1074 // Walk root → leaf, resolving each value relative to its parent context.
1075 let px = 16; // root default
1076 for (let i = chain.length - 1; i >= 0; i--) {
1077 const v = chain[i];
1078 if (!v || v === 'inherit') continue;
1079 const num = parseFloat(v);
1080 if (isNaN(num)) continue;
1081 if (v.endsWith('px')) px = num;
1082 else if (v.endsWith('rem')) px = num * 16;
1083 else if (v.endsWith('em')) px = num * px;
1084 else if (v.endsWith('%')) px = (num / 100) * px;
1085 else px = num; // unitless — already resolved
1086 }
1087 return px;
1088}
1089
1090// Resolve a CSS length value (line-height, letter-spacing, etc.) given a
1091// known font-size context. Returns null for "normal" / unparseable values.
1092function resolveLengthPx(value, fontSizePx) {
1093 if (!value || value === 'normal' || value === 'auto' || value === 'inherit') return null;
1094 const num = parseFloat(value);
1095 if (isNaN(num)) return null;
1096 if (value.endsWith('px')) return num;
1097 if (value.endsWith('rem')) return num * 16;
1098 if (value.endsWith('em')) return num * fontSizePx;
1099 if (value.endsWith('%')) return (num / 100) * fontSizePx;
1100 // Unitless line-height = multiplier, return px equivalent
1101 return num * fontSizePx;
1102}
1103
1104// Pure quality checks. Most run on computed CSS and DOM-only inputs (work in
1105// jsdom and the browser). Two checks (line-length, cramped-padding) gate on
1106// element rect dimensions, which jsdom can't compute — pass `rect: null` from
1107// the Node adapter to skip those.
1108//
1109// Both adapters resolve font-size, line-height and letter-spacing to pixels
1110// before calling this so the pure function only deals with numbers.
1111function checkQuality(opts) {
1112 const { el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax = 80 } = opts;
1113 const findings = [];
1114 // Skip browser extension injected elements
1115 const elId = el.id || '';
1116 if (elId.startsWith('claude-') || elId.startsWith('cic-')) return findings;
1117
1118 // --- Line length too long --- (browser-only: needs rect.width)
1119 if (rect && hasDirectText && QUALITY_TEXT_TAGS.has(tag) && rect.width > 0 && textLen > lineMax) {
1120 const charsPerLine = rect.width / (fontSize * 0.5);
1121 if (charsPerLine > lineMax + 5) {
1122 findings.push({ id: 'line-length', snippet: `~${Math.round(charsPerLine)} chars/line (aim for <${lineMax})` });
1123 }
1124 }
1125
1126 // --- Cramped padding --- (browser-only: needs rect to skip small badges/labels)
1127 // Vertical and horizontal thresholds are independent because line-height
1128 // already provides built-in vertical breathing room (the line box is taller
1129 // than the cap height), but horizontal has no equivalent. Both scale with
1130 // font-size — bigger text demands proportionally more padding.
1131 // vertical: max(4px, fontSize × 0.3)
1132 // horizontal: max(8px, fontSize × 0.5)
1133 if (rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) {
1134 const borders = {
1135 top: parseFloat(style.borderTopWidth) || 0,
1136 right: parseFloat(style.borderRightWidth) || 0,
1137 bottom: parseFloat(style.borderBottomWidth) || 0,
1138 left: parseFloat(style.borderLeftWidth) || 0,
1139 };
1140 const borderCount = Object.values(borders).filter(w => w > 0).length;
1141 const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)';
1142 if (borderCount >= 2 || hasBg) {
1143 const vPads = [], hPads = [];
1144 if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0);
1145 if (hasBg || borders.bottom > 0) vPads.push(parseFloat(style.paddingBottom) || 0);
1146 if (hasBg || borders.left > 0) hPads.push(parseFloat(style.paddingLeft) || 0);
1147 if (hasBg || borders.right > 0) hPads.push(parseFloat(style.paddingRight) || 0);
1148
1149 const vMin = vPads.length ? Math.min(...vPads) : Infinity;
1150 const hMin = hPads.length ? Math.min(...hPads) : Infinity;
1151 const vThresh = Math.max(4, fontSize * 0.3);
1152 const hThresh = Math.max(8, fontSize * 0.5);
1153
1154 // Emit at most one finding per element — pick whichever axis is worse.
1155 if (vMin < vThresh) {
1156 findings.push({ id: 'cramped-padding', snippet: `${vMin}px vertical padding (need ≥${vThresh.toFixed(1)}px for ${fontSize}px text)` });
1157 } else if (hMin < hThresh) {
1158 findings.push({ id: 'cramped-padding', snippet: `${hMin}px horizontal padding (need ≥${hThresh.toFixed(1)}px for ${fontSize}px text)` });
1159 }
1160 }
1161 }
1162
1163 // --- Tight line height ---
1164 if (hasDirectText && textLen > 50 && !['h1','h2','h3','h4','h5','h6'].includes(tag)) {
1165 if (lineHeightPx != null && fontSize > 0) {
1166 const ratio = lineHeightPx / fontSize;
1167 if (ratio > 0 && ratio < 1.3) {
1168 findings.push({ id: 'tight-leading', snippet: `line-height ${ratio.toFixed(2)}x (need >=1.3)` });
1169 }
1170 }
1171 }
1172
1173 // --- Justified text (without hyphens) ---
1174 if (hasDirectText && style.textAlign === 'justify') {
1175 const hyphens = style.hyphens || style.webkitHyphens || '';
1176 if (hyphens !== 'auto') {
1177 findings.push({ id: 'justified-text', snippet: 'text-align: justify without hyphens: auto' });
1178 }
1179 }
1180
1181 // --- Tiny body text ---
1182 // Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.)
1183 if (hasDirectText && textLen > 20 && fontSize < 12) {
1184 const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption'];
1185 const inUIContext = el.closest && el.closest('button, a, label, summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="label" i], [class*="caption" i]');
1186 const isUppercase = style.textTransform === 'uppercase';
1187 if (!skipTags.includes(tag) && !inUIContext && !isUppercase) {
1188 findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` });
1189 }
1190 }
1191
1192 // --- All-caps body text ---
1193 if (hasDirectText && textLen > 30 && style.textTransform === 'uppercase') {
1194 if (!['h1','h2','h3','h4','h5','h6'].includes(tag)) {
1195 findings.push({ id: 'all-caps-body', snippet: `text-transform: uppercase on ${textLen} chars of body text` });
1196 }
1197 }
1198
1199 // --- Wide letter spacing on body text ---
1200 if (hasDirectText && textLen > 20 && style.textTransform !== 'uppercase') {
1201 if (letterSpacingPx != null && letterSpacingPx > 0 && fontSize > 0) {
1202 const trackingEm = letterSpacingPx / fontSize;
1203 if (trackingEm > 0.05) {
1204 findings.push({ id: 'wide-tracking', snippet: `letter-spacing: ${trackingEm.toFixed(2)}em on body text` });
1205 }
1206 }
1207 }
1208
1209 return findings;
1210}
1211
1212function checkElementQualityDOM(el) {
1213 const tag = el.tagName.toLowerCase();
1214 const style = getComputedStyle(el);
1215 const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 10);
1216 const textLen = el.textContent?.trim().length || 0;
1217 // Browser getComputedStyle resolves everything to px — direct parseFloat
1218 // works.
1219 const fontSize = parseFloat(style.fontSize) || 16;
1220 const lineHeightPx = resolveLengthPx(style.lineHeight, fontSize);
1221 const letterSpacingPx = resolveLengthPx(style.letterSpacing, fontSize);
1222 const rect = el.getBoundingClientRect();
1223 const lineMax = (typeof window !== 'undefined' && window.__IMPECCABLE_CONFIG__?.lineLengthMax) || 80;
1224 return checkQuality({ el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax });
1225}
1226
1227// Pure page-level skipped-heading walk. Takes a Document so it works in both
1228// the browser and jsdom.
1229function checkPageQualityFromDoc(doc) {
1230 const findings = [];
1231 const headings = doc.querySelectorAll('h1, h2, h3, h4, h5, h6');
1232 let prevLevel = 0;
1233 let prevText = '';
1234 for (const h of headings) {
1235 const level = parseInt(h.tagName[1]);
1236 const text = (h.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 60);
1237 if (prevLevel > 0 && level > prevLevel + 1) {
1238 findings.push({
1239 id: 'skipped-heading',
1240 snippet: `<h${prevLevel}> "${prevText}" followed by <h${level}> "${text}" (missing h${prevLevel + 1})`,
1241 });
1242 }
1243 prevLevel = level;
1244 prevText = text;
1245 }
1246 return findings;
1247}
1248
1249// Browser adapter (returns the legacy { type, detail } shape used by the overlay loop)
1250function checkPageQualityDOM() {
1251 return checkPageQualityFromDoc(document).map(f => ({ type: f.id, detail: f.snippet }));
1252}
1253
1254// Node adapters — take pre-extracted jsdom computed style
1255
1256// jsdom doesn't lay out OR resolve em/rem/% to px — so we pre-resolve every
1257// CSS length the rule needs ourselves (walking the parent chain for
1258// font-size inheritance), and pass `rect: null` to skip the two rules that
1259// genuinely need element rects (line-length, cramped-padding).
1260function checkElementQuality(el, style, tag, window) {
1261 const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 10);
1262 const textLen = el.textContent?.trim().length || 0;
1263 const fontSize = resolveFontSizePx(el, window);
1264 const lineHeightPx = resolveLengthPx(style.lineHeight, fontSize);
1265 const letterSpacingPx = resolveLengthPx(style.letterSpacing, fontSize);
1266 return checkQuality({ el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect: null });
1267}
1268
1269function checkElementBorders(tag, style, overrides) {
1270 const sides = ['Top', 'Right', 'Bottom', 'Left'];
1271 const widths = {}, colors = {};
1272 for (const s of sides) {
1273 widths[s] = parseFloat(style[`border${s}Width`]) || 0;
1274 colors[s] = style[`border${s}Color`] || '';
1275 // jsdom silently drops any border shorthand containing var(), leaving
1276 // both width and color empty on the computed style. When the detectHtml
1277 // pre-pass pulled a resolved value off the rule, use it to fill in the
1278 // missing side so the side-tab check can run. Real browsers resolve
1279 // var() natively, so this fallback is a no-op in the browser path.
1280 if (widths[s] === 0 && overrides && overrides[s]) {
1281 widths[s] = overrides[s].width;
1282 colors[s] = overrides[s].color;
1283 } else if (colors[s] && colors[s].startsWith('var(') && overrides && overrides[s]) {
1284 // Longhand case: jsdom kept the width but left the color as the
1285 // literal `var(...)` string. Substitute the resolved color.
1286 colors[s] = overrides[s].color;
1287 }
1288 }
1289 return checkBorders(tag, widths, colors, parseFloat(style.borderRadius) || 0);
1290}
1291
1292function checkElementColors(el, style, tag, window) {
1293 const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
1294 const hasDirectText = directText.trim().length > 0;
1295
1296 const effectiveBg = resolveBackground(el, window);
1297 return checkColors({
1298 tag,
1299 textColor: parseRgb(style.color),
1300 bgColor: parseRgb(style.backgroundColor),
1301 effectiveBg,
1302 effectiveBgStops: effectiveBg ? null : resolveGradientStops(el, window),
1303 fontSize: parseFloat(style.fontSize) || 16,
1304 fontWeight: parseInt(style.fontWeight) || 400,
1305 hasDirectText,
1306 isEmojiOnly: isEmojiOnlyText(directText),
1307 bgClip: style.webkitBackgroundClip || style.backgroundClip || '',
1308 bgImage: style.backgroundImage || '',
1309 classList: el.getAttribute?.('class') || el.className || '',
1310 });
1311}
1312
1313function checkElementIconTile(el, tag, window) {
1314 if (!HEADING_TAGS.has(tag)) return [];
1315 const sibling = el.previousElementSibling;
1316 if (!sibling) return [];
1317
1318 const sibStyle = window.getComputedStyle(sibling);
1319 // jsdom doesn't lay out — read explicit pixel dimensions from CSS instead.
1320 const sibWidth = parseFloat(sibStyle.width) || 0;
1321 const sibHeight = parseFloat(sibStyle.height) || 0;
1322
1323 const iconChild = sibling.querySelector('svg, i[data-lucide], i[class*="fa-"], i[class*="icon"]');
1324 let iconWidth = 0;
1325 if (iconChild) {
1326 const iconStyle = window.getComputedStyle(iconChild);
1327 iconWidth = parseFloat(iconStyle.width) || parseFloat(iconChild.getAttribute('width')) || 0;
1328 }
1329 // Or: tile contains an emoji/symbol character directly as its only content
1330 const sibDirectText = [...sibling.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
1331 const hasInlineEmojiIcon = sibling.children.length === 0 && isEmojiOnlyText(sibDirectText);
1332
1333 return checkIconTile({
1334 headingTag: tag,
1335 headingText: el.textContent || '',
1336 headingTop: 0, // jsdom: no layout, skip vertical-stacking gate
1337 siblingTag: sibling.tagName.toLowerCase(),
1338 siblingWidth: sibWidth,
1339 siblingHeight: sibHeight,
1340 siblingBottom: 0,
1341 siblingBgColor: parseRgb(sibStyle.backgroundColor),
1342 siblingBgImage: sibStyle.backgroundImage || '',
1343 siblingBorderWidth: parseFloat(sibStyle.borderTopWidth) || 0,
1344 siblingBorderRadius: parseFloat(sibStyle.borderRadius) || 0,
1345 hasIconChild: !!iconChild || hasInlineEmojiIcon,
1346 iconChildWidth: iconWidth,
1347 });
1348}
1349
1350function checkElementMotion(tag, style) {
1351 return checkMotion({
1352 tag,
1353 transitionProperty: style.transitionProperty || '',
1354 animationName: style.animationName || '',
1355 timingFunctions: [style.animationTimingFunction, style.transitionTimingFunction].filter(Boolean).join(' '),
1356 classList: '',
1357 });
1358}
1359
1360function checkElementGlow(tag, style, effectiveBg) {
1361 if (!style.boxShadow || style.boxShadow === 'none') return [];
1362 return checkGlow({ tag, boxShadow: style.boxShadow, effectiveBg });
1363}
1364
1365// ─── Section 6: Page-Level Checks ───────────────────────────────────────────
1366
1367// Browser page-level checks — use document/getComputedStyle globals
1368
1369function checkTypography() {
1370 const findings = [];
1371
1372 // Walk actual text-bearing elements and tally font usage by *computed style*.
1373 // This is much more accurate than scanning CSS rules — it ignores rules that
1374 // exist in the stylesheet but apply to nothing (e.g. demo classes showing
1375 // anti-patterns), and counts what the user actually sees.
1376 const fontUsage = new Map(); // primary font name → count of elements
1377 let totalTextElements = 0;
1378 for (const el of document.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, td, th, dd, blockquote, figcaption, a, button, label, span')) {
1379 // Skip impeccable's own elements
1380 if (el.closest && el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue;
1381 // Only count elements that actually have visible direct text
1382 const hasText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
1383 if (!hasText) continue;
1384 const style = getComputedStyle(el);
1385 const ff = style.fontFamily;
1386 if (!ff) continue;
1387 const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
1388 const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
1389 if (!primary) continue;
1390 fontUsage.set(primary, (fontUsage.get(primary) || 0) + 1);
1391 totalTextElements++;
1392 }
1393
1394 if (totalTextElements >= 20) {
1395 // A font is "primary" if it's used by at least 15% of text elements
1396 const PRIMARY_THRESHOLD = 0.15;
1397 for (const [font, count] of fontUsage) {
1398 const share = count / totalTextElements;
1399 if (share < PRIMARY_THRESHOLD) continue;
1400 if (!OVERUSED_FONTS.has(font)) continue;
1401 if (isBrandFontOnOwnDomain(font)) continue;
1402 findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
1403 }
1404
1405 // Single-font check: only one distinct primary font across all text
1406 if (fontUsage.size === 1) {
1407 const only = [...fontUsage.keys()][0];
1408 findings.push({ type: 'single-font', detail: `only font used is ${only}` });
1409 }
1410 }
1411
1412 const sizes = new Set();
1413 for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) {
1414 const fs = parseFloat(getComputedStyle(el).fontSize);
1415 if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10);
1416 }
1417 if (sizes.size >= 3) {
1418 const sorted = [...sizes].sort((a, b) => a - b);
1419 const ratio = sorted[sorted.length - 1] / sorted[0];
1420 if (ratio < 2.0) {
1421 findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` });
1422 }
1423 }
1424
1425 return findings;
1426}
1427
1428function isCardLikeDOM(el) {
1429 const tag = el.tagName.toLowerCase();
1430 if (SAFE_TAGS.has(tag) || ['input','select','textarea','img','video','canvas','picture'].includes(tag)) return false;
1431 const style = getComputedStyle(el);
1432 const cls = el.getAttribute('class') || '';
1433 const hasShadow = (style.boxShadow && style.boxShadow !== 'none') || /\bshadow(?:-sm|-md|-lg|-xl|-2xl)?\b/.test(cls);
1434 const hasBorder = /\bborder\b/.test(cls);
1435 const hasRadius = parseFloat(style.borderRadius) > 0 || /\brounded(?:-sm|-md|-lg|-xl|-2xl|-full)?\b/.test(cls);
1436 const hasBg = (style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)') || /\bbg-(?:white|gray-\d+|slate-\d+)\b/.test(cls);
1437 return isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg);
1438}
1439
1440function checkLayout() {
1441 const findings = [];
1442 const flaggedEls = new Set();
1443
1444 for (const el of document.querySelectorAll('*')) {
1445 if (!isCardLikeDOM(el) || flaggedEls.has(el)) continue;
1446 const cls = el.getAttribute('class') || '';
1447 const style = getComputedStyle(el);
1448 if (style.position === 'absolute' || style.position === 'fixed') continue;
1449 if (/\b(?:dropdown|popover|tooltip|menu|modal|dialog)\b/i.test(cls)) continue;
1450 if ((el.textContent?.trim().length || 0) < 10) continue;
1451 const rect = el.getBoundingClientRect();
1452 if (rect.width < 50 || rect.height < 30) continue;
1453
1454 let parent = el.parentElement;
1455 while (parent) {
1456 if (isCardLikeDOM(parent)) { flaggedEls.add(el); break; }
1457 parent = parent.parentElement;
1458 }
1459 }
1460
1461 for (const el of flaggedEls) {
1462 let isAncestor = false;
1463 for (const other of flaggedEls) {
1464 if (other !== el && el.contains(other)) { isAncestor = true; break; }
1465 }
1466 if (!isAncestor) findings.push({ type: 'nested-cards', detail: 'Card inside card', el });
1467 }
1468
1469 return findings;
1470}
1471
1472// Node page-level checks — take document/window as parameters
1473
1474function checkPageTypography(doc, win) {
1475 const findings = [];
1476
1477 const fonts = new Set();
1478 const overusedFound = new Set();
1479
1480 for (const sheet of doc.styleSheets) {
1481 let rules;
1482 try { rules = sheet.cssRules || sheet.rules; } catch { continue; }
1483 if (!rules) continue;
1484 for (const rule of rules) {
1485 if (rule.type !== 1) continue;
1486 const ff = rule.style?.fontFamily;
1487 if (!ff) continue;
1488 const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
1489 const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
1490 if (primary) {
1491 fonts.add(primary);
1492 if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
1493 }
1494 }
1495 }
1496
1497 // Check Google Fonts links in HTML
1498 const html = doc.documentElement?.outerHTML || '';
1499 const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
1500 let m;
1501 while ((m = gfRe.exec(html)) !== null) {
1502 const families = m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase());
1503 for (const f of families) {
1504 fonts.add(f);
1505 if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
1506 }
1507 }
1508
1509 // Also parse raw HTML/style content for font-family (jsdom may not expose all via CSSOM)
1510 const ffRe = /font-family\s*:\s*([^;}]+)/gi;
1511 let fm;
1512 while ((fm = ffRe.exec(html)) !== null) {
1513 for (const f of fm[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) {
1514 if (f && !GENERIC_FONTS.has(f)) {
1515 fonts.add(f);
1516 if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
1517 }
1518 }
1519 }
1520
1521 for (const font of overusedFound) {
1522 findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` });
1523 }
1524
1525 // Single font
1526 if (fonts.size === 1) {
1527 const els = doc.querySelectorAll('*');
1528 if (els.length >= 20) {
1529 findings.push({ id: 'single-font', snippet: `only font used is ${[...fonts][0]}` });
1530 }
1531 }
1532
1533 // Flat type hierarchy
1534 const sizes = new Set();
1535 const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div');
1536 for (const el of textEls) {
1537 const fontSize = parseFloat(win.getComputedStyle(el).fontSize);
1538 // Filter out sub-8px values (jsdom doesn't resolve relative units properly)
1539 if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10);
1540 }
1541 if (sizes.size >= 3) {
1542 const sorted = [...sizes].sort((a, b) => a - b);
1543 const ratio = sorted[sorted.length - 1] / sorted[0];
1544 if (ratio < 2.0) {
1545 findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` });
1546 }
1547 }
1548
1549 return findings;
1550}
1551
1552function isCardLike(el, win) {
1553 const tag = el.tagName.toLowerCase();
1554 if (SAFE_TAGS.has(tag) || ['input', 'select', 'textarea', 'img', 'video', 'canvas', 'picture'].includes(tag)) return false;
1555
1556 const style = win.getComputedStyle(el);
1557 const rawStyle = el.getAttribute?.('style') || '';
1558 const cls = el.getAttribute?.('class') || '';
1559
1560 const hasShadow = (style.boxShadow && style.boxShadow !== 'none') ||
1561 /\bshadow(?:-sm|-md|-lg|-xl|-2xl)?\b/.test(cls) || /box-shadow/i.test(rawStyle);
1562 const hasBorder = /\bborder\b/.test(cls);
1563 const hasRadius = (parseFloat(style.borderRadius) || 0) > 0 ||
1564 /\brounded(?:-sm|-md|-lg|-xl|-2xl|-full)?\b/.test(cls) || /border-radius/i.test(rawStyle);
1565 const hasBg = /\bbg-(?:white|gray-\d+|slate-\d+)\b/.test(cls) ||
1566 /background(?:-color)?\s*:\s*(?!transparent)/i.test(rawStyle);
1567
1568 return isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg);
1569}
1570
1571function checkPageLayout(doc, win) {
1572 const findings = [];
1573
1574 // Nested cards
1575 const allEls = doc.querySelectorAll('*');
1576 const flaggedEls = new Set();
1577 for (const el of allEls) {
1578 if (!isCardLike(el, win)) continue;
1579 if (flaggedEls.has(el)) continue;
1580
1581 const tag = el.tagName.toLowerCase();
1582 const cls = el.getAttribute?.('class') || '';
1583 const rawStyle = el.getAttribute?.('style') || '';
1584
1585 if (['pre', 'code'].includes(tag)) continue;
1586 if (/\b(?:absolute|fixed)\b/.test(cls) || /position\s*:\s*(?:absolute|fixed)/i.test(rawStyle)) continue;
1587 if ((el.textContent?.trim().length || 0) < 10) continue;
1588 if (/\b(?:dropdown|popover|tooltip|menu|modal|dialog)\b/i.test(cls)) continue;
1589
1590 // Walk up to find card-like ancestor
1591 let parent = el.parentElement;
1592 while (parent) {
1593 if (isCardLike(parent, win)) {
1594 flaggedEls.add(el);
1595 break;
1596 }
1597 parent = parent.parentElement;
1598 }
1599 }
1600
1601 // Only report innermost nested cards
1602 for (const el of flaggedEls) {
1603 let isAncestorOfFlagged = false;
1604 for (const other of flaggedEls) {
1605 if (other !== el && el.contains(other)) {
1606 isAncestorOfFlagged = true;
1607 break;
1608 }
1609 }
1610 if (!isAncestorOfFlagged) {
1611 findings.push({ id: 'nested-cards', snippet: `Card inside card (${el.tagName.toLowerCase()})` });
1612 }
1613 }
1614
1615 // Everything centered
1616 const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, li, div, button');
1617 let centeredCount = 0;
1618 let totalText = 0;
1619 for (const el of textEls) {
1620 const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length >= 3);
1621 if (!hasDirectText) continue;
1622 totalText++;
1623
1624 let cur = el;
1625 let isCentered = false;
1626 while (cur && cur.nodeType === 1) {
1627 const rawStyle = cur.getAttribute?.('style') || '';
1628 const cls = cur.getAttribute?.('class') || '';
1629 if (/text-align\s*:\s*center/i.test(rawStyle) || /\btext-center\b/.test(cls)) {
1630 isCentered = true;
1631 break;
1632 }
1633 if (cur.tagName === 'BODY') break;
1634 cur = cur.parentElement;
1635 }
1636 if (isCentered) centeredCount++;
1637 }
1638
1639 if (totalText >= 5 && centeredCount / totalText > 0.7) {
1640 findings.push({
1641 id: 'everything-centered',
1642 snippet: `${centeredCount}/${totalText} text elements centered (${Math.round(centeredCount / totalText * 100)}%)`,
1643 });
1644 }
1645
1646 return findings;
1647}
1648
1649// ─── Section 7: Browser UI (IS_BROWSER only) ────────────────────────────────
1650
1651if (IS_BROWSER) {
1652 // Detect extension mode via the script tag's data attribute or the document element fallback.
1653 // currentScript is reliable for synchronously-executing scripts (which our IIFE is).
1654 const _myScript = document.currentScript;
1655 const EXTENSION_MODE = (_myScript && _myScript.dataset.impeccableExtension === 'true')
1656 || document.documentElement.dataset.impeccableExtension === 'true';
1657
1658 const BRAND_COLOR = 'oklch(55% 0.25 350)';
1659 const BRAND_COLOR_HOVER = 'oklch(45% 0.25 350)';
1660 const LABEL_BG = BRAND_COLOR;
1661 const OUTLINE_COLOR = BRAND_COLOR;
1662
1663 // Inject hover styles via CSS (more reliable than JS event listeners)
1664 const styleEl = document.createElement('style');
1665 styleEl.textContent = `
1666 @keyframes impeccable-reveal {
1667 from { opacity: 0; }
1668 to { opacity: 1; }
1669 }
1670 .impeccable-overlay:not(.impeccable-banner) {
1671 pointer-events: none;
1672 outline: 2px solid ${OUTLINE_COLOR};
1673 border-radius: 4px;
1674 transition: outline-color 0.15s ease;
1675 animation: impeccable-reveal 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
1676 animation-play-state: paused;
1677 border-top-left-radius: 0;
1678 }
1679 .impeccable-overlay.impeccable-visible {
1680 animation-play-state: running;
1681 }
1682 .impeccable-overlay.impeccable-hover {
1683 outline-color: ${BRAND_COLOR_HOVER};
1684 z-index: 100001 !important;
1685 }
1686 .impeccable-overlay.impeccable-hover .impeccable-label {
1687 background: ${BRAND_COLOR_HOVER};
1688 }
1689 .impeccable-overlay.impeccable-spotlight {
1690 z-index: 100002 !important;
1691 }
1692 .impeccable-overlay.impeccable-spotlight-dimmed {
1693 opacity: 0.15 !important;
1694 animation: none !important;
1695 filter: blur(3px);
1696 }
1697 .impeccable-spotlight-backdrop {
1698 position: fixed;
1699 top: 0; left: 0; right: 0; bottom: 0;
1700 backdrop-filter: blur(3px) brightness(0.6);
1701 -webkit-backdrop-filter: blur(3px) brightness(0.6);
1702 pointer-events: none;
1703 z-index: 99998;
1704 opacity: 0;
1705 outline: none !important;
1706 animation: none !important;
1707 }
1708 .impeccable-spotlight-backdrop.impeccable-visible {
1709 opacity: 1;
1710 }
1711 .impeccable-hidden .impeccable-overlay${EXTENSION_MODE ? '' : ':not(.impeccable-banner)'} {
1712 display: none !important;
1713 }
1714 .impeccable-hidden .impeccable-overlay${EXTENSION_MODE ? '' : ':not(.impeccable-banner)'} {
1715 display: none !important;
1716 }
1717 `;
1718 (document.head || document.documentElement).appendChild(styleEl);
1719
1720 // Spotlight backdrop element (created lazily on first use)
1721 let spotlightBackdrop = null;
1722 let spotlightTarget = null;
1723 let spotlightTimer = null;
1724
1725 function getSpotlightBackdrop() {
1726 if (!spotlightBackdrop) {
1727 spotlightBackdrop = document.createElement('div');
1728 spotlightBackdrop.className = 'impeccable-spotlight-backdrop';
1729 document.body.appendChild(spotlightBackdrop);
1730 }
1731 return spotlightBackdrop;
1732 }
1733
1734 function updateSpotlightClipPath() {
1735 if (!spotlightBackdrop || !spotlightTarget) return;
1736 const r = spotlightTarget.getBoundingClientRect();
1737 // Match the overlay's outer edge: element rect + 4px (2px overlay offset + 2px outline width)
1738 const inset = 4;
1739 const radius = 6; // outline border-radius (4) + outline width (2)
1740 const x1 = r.left - inset;
1741 const y1 = r.top - inset;
1742 const x2 = r.right + inset;
1743 const y2 = r.bottom + inset;
1744 const vw = window.innerWidth;
1745 const vh = window.innerHeight;
1746 // Outer rect + rounded inner rect (evenodd creates a hole)
1747 const path = `M0 0H${vw}V${vh}H0Z M${x1 + radius} ${y1}H${x2 - radius}A${radius} ${radius} 0 0 1 ${x2} ${y1 + radius}V${y2 - radius}A${radius} ${radius} 0 0 1 ${x2 - radius} ${y2}H${x1 + radius}A${radius} ${radius} 0 0 1 ${x1} ${y2 - radius}V${y1 + radius}A${radius} ${radius} 0 0 1 ${x1 + radius} ${y1}Z`;
1748 spotlightBackdrop.style.clipPath = `path(evenodd, "${path}")`;
1749 }
1750
1751 function showSpotlight(target) {
1752 if (!target || !target.getBoundingClientRect) return;
1753 // Respect the spotlightBlur setting: if disabled, don't show the backdrop
1754 if (window.__IMPECCABLE_CONFIG__?.spotlightBlur === false) {
1755 spotlightTarget = target;
1756 return;
1757 }
1758 spotlightTarget = target;
1759 const bd = getSpotlightBackdrop();
1760 updateSpotlightClipPath();
1761 bd.classList.add('impeccable-visible');
1762 }
1763
1764 function hideSpotlight() {
1765 spotlightTarget = null;
1766 if (spotlightTimer) { clearTimeout(spotlightTimer); spotlightTimer = null; }
1767 if (spotlightBackdrop) spotlightBackdrop.classList.remove('impeccable-visible');
1768 }
1769
1770 function isInViewport(el) {
1771 const r = el.getBoundingClientRect();
1772 return r.top >= 0 && r.left >= 0 && r.bottom <= window.innerHeight && r.right <= window.innerWidth;
1773 }
1774
1775 // Reposition spotlight on scroll/resize
1776 window.addEventListener('scroll', () => {
1777 if (spotlightTarget) updateSpotlightClipPath();
1778 }, { passive: true });
1779 window.addEventListener('resize', () => {
1780 if (spotlightTarget) updateSpotlightClipPath();
1781 });
1782
1783 const overlays = [];
1784 const TYPE_LABELS = {};
1785 const RULE_CATEGORY = {};
1786 for (const ap of ANTIPATTERNS) {
1787 TYPE_LABELS[ap.id] = ap.name.toLowerCase();
1788 RULE_CATEGORY[ap.id] = ap.category || 'quality';
1789 }
1790
1791 function isInFixedContext(el) {
1792 let p = el;
1793 while (p && p !== document.body) {
1794 if (getComputedStyle(p).position === 'fixed') return true;
1795 p = p.parentElement;
1796 }
1797 return false;
1798 }
1799
1800 function positionOverlay(overlay) {
1801 const el = overlay._targetEl;
1802 if (!el) return;
1803 const rect = el.getBoundingClientRect();
1804 if (overlay._isFixed) {
1805 // Viewport-relative coords for fixed targets
1806 overlay.style.top = `${rect.top - 2}px`;
1807 overlay.style.left = `${rect.left - 2}px`;
1808 } else {
1809 // Document-relative coords for normal targets
1810 overlay.style.top = `${rect.top + scrollY - 2}px`;
1811 overlay.style.left = `${rect.left + scrollX - 2}px`;
1812 }
1813 overlay.style.width = `${rect.width + 4}px`;
1814 overlay.style.height = `${rect.height + 4}px`;
1815 }
1816
1817 function repositionOverlays() {
1818 for (const o of overlays) {
1819 if (!o._targetEl || o.classList.contains('impeccable-banner')) continue;
1820 // Skip overlays whose target is currently hidden (display: none on the overlay)
1821 if (o.style.display === 'none') continue;
1822 positionOverlay(o);
1823 }
1824 }
1825
1826 let resizeRAF;
1827 const onResize = () => {
1828 cancelAnimationFrame(resizeRAF);
1829 resizeRAF = requestAnimationFrame(repositionOverlays);
1830 };
1831 window.addEventListener('resize', onResize);
1832 // Reposition on scroll too -- catches sticky/parallax shifts
1833 window.addEventListener('scroll', onResize, { passive: true });
1834 // Reposition when body resizes (lazy-loaded images, dynamic content, fonts loading)
1835 if (typeof ResizeObserver !== 'undefined') {
1836 const bodyResizeObserver = new ResizeObserver(onResize);
1837 bodyResizeObserver.observe(document.body);
1838 }
1839
1840 // Track target element visibility via IntersectionObserver.
1841 // Uses a huge rootMargin so all *rendered* elements count as intersecting,
1842 // while display:none / closed <details> / hidden modals etc. do not.
1843 // This is event-driven -- no polling needed.
1844 let overlayIndex = 0;
1845 const visibilityObserver = new IntersectionObserver((entries) => {
1846 for (const entry of entries) {
1847 const overlay = entry.target._impeccableOverlay;
1848 if (!overlay) continue;
1849 if (entry.isIntersecting) {
1850 overlay.style.display = '';
1851 positionOverlay(overlay);
1852 if (!overlay._revealed) {
1853 overlay._revealed = true;
1854 if (firstScanDone) {
1855 // Subsequent reveals (re-scans, scroll-into-view): instant, no animation
1856 overlay.style.animation = 'none';
1857 } else {
1858 // Initial scan: staggered cascade reveal
1859 overlay.style.animationDelay = `${Math.min((overlay._staggerIndex || 0) * 60, 600)}ms`;
1860 }
1861 requestAnimationFrame(() => {
1862 overlay.classList.add('impeccable-visible');
1863 if (overlay._checkLabel) overlay._checkLabel();
1864 });
1865 }
1866 } else {
1867 overlay.style.display = 'none';
1868 }
1869 }
1870 }, { rootMargin: '99999px' });
1871
1872 // Reposition overlays after CSS transitions end (e.g. reveal animations).
1873 // Listens at document level so it catches transitions on ancestor elements
1874 // (the transform may be on a parent, not the flagged element itself).
1875 document.addEventListener('transitionend', (e) => {
1876 if (e.propertyName !== 'transform') return;
1877 for (const o of overlays) {
1878 if (!o._targetEl || o.classList.contains('impeccable-banner') || o.style.display === 'none') continue;
1879 if (e.target === o._targetEl || e.target.contains(o._targetEl)) {
1880 positionOverlay(o);
1881 }
1882 }
1883 });
1884
1885 const highlight = function(el, findings) {
1886 const hasSlop = findings.some(f => RULE_CATEGORY[f.type || f.id] === 'slop');
1887
1888 const fixed = isInFixedContext(el);
1889 const rect = el.getBoundingClientRect();
1890 const outline = document.createElement('div');
1891 outline.className = 'impeccable-overlay';
1892 outline._targetEl = el;
1893 outline._isFixed = fixed;
1894 Object.assign(outline.style, {
1895 position: fixed ? 'fixed' : 'absolute',
1896 top: fixed ? `${rect.top - 2}px` : `${rect.top + scrollY - 2}px`,
1897 left: fixed ? `${rect.left - 2}px` : `${rect.left + scrollX - 2}px`,
1898 width: `${rect.width + 4}px`, height: `${rect.height + 4}px`,
1899 zIndex: '99999', boxSizing: 'border-box',
1900 });
1901
1902 // Build per-finding label entries: ✦ prefix for slop
1903 const entries = findings.map(f => {
1904 const name = TYPE_LABELS[f.type || f.id] || f.type || f.id;
1905 const prefix = RULE_CATEGORY[f.type || f.id] === 'slop' ? '\u2726 ' : '';
1906 return { name: prefix + name, detail: f.detail || f.snippet };
1907 });
1908 const allText = entries.map(e => e.name).join(', ');
1909
1910 const label = document.createElement('div');
1911 label.className = 'impeccable-label';
1912 Object.assign(label.style, {
1913 position: 'absolute', bottom: '100%', left: '-2px',
1914 display: 'flex', alignItems: 'center',
1915 whiteSpace: 'nowrap',
1916 fontSize: '11px', fontWeight: '600', letterSpacing: '0.02em',
1917 color: 'white', lineHeight: '14px',
1918 background: LABEL_BG,
1919 fontFamily: 'system-ui, sans-serif',
1920 borderRadius: '4px 4px 0 0',
1921 });
1922
1923 const textSpan = document.createElement('span');
1924 textSpan.style.padding = '3px 8px';
1925 textSpan.textContent = allText;
1926 label.appendChild(textSpan);
1927
1928 // State for cycling mode
1929 let cycleMode = false;
1930 let cycleIndex = 0;
1931 let isHovered = false;
1932 let prevBtn, nextBtn;
1933
1934 function updateCycleText() {
1935 const e = entries[cycleIndex];
1936 textSpan.textContent = isHovered ? e.detail : e.name;
1937 }
1938
1939 function enableCycleMode() {
1940 if (cycleMode || entries.length < 2) return;
1941 cycleMode = true;
1942
1943 const btnStyle = {
1944 background: 'none', border: 'none', color: 'rgba(255,255,255,0.7)',
1945 fontSize: '11px', cursor: 'pointer', padding: '3px 4px',
1946 fontFamily: 'system-ui, sans-serif', lineHeight: '14px',
1947 pointerEvents: 'auto',
1948 };
1949
1950 const navGroup = document.createElement('span');
1951 Object.assign(navGroup.style, {
1952 display: 'inline-flex', alignItems: 'center', flexShrink: '0',
1953 });
1954
1955 prevBtn = document.createElement('button');
1956 prevBtn.textContent = '\u2039';
1957 Object.assign(prevBtn.style, btnStyle);
1958 prevBtn.style.paddingLeft = '6px';
1959 prevBtn.addEventListener('click', (e) => {
1960 e.stopPropagation();
1961 cycleIndex = (cycleIndex - 1 + entries.length) % entries.length;
1962 updateCycleText();
1963 });
1964
1965 nextBtn = document.createElement('button');
1966 nextBtn.textContent = '\u203A';
1967 Object.assign(nextBtn.style, btnStyle);
1968 nextBtn.style.paddingRight = '2px';
1969 nextBtn.addEventListener('click', (e) => {
1970 e.stopPropagation();
1971 cycleIndex = (cycleIndex + 1) % entries.length;
1972 updateCycleText();
1973 });
1974
1975 navGroup.appendChild(prevBtn);
1976 navGroup.appendChild(nextBtn);
1977 label.insertBefore(navGroup, textSpan);
1978 textSpan.style.padding = '3px 8px 3px 4px';
1979 updateCycleText();
1980 }
1981
1982 outline.appendChild(label);
1983
1984 // Start hidden; the IntersectionObserver will show it once the target is rendered
1985 outline.style.display = 'none';
1986 outline._staggerIndex = overlayIndex++;
1987 el._impeccableOverlay = outline;
1988 visibilityObserver.observe(el);
1989
1990 // After first paint, check label width vs outline
1991 outline._checkLabel = () => {
1992 if (entries.length > 1 && label.offsetWidth > outline.offsetWidth) {
1993 enableCycleMode();
1994 }
1995 };
1996
1997 // Hover: show detail text, darken
1998 el.addEventListener('mouseenter', () => {
1999 isHovered = true;
2000 outline.classList.add('impeccable-hover');
2001 outline.style.outlineColor = BRAND_COLOR_HOVER;
2002 label.style.background = BRAND_COLOR_HOVER;
2003 if (cycleMode) {
2004 updateCycleText();
2005 } else {
2006 textSpan.textContent = entries.map(e => e.detail).join(' | ');
2007 }
2008 });
2009 el.addEventListener('mouseleave', () => {
2010 isHovered = false;
2011 outline.classList.remove('impeccable-hover');
2012 outline.style.outlineColor = '';
2013 label.style.background = LABEL_BG;
2014 if (cycleMode) {
2015 updateCycleText();
2016 } else {
2017 textSpan.textContent = allText;
2018 }
2019 });
2020
2021 document.body.appendChild(outline);
2022 overlays.push(outline);
2023 };
2024
2025 const showPageBanner = function(findings) {
2026 if (!findings.length) return;
2027 const banner = document.createElement('div');
2028 banner.className = 'impeccable-overlay impeccable-banner';
2029 Object.assign(banner.style, {
2030 position: 'fixed', top: '0', left: '0', right: '0', zIndex: '100000',
2031 background: LABEL_BG, color: 'white',
2032 fontFamily: 'system-ui, sans-serif', fontSize: '13px',
2033 display: 'flex', alignItems: 'center', pointerEvents: 'auto',
2034 height: '36px', overflow: 'hidden', maxWidth: '100vw',
2035 transform: 'translateY(-100%)',
2036 transition: 'transform 0.4s cubic-bezier(0.16, 1, 0.3, 1)',
2037 });
2038 requestAnimationFrame(() => requestAnimationFrame(() => {
2039 banner.style.transform = 'translateY(0)';
2040 }));
2041
2042 // Scrollable findings area
2043 const scrollArea = document.createElement('div');
2044 Object.assign(scrollArea.style, {
2045 flex: '1', minWidth: '0', overflowX: 'auto', overflowY: 'hidden',
2046 display: 'flex', gap: '8px', alignItems: 'center',
2047 padding: '0 12px', scrollSnapType: 'x mandatory',
2048 scrollbarWidth: 'none',
2049 });
2050 for (const f of findings) {
2051 const prefix = RULE_CATEGORY[f.type] === 'slop' ? '\u2726 ' : '';
2052 const tag = document.createElement('span');
2053 tag.textContent = `${prefix}${TYPE_LABELS[f.type] || f.type}: ${f.detail}`;
2054 Object.assign(tag.style, {
2055 background: 'rgba(255,255,255,0.15)', padding: '2px 8px',
2056 borderRadius: '3px', fontSize: '12px', fontFamily: 'ui-monospace, monospace',
2057 whiteSpace: 'nowrap', flexShrink: '0', scrollSnapAlign: 'start',
2058 });
2059 scrollArea.appendChild(tag);
2060 }
2061 banner.appendChild(scrollArea);
2062
2063 // Controls area (only in standalone mode, not extension)
2064 if (!EXTENSION_MODE) {
2065 const controls = document.createElement('div');
2066 Object.assign(controls.style, {
2067 display: 'flex', alignItems: 'center', gap: '2px',
2068 padding: '0 8px', flexShrink: '0',
2069 });
2070
2071 // Toggle visibility button
2072 const toggle = document.createElement('button');
2073 toggle.textContent = '\u25C9'; // circle with dot (visible state)
2074 toggle.title = 'Toggle overlay visibility';
2075 Object.assign(toggle.style, {
2076 background: 'none', border: 'none',
2077 color: 'white', fontSize: '16px', cursor: 'pointer', padding: '0 4px',
2078 opacity: '0.85', transition: 'opacity 0.15s',
2079 });
2080 let overlaysVisible = true;
2081 toggle.addEventListener('click', () => {
2082 overlaysVisible = !overlaysVisible;
2083 document.body.classList.toggle('impeccable-hidden', !overlaysVisible);
2084 toggle.textContent = overlaysVisible ? '\u25C9' : '\u25CB'; // filled vs empty circle
2085 toggle.style.opacity = overlaysVisible ? '0.85' : '0.5';
2086 });
2087 controls.appendChild(toggle);
2088
2089 // Close button
2090 const close = document.createElement('button');
2091 close.textContent = '\u00d7';
2092 close.title = 'Dismiss banner';
2093 Object.assign(close.style, {
2094 background: 'none', border: 'none',
2095 color: 'white', fontSize: '18px', cursor: 'pointer', padding: '0 4px',
2096 });
2097 close.addEventListener('click', () => banner.remove());
2098 controls.appendChild(close);
2099
2100 banner.appendChild(controls);
2101 }
2102 document.body.appendChild(banner);
2103 overlays.push(banner);
2104 };
2105
2106 // Heuristic for skipping CSS-in-JS hashed class names like "css-1a2b3c" or "_2x4hG_".
2107 // These change between builds and produce brittle, ugly selectors.
2108 function isLikelyHashedClass(c) {
2109 if (!c) return true;
2110 if (/^(css|sc|emotion|jsx|module)-[\w-]{4,}$/i.test(c)) return true;
2111 if (/^_[\w-]{5,}$/.test(c)) return true;
2112 if (/^[a-z0-9]{6,}$/i.test(c) && /\d/.test(c)) return true;
2113 return false;
2114 }
2115
2116 function buildSelectorSegment(el) {
2117 const tag = el.tagName.toLowerCase();
2118 let sel = tag;
2119
2120 if (el.classList && el.classList.length > 0) {
2121 const classes = [...el.classList]
2122 .filter(c => !c.startsWith('impeccable-') && !isLikelyHashedClass(c))
2123 .slice(0, 2);
2124 if (classes.length > 0) {
2125 sel += '.' + classes.map(c => CSS.escape(c)).join('.');
2126 }
2127 }
2128
2129 // Disambiguate among siblings only if the parent has multiple matches
2130 const parent = el.parentElement;
2131 if (parent) {
2132 try {
2133 const matching = parent.querySelectorAll(':scope > ' + sel);
2134 if (matching.length > 1) {
2135 const sameType = [...parent.children].filter(c => c.tagName === el.tagName);
2136 const idx = sameType.indexOf(el) + 1;
2137 sel += `:nth-of-type(${idx})`;
2138 }
2139 } catch {
2140 const idx = [...parent.children].indexOf(el) + 1;
2141 sel = `${tag}:nth-child(${idx})`;
2142 }
2143 }
2144 return sel;
2145 }
2146
2147 function generateSelector(el) {
2148 if (el === document.body) return 'body';
2149 if (el === document.documentElement) return 'html';
2150 if (el.id) return '#' + CSS.escape(el.id);
2151
2152 const parts = [];
2153 let current = el;
2154 let depth = 0;
2155 const MAX_DEPTH = 10;
2156
2157 while (current && current !== document.body && current !== document.documentElement && depth < MAX_DEPTH) {
2158 parts.unshift(buildSelectorSegment(current));
2159
2160 // Anchor on an ancestor's ID and stop walking up
2161 if (current.id) {
2162 parts[0] = '#' + CSS.escape(current.id);
2163 break;
2164 }
2165
2166 // Stop as soon as the partial selector uniquely identifies the target
2167 const trySelector = parts.join(' > ');
2168 try {
2169 const matches = document.querySelectorAll(trySelector);
2170 if (matches.length === 1 && matches[0] === el) {
2171 return trySelector;
2172 }
2173 } catch { /* invalid selector — keep walking */ }
2174
2175 current = current.parentElement;
2176 depth++;
2177 }
2178
2179 return parts.join(' > ');
2180 }
2181
2182 function isElementHidden(el) {
2183 if (!el || el === document.body || el === document.documentElement) return false;
2184 if (typeof el.checkVisibility === 'function') return !el.checkVisibility({ checkOpacity: false, checkVisibilityCSS: true });
2185 // Fallback: zero size or no offsetParent (covers display:none and detached subtrees)
2186 return el.offsetWidth === 0 && el.offsetHeight === 0;
2187 }
2188
2189 function serializeFindings(allFindings) {
2190 return allFindings.map(({ el, findings }) => ({
2191 selector: generateSelector(el),
2192 tagName: el.tagName?.toLowerCase() || 'unknown',
2193 rect: (el !== document.body && el !== document.documentElement && el.getBoundingClientRect)
2194 ? el.getBoundingClientRect().toJSON() : null,
2195 isPageLevel: el === document.body || el === document.documentElement,
2196 isHidden: isElementHidden(el),
2197 findings: findings.map(f => {
2198 const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id));
2199 return {
2200 type: f.type || f.id,
2201 category: ap ? ap.category : 'quality',
2202 detail: f.detail || f.snippet,
2203 name: ap ? ap.name : (f.type || f.id),
2204 description: ap ? ap.description : '',
2205 };
2206 }),
2207 }));
2208 }
2209
2210 const printSummary = function(allFindings) {
2211 if (allFindings.length === 0) {
2212 console.log('%c[impeccable] No anti-patterns found.', 'color: #22c55e; font-weight: bold');
2213 return;
2214 }
2215 console.group(
2216 `%c[impeccable] ${allFindings.length} anti-pattern${allFindings.length === 1 ? '' : 's'} found`,
2217 'color: oklch(60% 0.25 350); font-weight: bold'
2218 );
2219 for (const { el, findings } of allFindings) {
2220 for (const f of findings) {
2221 console.log(`%c${f.type || f.id}%c ${f.detail || f.snippet}`,
2222 'color: oklch(55% 0.25 350); font-weight: bold', 'color: inherit', el);
2223 }
2224 }
2225 console.groupEnd();
2226 };
2227
2228 let firstScanDone = false;
2229 const scan = function() {
2230 for (const o of overlays) o.remove();
2231 overlays.length = 0;
2232 visibilityObserver.disconnect();
2233 overlayIndex = 0;
2234 const allFindings = [];
2235 const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : [];
2236 const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id);
2237
2238 for (const el of document.querySelectorAll('*')) {
2239 // Skip impeccable's own elements and any descendants (overlays, labels, banner, nav buttons)
2240 if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue;
2241 // Skip browser extension elements (Claude, etc.)
2242 const elId = el.id || '';
2243 if (elId.startsWith('claude-') || elId.startsWith('cic-')) continue;
2244 // Skip html/body -- page-level findings go in the banner, not a full-page overlay
2245 if (el === document.body || el === document.documentElement) continue;
2246
2247 const findings = [
2248 ...checkElementBordersDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
2249 ...checkElementColorsDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
2250 ...checkElementMotionDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
2251 ...checkElementGlowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
2252 ...checkElementAIPaletteDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
2253 ...checkElementIconTileDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
2254 ...checkElementQualityDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
2255 ].filter(f => _ruleOk(f.type));
2256
2257 if (findings.length > 0) {
2258 highlight(el, findings);
2259 allFindings.push({ el, findings });
2260 }
2261 }
2262
2263 const pageLevelFindings = [];
2264
2265 const typoFindings = checkTypography().filter(f => _ruleOk(f.type));
2266 if (typoFindings.length > 0) {
2267 pageLevelFindings.push(...typoFindings);
2268 allFindings.push({ el: document.body, findings: typoFindings });
2269 }
2270
2271 const layoutFindings = checkLayout().filter(f => _ruleOk(f.type));
2272 for (const f of layoutFindings) {
2273 const el = f.el || document.body;
2274 delete f.el;
2275 // Merge into existing overlay if this element already has one
2276 const existing = el._impeccableOverlay;
2277 if (existing) {
2278 const nameRow = existing.querySelector('.impeccable-label-name');
2279 const detailRow = existing.querySelector('.impeccable-label-detail');
2280 const newType = TYPE_LABELS[f.type] || f.type;
2281 if (nameRow) nameRow.textContent += ', ' + newType;
2282 if (detailRow) detailRow.textContent += ' | ' + (f.detail || '');
2283 } else {
2284 highlight(el, [f]);
2285 }
2286 allFindings.push({ el, findings: [f] });
2287 }
2288
2289 // Page-level quality checks (headings, etc.)
2290 const qualityFindings = checkPageQualityDOM().filter(f => _ruleOk(f.type));
2291 if (qualityFindings.length > 0) {
2292 pageLevelFindings.push(...qualityFindings);
2293 allFindings.push({ el: document.body, findings: qualityFindings });
2294 }
2295
2296 // Regex-on-HTML checks (shared with Node)
2297 const htmlPatternFindings = checkHtmlPatterns(document.documentElement.outerHTML);
2298 if (htmlPatternFindings.length > 0) {
2299 const mapped = htmlPatternFindings.map(f => ({ type: f.id, detail: f.snippet })).filter(f => _ruleOk(f.type));
2300 pageLevelFindings.push(...mapped);
2301 allFindings.push({ el: document.body, findings: mapped });
2302 }
2303
2304 if (pageLevelFindings.length > 0) {
2305 showPageBanner(pageLevelFindings);
2306 }
2307
2308 if (!EXTENSION_MODE) printSummary(allFindings);
2309
2310 // In extension mode, post serialized results for the DevTools panel
2311 if (EXTENSION_MODE) {
2312 window.postMessage({
2313 source: 'impeccable-results',
2314 findings: serializeFindings(allFindings),
2315 count: allFindings.length,
2316 }, '*');
2317 }
2318
2319 // After this scan completes, all subsequent reveals are instant (no stagger, no animation)
2320 setTimeout(() => { firstScanDone = true; }, 1000);
2321
2322 return allFindings;
2323 };
2324
2325 if (EXTENSION_MODE) {
2326 // Extension mode: listen for commands, don't auto-scan
2327 window.addEventListener('message', (e) => {
2328 if (e.source !== window || !e.data || e.data.source !== 'impeccable-command') return;
2329 if (e.data.action === 'scan') {
2330 if (e.data.config) window.__IMPECCABLE_CONFIG__ = e.data.config;
2331 scan();
2332 }
2333 if (e.data.action === 'toggle-overlays') {
2334 const visible = !document.body.classList.contains('impeccable-hidden');
2335 document.body.classList.toggle('impeccable-hidden', visible);
2336 window.postMessage({ source: 'impeccable-overlays-toggled', visible: !visible }, '*');
2337 }
2338 if (e.data.action === 'remove') {
2339 for (const o of overlays) o.remove();
2340 overlays.length = 0;
2341 visibilityObserver.disconnect();
2342 styleEl.remove();
2343 if (spotlightBackdrop) { spotlightBackdrop.remove(); spotlightBackdrop = null; }
2344 document.body.classList.remove('impeccable-hidden');
2345 }
2346 if (e.data.action === 'highlight') {
2347 if (spotlightTimer) { clearTimeout(spotlightTimer); spotlightTimer = null; }
2348 try {
2349 const target = e.data.selector ? document.querySelector(e.data.selector) : null;
2350 if (target) {
2351 // Scroll first so positionOverlay reads the post-scroll rect
2352 if (!isInViewport(target) && target.scrollIntoView) {
2353 target.scrollIntoView({ behavior: 'instant', block: 'center' });
2354 }
2355 for (const o of overlays) {
2356 if (o.classList.contains('impeccable-banner')) continue;
2357 const isMatch = o._targetEl === target;
2358 o.classList.toggle('impeccable-spotlight', isMatch);
2359 o.classList.toggle('impeccable-spotlight-dimmed', !isMatch);
2360 if (isMatch) {
2361 // Force the matching overlay visible immediately, don't wait for IntersectionObserver
2362 o.style.display = '';
2363 o.style.animation = 'none';
2364 o.classList.add('impeccable-visible');
2365 o._revealed = true;
2366 positionOverlay(o);
2367 }
2368 }
2369 showSpotlight(target);
2370 }
2371 } catch { /* invalid selector */ }
2372 }
2373 if (e.data.action === 'unhighlight') {
2374 hideSpotlight();
2375 for (const o of overlays) {
2376 o.classList.remove('impeccable-spotlight');
2377 o.classList.remove('impeccable-spotlight-dimmed');
2378 }
2379 }
2380 });
2381 window.postMessage({ source: 'impeccable-ready' }, '*');
2382 } else {
2383 if (document.readyState === 'loading') {
2384 document.addEventListener('DOMContentLoaded', () => setTimeout(scan, 100));
2385 } else {
2386 setTimeout(scan, 100);
2387 }
2388 }
2389
2390 window.impeccableScan = scan;
2391}
2392
2393// ─── Section 8: Node Engine ─────────────────────────────────────────────────
2394// @browser-strip-start
2395
2396function getAP(id) {
2397 return ANTIPATTERNS.find(a => a.id === id);
2398}
2399
2400function finding(id, filePath, snippet, line = 0) {
2401 const ap = getAP(id);
2402 return { antipattern: id, name: ap.name, description: ap.description, file: filePath, line, snippet };
2403}
2404
2405/** Check if content looks like a full page (not a component/partial) */
2406function isFullPage(content) {
2407 const stripped = content.replace(/<!--[\s\S]*?-->/g, '');
2408 return /<!doctype\s|<html[\s>]|<head[\s>]/i.test(stripped);
2409}
2410
2411// ---------------------------------------------------------------------------
2412// jsdom CSS-variable border override map
2413// ---------------------------------------------------------------------------
2414//
2415// jsdom's CSSOM silently drops any border shorthand that contains a var()
2416// reference — the computed style for the element then shows empty width,
2417// empty style, and a default black color. That's enough to hide the most
2418// common real-world side-tab pattern in AI-generated pages:
2419//
2420// :root { --brand: #87a8ff; }
2421// .card { border-left: 5px solid var(--brand); border-radius: 4px; }
2422//
2423// Real browsers (and therefore the browser detector path) resolve var()
2424// natively, so this only affects the Node jsdom path.
2425//
2426// This pre-pass walks the stylesheets, finds any rule whose per-side or
2427// all-sides border property contains var(), resolves the var() against
2428// :root-level custom properties (read from the documentElement's computed
2429// style, which jsdom DOES handle correctly), and attaches the resolved
2430// width+color to every element that matches the rule's selector. The
2431// Node-side `checkElementBorders` adapter consumes that map as a fallback
2432// whenever jsdom's computed style came back empty.
2433//
2434// Limitations (intentional, to keep the pass simple):
2435// * Only :root-level custom properties are resolved. Scoped overrides on
2436// descendants are not tracked — uncommon in practice and would require
2437// a per-element cascade walk.
2438// * @media / @supports wrapped rules are ignored (jsdom often mishandles
2439// these anyway).
2440// * The fallback only fills sides that jsdom left empty, so any rule
2441// whose border parses normally still wins via the computed style.
2442
2443const BORDER_SHORTHAND_RE = /^(\d+(?:\.\d+)?)px\s+(solid|dashed|dotted|double|groove|ridge|inset|outset)\s+(.+)$/i;
2444
2445// isNeutralColor only understands rgba()/oklch()/lch()/lab()/hsl()/hwb().
2446// CSS variables typically hold hex or named colors, so normalize those to
2447// rgb() before handing the value off to the shared check. Anything we don't
2448// recognise is passed through unchanged — isNeutralColor then treats it as
2449// non-neutral, which is the safer default (matches the oklch-era bugfix).
2450const NAMED_COLORS = {
2451 white: [255, 255, 255], black: [0, 0, 0], gray: [128, 128, 128],
2452 grey: [128, 128, 128], silver: [192, 192, 192], red: [255, 0, 0],
2453 green: [0, 128, 0], blue: [0, 0, 255], yellow: [255, 255, 0],
2454};
2455
2456function normalizeColorForCheck(value) {
2457 if (!value) return value;
2458 const v = value.trim();
2459 const hex6 = v.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
2460 if (hex6) {
2461 const [r, g, b] = [parseInt(hex6[1], 16), parseInt(hex6[2], 16), parseInt(hex6[3], 16)];
2462 return `rgb(${r}, ${g}, ${b})`;
2463 }
2464 const hex3 = v.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/i);
2465 if (hex3) {
2466 const [r, g, b] = [
2467 parseInt(hex3[1] + hex3[1], 16),
2468 parseInt(hex3[2] + hex3[2], 16),
2469 parseInt(hex3[3] + hex3[3], 16),
2470 ];
2471 return `rgb(${r}, ${g}, ${b})`;
2472 }
2473 const named = NAMED_COLORS[v.toLowerCase()];
2474 if (named) return `rgb(${named[0]}, ${named[1]}, ${named[2]})`;
2475 return v;
2476}
2477
2478function buildBorderOverrideMap(document, window) {
2479 const map = new Map();
2480 const rootStyle = window.getComputedStyle(document.documentElement);
2481
2482 function resolveVar(value, depth = 0) {
2483 if (!value || depth > 10 || !value.includes('var(')) return value;
2484 return value.replace(
2485 /var\(\s*(--[\w-]+)\s*(?:,\s*([^)]+))?\s*\)/g,
2486 (_, name, fallback) => {
2487 const v = rootStyle.getPropertyValue(name).trim();
2488 if (v) return resolveVar(v, depth + 1);
2489 if (fallback) return resolveVar(fallback.trim(), depth + 1);
2490 return '';
2491 }
2492 );
2493 }
2494
2495 function parseShorthand(text) {
2496 const m = text.trim().match(BORDER_SHORTHAND_RE);
2497 if (!m) return null;
2498 return { width: parseFloat(m[1]), color: normalizeColorForCheck(m[3]) };
2499 }
2500
2501 // Read from the per-property accessors on rule.style. jsdom preserves
2502 // each border-* shorthand it parsed, even when the overall cssText has
2503 // been truncated (e.g. a `border: 1px solid var(...)` followed by a
2504 // `border-left: ...` loses the first declaration but keeps the second).
2505 const SIDE_PROPS = [
2506 ['borderLeft', 'Left'],
2507 ['borderRight', 'Right'],
2508 ['borderTop', 'Top'],
2509 ['borderBottom', 'Bottom'],
2510 ['borderInlineStart', 'Left'],
2511 ['borderInlineEnd', 'Right'],
2512 ];
2513
2514 for (const sheet of document.styleSheets) {
2515 let rules;
2516 try { rules = sheet.cssRules || []; } catch { continue; }
2517 for (const rule of rules) {
2518 // CSSStyleRule only; skip @media / @keyframes / @supports wrappers.
2519 if (rule.type !== 1 || !rule.style || !rule.selectorText) continue;
2520
2521 const perSide = {};
2522
2523 for (const [prop, side] of SIDE_PROPS) {
2524 const val = rule.style[prop];
2525 if (!val || !val.includes('var(')) continue;
2526 const parsed = parseShorthand(resolveVar(val));
2527 if (parsed && parsed.color) perSide[side] = parsed;
2528 }
2529
2530 // Uniform `border: <w> <style> var(...)` applies to every side the
2531 // per-side map didn't already claim.
2532 const borderAll = rule.style.border;
2533 if (borderAll && borderAll.includes('var(')) {
2534 const parsed = parseShorthand(resolveVar(borderAll));
2535 if (parsed && parsed.color) {
2536 for (const s of ['Top', 'Right', 'Bottom', 'Left']) {
2537 if (!perSide[s]) perSide[s] = parsed;
2538 }
2539 }
2540 }
2541
2542 // Longhand `border-*-color: var(...)` with width/style in separate
2543 // declarations. Rare in AI-generated pages, but cheap to cover.
2544 for (const [prop, side] of [
2545 ['borderLeftColor', 'Left'],
2546 ['borderRightColor', 'Right'],
2547 ['borderTopColor', 'Top'],
2548 ['borderBottomColor', 'Bottom'],
2549 ]) {
2550 const val = rule.style[prop];
2551 if (!val || !val.includes('var(')) continue;
2552 const resolved = resolveVar(val).trim();
2553 if (!resolved) continue;
2554 // Width may or may not come from this rule — that's fine; the
2555 // adapter only substitutes the color when jsdom left it as a
2556 // literal var() string.
2557 if (!perSide[side]) perSide[side] = { width: 0, color: normalizeColorForCheck(resolved) };
2558 }
2559
2560 if (Object.keys(perSide).length === 0) continue;
2561
2562 let matched;
2563 try { matched = document.querySelectorAll(rule.selectorText); }
2564 catch { continue; }
2565
2566 for (const el of matched) {
2567 const existing = map.get(el);
2568 if (existing) {
2569 // Later rules overwrite earlier ones — approximates source-order
2570 // cascade for equal-specificity rules and is good enough for the
2571 // uncontested var()-dropped sides we're trying to recover.
2572 Object.assign(existing, perSide);
2573 } else {
2574 map.set(el, { ...perSide });
2575 }
2576 }
2577 }
2578 }
2579
2580 return map;
2581}
2582
2583// ---------------------------------------------------------------------------
2584// jsdom detection (default for HTML files)
2585// ---------------------------------------------------------------------------
2586
2587async function detectHtml(filePath) {
2588 let JSDOM;
2589 try {
2590 ({ JSDOM } = await import('jsdom'));
2591 } catch {
2592 const content = fs.readFileSync(filePath, 'utf-8');
2593 return detectText(content, filePath);
2594 }
2595
2596 const html = fs.readFileSync(filePath, 'utf-8');
2597 const resolvedPath = path.resolve(filePath);
2598 const fileDir = path.dirname(resolvedPath);
2599
2600 // Inline linked local stylesheets so jsdom can see them
2601 let processedHtml = html;
2602 const linkRes = [
2603 /<link[^>]+rel=["']stylesheet["'][^>]*href=["']([^"']+)["'][^>]*>/gi,
2604 /<link[^>]+href=["']([^"']+)["'][^>]*rel=["']stylesheet["'][^>]*>/gi,
2605 ];
2606 for (const re of linkRes) {
2607 let m;
2608 while ((m = re.exec(html)) !== null) {
2609 const href = m[1];
2610 if (/^(https?:)?\/\//.test(href)) continue;
2611 const cssPath = path.resolve(fileDir, href);
2612 try {
2613 const css = fs.readFileSync(cssPath, 'utf-8');
2614 processedHtml = processedHtml.replace(m[0], `<style>/* ${href} */\n${css}\n</style>`);
2615 } catch { /* skip unreadable */ }
2616 }
2617 }
2618
2619 const dom = new JSDOM(processedHtml, {
2620 url: `file://${resolvedPath}`,
2621 });
2622 const { window } = dom;
2623 const { document } = window;
2624
2625 const findings = [];
2626
2627 // Pre-pass: recover border declarations that jsdom dropped because they
2628 // contained a var() reference. The map is keyed by element and consulted
2629 // by the border check adapter as a fallback.
2630 const borderOverrides = buildBorderOverrideMap(document, window);
2631
2632 // Element-level checks (borders + colors + motion)
2633 for (const el of document.querySelectorAll('*')) {
2634 const tag = el.tagName.toLowerCase();
2635 const style = window.getComputedStyle(el);
2636 for (const f of checkElementBorders(tag, style, borderOverrides.get(el))) {
2637 findings.push(finding(f.id, filePath, f.snippet));
2638 }
2639 for (const f of checkElementColors(el, style, tag, window)) {
2640 findings.push(finding(f.id, filePath, f.snippet));
2641 }
2642 for (const f of checkElementGlow(tag, style, resolveBackground(el.parentElement || el, window))) {
2643 findings.push(finding(f.id, filePath, f.snippet));
2644 }
2645 for (const f of checkElementMotion(tag, style)) {
2646 findings.push(finding(f.id, filePath, f.snippet));
2647 }
2648 for (const f of checkElementIconTile(el, tag, window)) {
2649 findings.push(finding(f.id, filePath, f.snippet));
2650 }
2651 for (const f of checkElementQuality(el, style, tag, window)) {
2652 findings.push(finding(f.id, filePath, f.snippet));
2653 }
2654 }
2655
2656 // Page-level checks (only for full pages, not partials)
2657 if (isFullPage(html)) {
2658 for (const f of checkPageTypography(document, window)) {
2659 findings.push(finding(f.id, filePath, f.snippet));
2660 }
2661 for (const f of checkPageLayout(document, window)) {
2662 findings.push(finding(f.id, filePath, f.snippet));
2663 }
2664 for (const f of checkPageQualityFromDoc(document)) {
2665 findings.push(finding(f.id, filePath, f.snippet));
2666 }
2667 for (const f of checkHtmlPatterns(html)) {
2668 findings.push(finding(f.id, filePath, f.snippet));
2669 }
2670 }
2671
2672 window.close();
2673 return findings;
2674}
2675
2676// ---------------------------------------------------------------------------
2677// Puppeteer detection (for URLs)
2678// ---------------------------------------------------------------------------
2679
2680async function detectUrl(url) {
2681 let puppeteer;
2682 try {
2683 puppeteer = await import('puppeteer');
2684 } catch {
2685 throw new Error('puppeteer is required for URL scanning. Install: npm install puppeteer');
2686 }
2687
2688 // Read the browser detection script — reuse it instead of reimplementing
2689 const browserScriptPath = path.resolve(
2690 path.dirname(new URL(import.meta.url).pathname),
2691 'detect-antipatterns-browser.js'
2692 );
2693 let browserScript;
2694 try {
2695 browserScript = fs.readFileSync(browserScriptPath, 'utf-8');
2696 } catch {
2697 throw new Error(`Browser script not found at ${browserScriptPath}`);
2698 }
2699
2700 // CI runners (GitHub Actions Ubuntu) block unprivileged user namespaces, so
2701 // Chrome can't initialize its sandbox there. Disable the sandbox only when
2702 // running in CI; local users keep the default hardened launch.
2703 const launchArgs = process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [];
2704 const browser = await puppeteer.default.launch({ headless: true, args: launchArgs });
2705 const page = await browser.newPage();
2706 await page.setViewport({ width: 1280, height: 800 });
2707 await page.goto(url, { waitUntil: 'networkidle0', timeout: 30000 });
2708
2709 // Inject the browser detection script and collect results
2710 await page.evaluate(browserScript);
2711 const results = await page.evaluate(() => {
2712 if (!window.impeccableScan) return [];
2713 const allFindings = window.impeccableScan();
2714 return allFindings.flatMap(({ findings }) =>
2715 findings.map(f => ({ id: f.type, snippet: f.detail }))
2716 );
2717 });
2718
2719 await browser.close();
2720 return results.map(f => finding(f.id, url, f.snippet));
2721}
2722
2723// ---------------------------------------------------------------------------
2724// Regex fallback (non-HTML files: CSS, JSX, TSX, etc.)
2725// ---------------------------------------------------------------------------
2726
2727const hasRounded = (line) => /\brounded(?:-\w+)?\b/.test(line);
2728const hasBorderRadius = (line) => /border-radius/i.test(line);
2729const isSafeElement = (line) => /<(?:blockquote|nav[\s>]|pre[\s>]|code[\s>]|a\s|input[\s>]|span[\s>])/i.test(line);
2730
2731function isNeutralBorderColor(str) {
2732 const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i);
2733 if (!m) return false;
2734 const c = m[1].toLowerCase();
2735 if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true;
2736 const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/);
2737 if (hex) {
2738 const [r, g, b] = [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)];
2739 return (Math.max(r, g, b) - Math.min(r, g, b)) < 30;
2740 }
2741 const shex = c.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/);
2742 if (shex) {
2743 const [r, g, b] = [parseInt(shex[1] + shex[1], 16), parseInt(shex[2] + shex[2], 16), parseInt(shex[3] + shex[3], 16)];
2744 return (Math.max(r, g, b) - Math.min(r, g, b)) < 30;
2745 }
2746 return false;
2747}
2748
2749const REGEX_MATCHERS = [
2750 // --- Side-tab ---
2751 { id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
2752 test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 1 : n >= 4; },
2753 fmt: (m) => m[0] },
2754 { id: 'side-tab', regex: /border-(?:left|right)\s*:\s*(\d+)px\s+solid[^;]*/gi,
2755 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; },
2756 fmt: (m) => m[0].replace(/\s*;?\s*$/, '') },
2757 { id: 'side-tab', regex: /border-(?:left|right)-width\s*:\s*(\d+)px/gi,
2758 test: (m, line) => !isSafeElement(line) && +m[1] >= 3,
2759 fmt: (m) => m[0] },
2760 { id: 'side-tab', regex: /border-inline-(?:start|end)\s*:\s*(\d+)px\s+solid/gi,
2761 test: (m, line) => !isSafeElement(line) && +m[1] >= 3,
2762 fmt: (m) => m[0] },
2763 { id: 'side-tab', regex: /border-inline-(?:start|end)-width\s*:\s*(\d+)px/gi,
2764 test: (m, line) => !isSafeElement(line) && +m[1] >= 3,
2765 fmt: (m) => m[0] },
2766 { id: 'side-tab', regex: /border(?:Left|Right)\s*[:=]\s*["'`](\d+)px\s+solid/g,
2767 test: (m) => +m[1] >= 3,
2768 fmt: (m) => m[0] },
2769 // --- Border accent on rounded ---
2770 { id: 'border-accent-on-rounded', regex: /\bborder-[tb]-(\d+)\b/g,
2771 test: (m, line) => hasRounded(line) && +m[1] >= 1,
2772 fmt: (m) => m[0] },
2773 { id: 'border-accent-on-rounded', regex: /border-(?:top|bottom)\s*:\s*(\d+)px\s+solid/gi,
2774 test: (m, line) => +m[1] >= 3 && hasBorderRadius(line),
2775 fmt: (m) => m[0] },
2776 // --- Overused font ---
2777 { id: 'overused-font', regex: /font-family\s*:\s*['"]?(Inter|Roboto|Open Sans|Lato|Montserrat|Arial|Helvetica)\b/gi,
2778 test: () => true,
2779 fmt: (m) => m[0] },
2780 { id: 'overused-font', regex: /fonts\.googleapis\.com\/css2?\?family=(Inter|Roboto|Open\+Sans|Lato|Montserrat)\b/gi,
2781 test: () => true,
2782 fmt: (m) => `Google Fonts: ${m[1].replace(/\+/g, ' ')}` },
2783 // --- Pure black background ---
2784 { id: 'pure-black-white', regex: /background(?:-color)?\s*:\s*(#000000|#000|rgb\(0,\s*0,\s*0\))\b/gi,
2785 test: () => true,
2786 fmt: (m) => m[0] },
2787 // --- Gradient text ---
2788 { id: 'gradient-text', regex: /background-clip\s*:\s*text|-webkit-background-clip\s*:\s*text/gi,
2789 test: (m, line) => /gradient/i.test(line),
2790 fmt: () => 'background-clip: text + gradient' },
2791 // --- Gradient text (Tailwind) ---
2792 { id: 'gradient-text', regex: /\bbg-clip-text\b/g,
2793 test: (m, line) => /\bbg-gradient-to-/i.test(line),
2794 fmt: () => 'bg-clip-text + bg-gradient' },
2795 // --- Tailwind pure black background ---
2796 { id: 'pure-black-white', regex: /\bbg-black\b/g,
2797 test: () => true,
2798 fmt: (m) => m[0] },
2799 // --- Tailwind gray on colored bg ---
2800 { id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
2801 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),
2802 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] || '?'}`; } },
2803 // --- Tailwind AI palette ---
2804 { id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
2805 test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
2806 fmt: (m) => `${m[0]} on heading` },
2807 { id: 'ai-color-palette', regex: /\bfrom-(?:purple|violet|indigo)-(\d+)\b/g,
2808 test: (m, line) => /\bto-(?:purple|violet|indigo|blue|cyan|pink|fuchsia)-\d+\b/.test(line),
2809 fmt: (m) => `${m[0]} gradient` },
2810 // --- Bounce/elastic easing ---
2811 { id: 'bounce-easing', regex: /\banimate-bounce\b/g,
2812 test: () => true,
2813 fmt: () => 'animate-bounce (Tailwind)' },
2814 { id: 'bounce-easing', regex: /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi,
2815 test: () => true,
2816 fmt: (m) => m[0] },
2817 { id: 'bounce-easing', regex: /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g,
2818 test: (m) => {
2819 const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]);
2820 return y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1;
2821 },
2822 fmt: (m) => `cubic-bezier(${m[1]}, ${m[2]}, ${m[3]}, ${m[4]})` },
2823 // --- Layout property transition ---
2824 { id: 'layout-transition', regex: /transition\s*:\s*([^;{}]+)/gi,
2825 test: (m) => {
2826 const val = m[1].toLowerCase();
2827 if (/\ball\b/.test(val)) return false;
2828 return /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding\b|\bmargin\b/.test(val);
2829 },
2830 fmt: (m) => {
2831 const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
2832 return `transition: ${found ? found.join(', ') : m[1].trim()}`;
2833 } },
2834 { id: 'layout-transition', regex: /transition-property\s*:\s*([^;{}]+)/gi,
2835 test: (m) => {
2836 const val = m[1].toLowerCase();
2837 if (/\ball\b/.test(val)) return false;
2838 return /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding\b|\bmargin\b/.test(val);
2839 },
2840 fmt: (m) => {
2841 const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
2842 return `transition-property: ${found ? found.join(', ') : m[1].trim()}`;
2843 } },
2844];
2845
2846const REGEX_ANALYZERS = [
2847 // Single font
2848 (content, filePath) => {
2849 const fontFamilyRe = /font-family\s*:\s*([^;}]+)/gi;
2850 const fonts = new Set();
2851 let m;
2852 while ((m = fontFamilyRe.exec(content)) !== null) {
2853 for (const f of m[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) {
2854 if (f && !GENERIC_FONTS.has(f)) fonts.add(f);
2855 }
2856 }
2857 const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
2858 while ((m = gfRe.exec(content)) !== null) {
2859 for (const f of m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase())) fonts.add(f);
2860 }
2861 if (fonts.size !== 1 || content.split('\n').length < 20) return [];
2862 const name = [...fonts][0];
2863 const lines = content.split('\n');
2864 let line = 1;
2865 for (let i = 0; i < lines.length; i++) { if (lines[i].toLowerCase().includes(name)) { line = i + 1; break; } }
2866 return [finding('single-font', filePath, `only font used is ${name}`, line)];
2867 },
2868 // Flat type hierarchy
2869 (content, filePath) => {
2870 const sizes = new Set();
2871 const REM = 16;
2872 let m;
2873 const sizeRe = /font-size\s*:\s*([\d.]+)(px|rem|em)\b/gi;
2874 while ((m = sizeRe.exec(content)) !== null) {
2875 const px = m[2] === 'px' ? +m[1] : +m[1] * REM;
2876 if (px > 0 && px < 200) sizes.add(Math.round(px * 10) / 10);
2877 }
2878 const clampRe = /font-size\s*:\s*clamp\(\s*([\d.]+)(px|rem|em)\s*,\s*[^,]+,\s*([\d.]+)(px|rem|em)\s*\)/gi;
2879 while ((m = clampRe.exec(content)) !== null) {
2880 sizes.add(Math.round((m[2] === 'px' ? +m[1] : +m[1] * REM) * 10) / 10);
2881 sizes.add(Math.round((m[4] === 'px' ? +m[3] : +m[3] * REM) * 10) / 10);
2882 }
2883 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 };
2884 for (const [cls, px] of Object.entries(TW)) { if (new RegExp(`\\b${cls}\\b`).test(content)) sizes.add(px); }
2885 if (sizes.size < 3) return [];
2886 const sorted = [...sizes].sort((a, b) => a - b);
2887 const ratio = sorted[sorted.length - 1] / sorted[0];
2888 if (ratio >= 2.0) return [];
2889 const lines = content.split('\n');
2890 let line = 1;
2891 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; } }
2892 return [finding('flat-type-hierarchy', filePath, `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)`, line)];
2893 },
2894 // Monotonous spacing (regex)
2895 (content, filePath) => {
2896 const vals = [];
2897 let m;
2898 const pxRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*(\d+)px/gi;
2899 while ((m = pxRe.exec(content)) !== null) { const v = +m[1]; if (v > 0 && v < 200) vals.push(v); }
2900 const remRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*([\d.]+)rem/gi;
2901 while ((m = remRe.exec(content)) !== null) { const v = Math.round(parseFloat(m[1]) * 16); if (v > 0 && v < 200) vals.push(v); }
2902 const gapRe = /gap\s*:\s*(\d+)px/gi;
2903 while ((m = gapRe.exec(content)) !== null) vals.push(+m[1]);
2904 const twRe = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+)\b/g;
2905 while ((m = twRe.exec(content)) !== null) vals.push(+m[1] * 4);
2906 const rounded = vals.map(v => Math.round(v / 4) * 4);
2907 if (rounded.length < 10) return [];
2908 const counts = {};
2909 for (const v of rounded) counts[v] = (counts[v] || 0) + 1;
2910 const maxCount = Math.max(...Object.values(counts));
2911 const pct = maxCount / rounded.length;
2912 const unique = [...new Set(rounded)].filter(v => v > 0);
2913 if (pct <= 0.6 || unique.length > 3) return [];
2914 const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
2915 return [finding('monotonous-spacing', filePath, `~${dominant}px used ${maxCount}/${rounded.length} times (${Math.round(pct * 100)}%)`)];
2916 },
2917 // Everything centered (regex)
2918 (content, filePath) => {
2919 const lines = content.split('\n');
2920 let centered = 0, total = 0;
2921 for (const line of lines) {
2922 if (/<(?:h[1-6]|p|div|li|button)\b[^>]*>/i.test(line) && line.trim().length > 20) {
2923 total++;
2924 if (/text-align\s*:\s*center/i.test(line) || /\btext-center\b/.test(line)) centered++;
2925 }
2926 }
2927 if (total < 5 || centered / total <= 0.7) return [];
2928 return [finding('everything-centered', filePath, `${centered}/${total} text elements centered (${Math.round(centered / total * 100)}%)`)];
2929 },
2930 // Dark glow (page-level: dark bg + colored box-shadow with blur)
2931 (content, filePath) => {
2932 // Check if page has a dark background
2933 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;
2934 const twDarkBg = /\bbg-(?:gray|slate|zinc|neutral|stone)-(?:9\d{2}|800)\b/;
2935 const hasDarkBg = darkBgRe.test(content) || twDarkBg.test(content);
2936 if (!hasDarkBg) return [];
2937
2938 // Check for colored box-shadow with blur > 4px
2939 const shadowRe = /box-shadow\s*:\s*([^;{}]+)/gi;
2940 let m;
2941 while ((m = shadowRe.exec(content)) !== null) {
2942 const val = m[1];
2943 const colorMatch = val.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
2944 if (!colorMatch) continue;
2945 const [r, g, b] = [+colorMatch[1], +colorMatch[2], +colorMatch[3]];
2946 if ((Math.max(r, g, b) - Math.min(r, g, b)) < 30) continue; // skip gray
2947 // Check blur: look for pattern like "0 0 20px" (third number > 4)
2948 const pxVals = [...val.matchAll(/(\d+)px|(?<![.\d])\b(0)\b(?![.\d])/g)].map(p => +(p[1] || p[2]));
2949 if (pxVals.length >= 3 && pxVals[2] > 4) {
2950 const lines = content.substring(0, m.index).split('\n');
2951 return [finding('dark-glow', filePath, `Colored glow (rgb(${r},${g},${b})) on dark page`, lines.length)];
2952 }
2953 }
2954 return [];
2955 },
2956];
2957
2958// ---------------------------------------------------------------------------
2959// Style block extraction (Vue/Svelte <style> blocks)
2960// ---------------------------------------------------------------------------
2961
2962function extractStyleBlocks(content, ext) {
2963 ext = ext.toLowerCase();
2964 if (ext !== '.vue' && ext !== '.svelte') return [];
2965 const blocks = [];
2966 const re = /<style[^>]*>([\s\S]*?)<\/style>/gi;
2967 let m;
2968 while ((m = re.exec(content)) !== null) {
2969 const before = content.substring(0, m.index);
2970 const startLine = before.split('\n').length + 1;
2971 blocks.push({ content: m[1], startLine });
2972 }
2973 return blocks;
2974}
2975
2976// ---------------------------------------------------------------------------
2977// CSS-in-JS extraction (styled-components, emotion)
2978// ---------------------------------------------------------------------------
2979
2980const CSS_IN_JS_EXTENSIONS = new Set(['.js', '.ts', '.jsx', '.tsx']);
2981
2982function extractCSSinJS(content, ext) {
2983 ext = ext.toLowerCase();
2984 if (!CSS_IN_JS_EXTENSIONS.has(ext)) return [];
2985 const blocks = [];
2986 const re = /(?:styled(?:\.\w+|\([^)]+\))|css)\s*`([\s\S]*?)`/g;
2987 let m;
2988 while ((m = re.exec(content)) !== null) {
2989 const before = content.substring(0, m.index);
2990 const startLine = before.split('\n').length;
2991 blocks.push({ content: m[1], startLine });
2992 }
2993 return blocks;
2994}
2995
2996function runRegexMatchers(lines, filePath, lineOffset = 0, blockContext = null) {
2997 const findings = [];
2998 for (const matcher of REGEX_MATCHERS) {
2999 for (let i = 0; i < lines.length; i++) {
3000 const line = lines[i];
3001 matcher.regex.lastIndex = 0;
3002 let m;
3003 while ((m = matcher.regex.exec(line)) !== null) {
3004 // For extracted blocks, use nearby lines as context for multi-line CSS patterns
3005 const context = blockContext
3006 ? lines.slice(Math.max(0, i - 3), Math.min(lines.length, i + 4)).join(' ')
3007 : line;
3008 if (matcher.test(m, context)) {
3009 findings.push(finding(matcher.id, filePath, matcher.fmt(m, context), i + 1 + lineOffset));
3010 }
3011 }
3012 }
3013 }
3014 return findings;
3015}
3016
3017function detectText(content, filePath) {
3018 const findings = [];
3019 const lines = content.split('\n');
3020 const ext = filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : '';
3021
3022 // Run regex matchers on the full file content (catches Tailwind classes, inline styles)
3023 // Enable block context for CSS files where related properties span multiple lines
3024 const cssLike = new Set(['.css', '.scss', '.less']);
3025 findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null));
3026
3027 // Extract and scan <style> blocks from Vue/Svelte SFCs
3028 const styleBlocks = extractStyleBlocks(content, ext);
3029 for (const block of styleBlocks) {
3030 const blockLines = block.content.split('\n');
3031 findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true));
3032 }
3033
3034 // Extract and scan CSS-in-JS template literals
3035 const cssJsBlocks = extractCSSinJS(content, ext);
3036 for (const block of cssJsBlocks) {
3037 const blockLines = block.content.split('\n');
3038 findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true));
3039 }
3040
3041 // Deduplicate findings (same antipattern + similar snippet, within 2 lines)
3042 const deduped = [];
3043 for (const f of findings) {
3044 const isDupe = deduped.some(d =>
3045 d.antipattern === f.antipattern &&
3046 d.snippet === f.snippet &&
3047 Math.abs(d.line - f.line) <= 2
3048 );
3049 if (!isDupe) deduped.push(f);
3050 }
3051
3052 // Page-level analyzers only run on full pages
3053 if (isFullPage(content)) {
3054 for (const analyzer of REGEX_ANALYZERS) {
3055 deduped.push(...analyzer(content, filePath));
3056 }
3057 }
3058
3059 return deduped;
3060}
3061
3062// ---------------------------------------------------------------------------
3063// File walker
3064// ---------------------------------------------------------------------------
3065
3066const SKIP_DIRS = new Set([
3067 'node_modules', '.git', 'dist', 'build', '.next', '.nuxt', '.output',
3068 '.svelte-kit', '__pycache__', '.turbo', '.vercel',
3069]);
3070
3071const SCANNABLE_EXTENSIONS = new Set([
3072 '.html', '.htm', '.css', '.scss', '.less',
3073 '.jsx', '.tsx', '.js', '.ts',
3074 '.vue', '.svelte', '.astro',
3075]);
3076
3077const HTML_EXTENSIONS = new Set(['.html', '.htm']);
3078
3079function walkDir(dir) {
3080 const files = [];
3081 let entries;
3082 try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
3083 for (const entry of entries) {
3084 if (SKIP_DIRS.has(entry.name)) continue;
3085 const full = path.join(dir, entry.name);
3086 if (entry.isDirectory()) files.push(...walkDir(full));
3087 else if (SCANNABLE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) files.push(full);
3088 }
3089 return files;
3090}
3091
3092// ---------------------------------------------------------------------------
3093// Output formatting
3094// ---------------------------------------------------------------------------
3095
3096function formatFindings(findings, jsonMode) {
3097 if (jsonMode) return JSON.stringify(findings, null, 2);
3098
3099 const grouped = {};
3100 for (const f of findings) {
3101 if (!grouped[f.file]) grouped[f.file] = [];
3102 grouped[f.file].push(f);
3103 }
3104 const out = [];
3105 for (const [file, items] of Object.entries(grouped)) {
3106 const importNote = items[0]?.importedBy?.length ? ` (imported by ${items[0].importedBy.join(', ')})` : '';
3107 out.push(`\n${file}${importNote}`);
3108 for (const item of items) {
3109 out.push(` ${item.line ? `line ${item.line}: ` : ''}[${item.antipattern}] ${item.snippet}`);
3110 out.push(` → ${item.description}`);
3111 }
3112 }
3113 out.push(`\n${findings.length} anti-pattern${findings.length === 1 ? '' : 's'} found.`);
3114 return out.join('\n');
3115}
3116
3117// ---------------------------------------------------------------------------
3118// Stdin handling
3119// ---------------------------------------------------------------------------
3120
3121async function handleStdin() {
3122 const chunks = [];
3123 for await (const chunk of process.stdin) chunks.push(chunk);
3124 const input = Buffer.concat(chunks).toString('utf-8');
3125 try {
3126 const parsed = JSON.parse(input);
3127 const fp = parsed?.tool_input?.file_path;
3128 if (fp && fs.existsSync(fp)) {
3129 return HTML_EXTENSIONS.has(path.extname(fp).toLowerCase())
3130 ? detectHtml(fp) : detectText(fs.readFileSync(fp, 'utf-8'), fp);
3131 }
3132 } catch { /* not JSON */ }
3133 return detectText(input, '<stdin>');
3134}
3135
3136// ---------------------------------------------------------------------------
3137// Import graph (multi-file awareness)
3138// ---------------------------------------------------------------------------
3139
3140function resolveImport(specifier, fromDir, fileSet) {
3141 if (!/^[./]/.test(specifier)) return null; // skip bare specifiers
3142 const base = path.resolve(fromDir, specifier);
3143 if (fileSet.has(base)) return base;
3144 for (const ext of SCANNABLE_EXTENSIONS) {
3145 const withExt = base + ext;
3146 if (fileSet.has(withExt)) return withExt;
3147 }
3148 // index file convention
3149 for (const ext of SCANNABLE_EXTENSIONS) {
3150 const indexFile = path.join(base, 'index' + ext);
3151 if (fileSet.has(indexFile)) return indexFile;
3152 }
3153 return null;
3154}
3155
3156function buildImportGraph(files) {
3157 const fileSet = new Set(files);
3158 const graph = new Map();
3159
3160 for (const file of files) {
3161 const content = fs.readFileSync(file, 'utf-8');
3162 const dir = path.dirname(file);
3163 const imports = new Set();
3164
3165 // ES imports: import ... from '...' and import '...'
3166 const esRe = /import\s+(?:[\s\S]*?from\s+)?['"]([^'"]+)['"]/g;
3167 let m;
3168 while ((m = esRe.exec(content)) !== null) {
3169 const resolved = resolveImport(m[1], dir, fileSet);
3170 if (resolved) imports.add(resolved);
3171 }
3172
3173 // CSS @import
3174 const cssRe = /@import\s+(?:url\(\s*)?['"]?([^'");\s]+)['"]?\s*\)?/g;
3175 while ((m = cssRe.exec(content)) !== null) {
3176 const resolved = resolveImport(m[1], dir, fileSet);
3177 if (resolved) imports.add(resolved);
3178 }
3179
3180 // SCSS @use / @forward
3181 const scssRe = /@(?:use|forward)\s+['"]([^'"]+)['"]/g;
3182 while ((m = scssRe.exec(content)) !== null) {
3183 const resolved = resolveImport(m[1], dir, fileSet);
3184 if (resolved) imports.add(resolved);
3185 }
3186
3187 graph.set(file, imports);
3188 }
3189 return graph;
3190}
3191
3192// ---------------------------------------------------------------------------
3193// Framework dev server detection
3194// ---------------------------------------------------------------------------
3195
3196const FRAMEWORK_CONFIGS = [
3197 { name: 'Next.js', files: ['next.config.js', 'next.config.mjs', 'next.config.ts'], defaultPort: 3000,
3198 portRe: /port\s*[:=]\s*(\d+)/,
3199 fingerprint: { header: 'x-powered-by', value: /next/i } },
3200 { name: 'SvelteKit', files: ['svelte.config.js', 'svelte.config.ts'], defaultPort: 5173,
3201 portRe: /port\s*[:=]\s*(\d+)/,
3202 fingerprint: { header: 'x-sveltekit-page', value: null } },
3203 { name: 'Nuxt', files: ['nuxt.config.js', 'nuxt.config.ts'], defaultPort: 3000,
3204 portRe: /port\s*[:=]\s*(\d+)/,
3205 fingerprint: { header: 'x-powered-by', value: /nuxt/i } },
3206 { name: 'Vite', files: ['vite.config.js', 'vite.config.ts', 'vite.config.mjs'], defaultPort: 5173,
3207 portRe: /port\s*[:=]\s*(\d+)/,
3208 fingerprint: { body: /@vite\/client/ } },
3209 { name: 'Astro', files: ['astro.config.js', 'astro.config.ts', 'astro.config.mjs'], defaultPort: 4321,
3210 portRe: /port\s*[:=]\s*(\d+)/,
3211 fingerprint: { body: /astro/i } },
3212 { name: 'Angular', files: ['angular.json'], defaultPort: 4200,
3213 portRe: /"port"\s*:\s*(\d+)/,
3214 fingerprint: { body: /ng-version/i } },
3215 { name: 'Remix', files: ['remix.config.js', 'remix.config.ts'], defaultPort: 3000,
3216 portRe: /port\s*[:=]\s*(\d+)/,
3217 fingerprint: { header: 'x-powered-by', value: /remix/i } },
3218];
3219
3220function detectFrameworkConfig(dir) {
3221 let entries;
3222 try { entries = fs.readdirSync(dir); } catch { return null; }
3223 const entrySet = new Set(entries);
3224
3225 for (const cfg of FRAMEWORK_CONFIGS) {
3226 const match = cfg.files.find(f => entrySet.has(f));
3227 if (!match) continue;
3228
3229 const configPath = path.join(dir, match);
3230 let port = cfg.defaultPort;
3231 try {
3232 const content = fs.readFileSync(configPath, 'utf-8');
3233 const portMatch = content.match(cfg.portRe);
3234 if (portMatch) port = parseInt(portMatch[1], 10);
3235 } catch { /* use default */ }
3236
3237 return { name: cfg.name, port, configPath, fingerprint: cfg.fingerprint };
3238 }
3239 return null;
3240}
3241
3242/**
3243 * Check if a port is listening and optionally verify it matches the expected framework.
3244 * Returns { listening: true, matched: true/false } or { listening: false }.
3245 */
3246async function isPortListening(port, fingerprint = null) {
3247 if (!fingerprint) {
3248 // Simple TCP probe fallback
3249 const net = await import('node:net');
3250 return new Promise((resolve) => {
3251 const sock = net.default.createConnection({ port, host: '127.0.0.1' });
3252 sock.setTimeout(500);
3253 sock.on('connect', () => { sock.destroy(); resolve({ listening: true, matched: true }); });
3254 sock.on('error', () => resolve({ listening: false }));
3255 sock.on('timeout', () => { sock.destroy(); resolve({ listening: false }); });
3256 });
3257 }
3258
3259 // HTTP probe with fingerprint matching
3260 try {
3261 const controller = new AbortController();
3262 const timeout = setTimeout(() => controller.abort(), 2000);
3263 const res = await fetch(`http://localhost:${port}/`, { signal: controller.signal, redirect: 'follow' });
3264 clearTimeout(timeout);
3265
3266 // Check header fingerprint
3267 if (fingerprint.header) {
3268 const val = res.headers.get(fingerprint.header);
3269 if (val && (!fingerprint.value || fingerprint.value.test(val))) {
3270 return { listening: true, matched: true };
3271 }
3272 }
3273
3274 // Check body fingerprint
3275 if (fingerprint.body) {
3276 const body = await res.text();
3277 if (fingerprint.body.test(body)) {
3278 return { listening: true, matched: true };
3279 }
3280 }
3281
3282 // Port is listening but doesn't match the expected framework
3283 return { listening: true, matched: false };
3284 } catch {
3285 return { listening: false };
3286 }
3287}
3288
3289// ---------------------------------------------------------------------------
3290// CLI
3291// ---------------------------------------------------------------------------
3292
3293async function confirm(question) {
3294 const rl = (await import('node:readline')).default.createInterface({
3295 input: process.stdin, output: process.stderr,
3296 });
3297 return new Promise((resolve) => {
3298 rl.question(`${question} [Y/n] `, (answer) => {
3299 rl.close();
3300 resolve(!answer || /^y(es)?$/i.test(answer.trim()));
3301 });
3302 });
3303}
3304
3305function printUsage() {
3306 console.log(`Usage: impeccable detect [options] [file-or-dir-or-url...]
3307
3308Scan files or URLs for UI anti-patterns and design quality issues.
3309
3310Options:
3311 --fast Regex-only mode (skip jsdom, faster but misses linked stylesheets)
3312 --json Output results as JSON
3313 --help Show this help message
3314
3315Detection modes:
3316 HTML files jsdom with computed styles (default, catches linked CSS)
3317 Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.)
3318 URLs Puppeteer full browser rendering (auto-detected)
3319 --fast Forces regex for all files
3320
3321Examples:
3322 impeccable detect src/
3323 impeccable detect index.html
3324 impeccable detect https://example.com
3325 impeccable detect --fast --json .`);
3326}
3327
3328async function main() {
3329 const args = process.argv.slice(2);
3330 const jsonMode = args.includes('--json');
3331 const helpMode = args.includes('--help');
3332 const fastMode = args.includes('--fast');
3333 const targets = args.filter(a => !a.startsWith('--'));
3334
3335 if (helpMode) { printUsage(); process.exit(0); }
3336
3337 let allFindings = [];
3338
3339 if (!process.stdin.isTTY && targets.length === 0) {
3340 allFindings = await handleStdin();
3341 } else {
3342 const paths = targets.length > 0 ? targets : [process.cwd()];
3343
3344 for (const target of paths) {
3345 if (/^https?:\/\//i.test(target)) {
3346 try { allFindings.push(...await detectUrl(target)); }
3347 catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
3348 continue;
3349 }
3350
3351 const resolved = path.resolve(target);
3352 let stat;
3353 try { stat = fs.statSync(resolved); }
3354 catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
3355
3356 if (stat.isDirectory()) {
3357 // Check for framework dev server config (skip in JSON mode to avoid polluting output)
3358 if (!jsonMode) {
3359 const fwConfig = detectFrameworkConfig(resolved);
3360 if (fwConfig) {
3361 const probe = await isPortListening(fwConfig.port, fwConfig.fingerprint);
3362 if (probe.listening && probe.matched) {
3363 process.stderr.write(
3364 `\n${fwConfig.name} dev server detected on localhost:${fwConfig.port}.\n` +
3365 `For more accurate results, scan the running site:\n` +
3366 ` npx impeccable detect http://localhost:${fwConfig.port}\n\n`
3367 );
3368 } else if (probe.listening && !probe.matched) {
3369 process.stderr.write(
3370 `\n${fwConfig.name} project detected (${path.basename(fwConfig.configPath)}).\n` +
3371 `Port ${fwConfig.port} is in use by another service. Start the ${fwConfig.name} dev server and scan via URL for best results.\n\n`
3372 );
3373 } else {
3374 process.stderr.write(
3375 `\n${fwConfig.name} project detected (${path.basename(fwConfig.configPath)}).\n` +
3376 `Start the dev server and scan via URL for best results:\n` +
3377 ` npx impeccable detect http://localhost:${fwConfig.port}\n\n`
3378 );
3379 }
3380 }
3381 }
3382
3383 const files = walkDir(resolved);
3384 const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
3385
3386 // Warn and confirm if scanning many files (jsdom is slow per HTML file)
3387 if (files.length > 50 && process.stdin.isTTY && !jsonMode) {
3388 process.stderr.write(
3389 `\nFound ${files.length} files (${htmlCount} HTML) in ${target}.\n` +
3390 `Scanning may take a while${htmlCount > 10 ? ' (jsdom processes each HTML file individually)' : ''}.\n` +
3391 `Use --fast to skip jsdom, or target a specific subdirectory.\n`
3392 );
3393 const ok = await confirm('Continue?');
3394 if (!ok) { process.stderr.write('Aborted.\n'); process.exit(0); }
3395 }
3396
3397 // Build import graph for multi-file awareness
3398 const graph = buildImportGraph(files);
3399 // Build reverse map: file -> set of files that import it
3400 const importedByMap = new Map();
3401 for (const [importer, imports] of graph) {
3402 for (const imported of imports) {
3403 if (!importedByMap.has(imported)) importedByMap.set(imported, new Set());
3404 importedByMap.get(imported).add(importer);
3405 }
3406 }
3407
3408 for (const file of files) {
3409 const ext = path.extname(file).toLowerCase();
3410 let fileFindings;
3411 if (!fastMode && HTML_EXTENSIONS.has(ext)) {
3412 fileFindings = await detectHtml(file);
3413 } else {
3414 fileFindings = detectText(fs.readFileSync(file, 'utf-8'), file);
3415 }
3416 // Annotate findings with import context
3417 const importers = importedByMap.get(file);
3418 if (importers && importers.size > 0) {
3419 const importerNames = [...importers].map(f => path.basename(f));
3420 for (const f of fileFindings) {
3421 f.importedBy = importerNames;
3422 }
3423 }
3424 allFindings.push(...fileFindings);
3425 }
3426 } else if (stat.isFile()) {
3427 const ext = path.extname(resolved).toLowerCase();
3428 if (!fastMode && HTML_EXTENSIONS.has(ext)) {
3429 allFindings.push(...await detectHtml(resolved));
3430 } else {
3431 allFindings.push(...detectText(fs.readFileSync(resolved, 'utf-8'), resolved));
3432 }
3433 }
3434 }
3435 }
3436
3437 if (allFindings.length > 0) {
3438 process.stderr.write(formatFindings(allFindings, jsonMode) + '\n');
3439 process.exit(2);
3440 }
3441 if (jsonMode) process.stdout.write('[]\n');
3442 process.exit(0);
3443}
3444
3445// ---------------------------------------------------------------------------
3446// Live detection server
3447// ---------------------------------------------------------------------------
3448
3449async function findOpenPort(start = 8400) {
3450 const net = await import('node:net');
3451 return new Promise((resolve) => {
3452 const server = net.default.createServer();
3453 server.listen(start, '127.0.0.1', () => {
3454 const port = server.address().port;
3455 server.close(() => resolve(port));
3456 });
3457 server.on('error', () => resolve(findOpenPort(start + 1)));
3458 });
3459}
3460
3461const LIVE_PID_FILE = path.join((await import('node:os')).default.tmpdir(), 'impeccable-live.json');
3462
3463async function liveCli() {
3464 const args = process.argv.slice(2);
3465 const helpMode = args.includes('--help');
3466 const stopMode = args.includes('stop');
3467 const portArg = args.find(a => a.startsWith('--port='));
3468 const requestedPort = portArg ? parseInt(portArg.split('=')[1], 10) : null;
3469
3470 if (helpMode) {
3471 console.log(`Usage: impeccable live [options]
3472
3473Start a local server that serves the browser detection overlay script.
3474Inject the script into any page to scan for anti-patterns in real time.
3475
3476Commands:
3477 live Start the server (default)
3478 live stop Stop a running live server
3479
3480Options:
3481 --port=PORT Use a specific port (default: auto-detect unused port)
3482 --help Show this help message
3483
3484The server provides:
3485 /detect.js The detection overlay script (inject via <script> tag)
3486 /health Health check endpoint
3487 /stop Stop the server remotely`);
3488 process.exit(0);
3489 }
3490
3491 // Stop a running server
3492 if (stopMode) {
3493 try {
3494 const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
3495 const res = await fetch(`http://localhost:${info.port}/stop`);
3496 if (res.ok) {
3497 console.log(`Stopped live server on port ${info.port}.`);
3498 }
3499 } catch {
3500 console.log('No running live server found.');
3501 }
3502 process.exit(0);
3503 }
3504
3505 const http = await import('node:http');
3506 const scriptPath = path.join(path.dirname(new URL(import.meta.url).pathname), 'detect-antipatterns-browser.js');
3507
3508 let browserScript;
3509 try {
3510 browserScript = fs.readFileSync(scriptPath, 'utf-8');
3511 } catch {
3512 process.stderr.write('Error: Browser script not found. Run `npm run build:browser` first.\n');
3513 process.exit(1);
3514 }
3515
3516 const port = requestedPort || await findOpenPort();
3517
3518 const shutdown = () => {
3519 try { fs.unlinkSync(LIVE_PID_FILE); } catch { /* ignore */ }
3520 server.close();
3521 process.exit(0);
3522 };
3523
3524 const server = http.default.createServer((req, res) => {
3525 // CORS headers for cross-origin injection
3526 res.setHeader('Access-Control-Allow-Origin', '*');
3527 res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
3528 if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
3529
3530 if (req.url === '/detect.js' || req.url === '/') {
3531 res.writeHead(200, { 'Content-Type': 'application/javascript' });
3532 res.end(browserScript);
3533 } else if (req.url === '/health') {
3534 res.writeHead(200, { 'Content-Type': 'application/json' });
3535 res.end(JSON.stringify({ status: 'ok', port }));
3536 } else if (req.url === '/stop') {
3537 res.writeHead(200, { 'Content-Type': 'text/plain' });
3538 res.end('stopping');
3539 shutdown();
3540 } else {
3541 res.writeHead(404);
3542 res.end('Not found');
3543 }
3544 });
3545
3546 server.listen(port, '127.0.0.1', () => {
3547 // Write PID file so `live stop` can find us
3548 fs.writeFileSync(LIVE_PID_FILE, JSON.stringify({ pid: process.pid, port }));
3549
3550 const url = `http://localhost:${port}`;
3551 console.log(`Impeccable live detection server running on ${url}\n`);
3552 console.log(`Inject into any page:`);
3553 console.log(` const s = document.createElement('script');`);
3554 console.log(` s.src = '${url}/detect.js';`);
3555 console.log(` document.head.appendChild(s);\n`);
3556 console.log(`Stop: npx impeccable live stop`);
3557 });
3558
3559 process.on('SIGINT', shutdown);
3560 process.on('SIGTERM', shutdown);
3561}
3562
3563// ---------------------------------------------------------------------------
3564// Entry point
3565// ---------------------------------------------------------------------------
3566
3567if (!IS_BROWSER) {
3568 const isMainModule = process.argv[1]?.endsWith('detect-antipatterns.mjs') ||
3569 process.argv[1]?.endsWith('detect-antipatterns.mjs/');
3570 if (isMainModule) main();
3571}
3572
3573// @browser-strip-end
3574
3575// ─── Section 9: Exports ─────────────────────────────────────────────────────
3576// @browser-strip-start
3577
3578export {
3579 ANTIPATTERNS, SAFE_TAGS, OVERUSED_FONTS, GENERIC_FONTS,
3580 checkElementBorders, checkElementMotion, checkElementGlow, checkPageTypography, checkPageLayout, isNeutralColor, isFullPage,
3581 detectHtml, detectUrl, detectText,
3582 walkDir, formatFindings, SCANNABLE_EXTENSIONS, SKIP_DIRS,
3583 extractStyleBlocks, extractCSSinJS,
3584 buildImportGraph, resolveImport,
3585 detectFrameworkConfig, isPortListening, FRAMEWORK_CONFIGS,
3586 main as detectCli,
3587 liveCli,
3588};
3589
3590// @browser-strip-end