1/**
2 * Anti-Pattern Browser Detector for Impeccable
3 * Copyright (c) 2026 Paul Bakaus
4 * SPDX-License-Identifier: Apache-2.0
5 *
6 * GENERATED -- do not edit. Source: detect-antipatterns.mjs
7 * Rebuild: node scripts/build-browser-detector.js
8 *
9 * Usage: <script src="detect-antipatterns-browser.js"></script>
10 * Re-scan: window.impeccableScan()
11 */
12(function () {
13if (typeof window === 'undefined') return;
14
15/**
16 * Anti-Pattern Detector for Impeccable
17 * Copyright (c) 2026 Paul Bakaus
18 * SPDX-License-Identifier: Apache-2.0
19 *
20 * Universal file — auto-detects environment (browser vs Node) and adapts.
21 *
22 * Node usage:
23 * node detect-antipatterns.mjs [file-or-dir...] # jsdom for HTML, regex for rest
24 * node detect-antipatterns.mjs https://... # Puppeteer (auto)
25 * node detect-antipatterns.mjs --fast [files...] # regex-only (skip jsdom)
26 * node detect-antipatterns.mjs --json # JSON output
27 *
28 * Browser usage:
29 * <script src="detect-antipatterns-browser.js"></script>
30 * Re-scan: window.impeccableScan()
31 *
32 * Exit codes: 0 = clean, 2 = findings
33 */
34
35// ─── Environment ────────────────────────────────────────────────────────────
36
37const IS_BROWSER = true;
38const IS_NODE = !IS_BROWSER;
39
40
41// ─── Section 1: Constants ───────────────────────────────────────────────────
42
43const SAFE_TAGS = new Set([
44 'blockquote', 'nav', 'a', 'input', 'textarea', 'select',
45 'pre', 'code', 'span', 'th', 'td', 'tr', 'li', 'label',
46 'button', 'hr', 'html', 'head', 'body', 'script', 'style',
47 'link', 'meta', 'title', 'br', 'img', 'svg', 'path', 'circle',
48 'rect', 'line', 'polyline', 'polygon', 'g', 'defs', 'use',
49]);
50
51// Per-check safe-tags override for the border (side-tab / border-accent)
52// rule. We intentionally re-allow <label> here because card-shaped clickable
53// labels (e.g. .checklist-item wrapping a checkbox + content) are one of the
54// canonical side-tab anti-pattern shapes and must be detected. The rule's
55// other preconditions (non-neutral color, width >= 2px on a single side,
56// radius > 0 or width >= 3, element size >= 20x20 in the browser path)
57// already filter out plain inline form labels so this does not introduce
58// false positives. See modern-color-borders.html for the test matrix.
59const BORDER_SAFE_TAGS = new Set(
60 [...SAFE_TAGS].filter(t => t !== 'label')
61);
62
63const OVERUSED_FONTS = new Set([
64 'inter', 'roboto', 'open sans', 'lato', 'montserrat', 'arial', 'helvetica',
65]);
66
67// Brand-associated fonts: don't flag these as "overused" on the brand's own domains.
68// Keys are font names, values are arrays of hostname suffixes where the font is allowed.
69const GOOGLE_DOMAINS = [
70 'google.com', 'youtube.com', 'android.com', 'chromium.org',
71 'chrome.com', 'web.dev', 'gstatic.com', 'firebase.google.com',
72];
73const BRAND_FONT_DOMAINS = {
74 'roboto': GOOGLE_DOMAINS,
75 'google sans': GOOGLE_DOMAINS,
76 'product sans': GOOGLE_DOMAINS,
77};
78
79function isBrandFontOnOwnDomain(font) {
80 if (typeof location === 'undefined') return false;
81 const allowed = BRAND_FONT_DOMAINS[font];
82 if (!allowed) return false;
83 const host = location.hostname.toLowerCase();
84 return allowed.some(suffix => host === suffix || host.endsWith('.' + suffix));
85}
86
87const GENERIC_FONTS = new Set([
88 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy',
89 'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
90 '-apple-system', 'blinkmacsystemfont', 'segoe ui',
91 'inherit', 'initial', 'unset', 'revert',
92]);
93
94const ANTIPATTERNS = [
95 // ── AI slop: tells that something was AI-generated ──
96 {
97 id: 'side-tab',
98 category: 'slop',
99 name: 'Side-tab accent border',
100 description:
101 '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.',
102 skillSection: 'Visual Details',
103 skillGuideline: 'colored accent stripe',
104 },
105 {
106 id: 'border-accent-on-rounded',
107 category: 'slop',
108 name: 'Border accent on rounded element',
109 description:
110 'Thick accent border on a rounded card — the border clashes with the rounded corners. Remove the border or the border-radius.',
111 skillSection: 'Visual Details',
112 skillGuideline: 'colored accent stripe',
113 },
114 {
115 id: 'overused-font',
116 category: 'slop',
117 name: 'Overused font',
118 description:
119 'Inter, Roboto, Open Sans, Lato, Montserrat, and Arial are used on millions of sites. Choose a distinctive font that gives your interface personality.',
120 skillSection: 'Typography',
121 skillGuideline: 'overused fonts like Inter',
122 },
123 {
124 id: 'single-font',
125 category: 'slop',
126 name: 'Single font for everything',
127 description:
128 'Only one font family is used for the entire page. Pair a distinctive display font with a refined body font to create typographic hierarchy.',
129 skillSection: 'Typography',
130 skillGuideline: 'only one font family for the entire page',
131 },
132 {
133 id: 'flat-type-hierarchy',
134 category: 'slop',
135 name: 'Flat type hierarchy',
136 description:
137 '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).',
138 skillSection: 'Typography',
139 skillGuideline: 'flat type hierarchy',
140 },
141 {
142 id: 'gradient-text',
143 category: 'slop',
144 name: 'Gradient text',
145 description:
146 'Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.',
147 skillSection: 'Color & Contrast',
148 skillGuideline: 'gradient text for',
149 },
150 {
151 id: 'ai-color-palette',
152 category: 'slop',
153 name: 'AI color palette',
154 description:
155 'Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.',
156 skillSection: 'Color & Contrast',
157 skillGuideline: 'AI color palette',
158 },
159 {
160 id: 'nested-cards',
161 category: 'slop',
162 name: 'Nested cards',
163 description:
164 'Cards inside cards create visual noise and excessive depth. Flatten the hierarchy — use spacing, typography, and dividers instead of nesting containers.',
165 skillSection: 'Layout & Space',
166 skillGuideline: 'Nest cards inside cards',
167 },
168 {
169 id: 'monotonous-spacing',
170 category: 'slop',
171 name: 'Monotonous spacing',
172 description:
173 'The same spacing value used everywhere — no rhythm, no variation. Use tight groupings for related items and generous separations between sections.',
174 skillSection: 'Layout & Space',
175 skillGuideline: 'same spacing everywhere',
176 },
177 {
178 id: 'everything-centered',
179 category: 'slop',
180 name: 'Everything centered',
181 description:
182 'Every text element is center-aligned. Left-aligned text with asymmetric layouts feels more designed. Center only hero sections and CTAs.',
183 skillSection: 'Layout & Space',
184 skillGuideline: 'Center everything',
185 },
186 {
187 id: 'bounce-easing',
188 category: 'slop',
189 name: 'Bounce or elastic easing',
190 description:
191 'Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.',
192 skillSection: 'Motion',
193 skillGuideline: 'bounce or elastic easing',
194 },
195 {
196 id: 'dark-glow',
197 category: 'slop',
198 name: 'Dark mode with glowing accents',
199 description:
200 '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.',
201 skillSection: 'Color & Contrast',
202 skillGuideline: 'dark mode with glowing accents',
203 },
204 {
205 id: 'icon-tile-stack',
206 category: 'slop',
207 name: 'Icon tile stacked above heading',
208 description:
209 '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.',
210 skillSection: 'Typography',
211 skillGuideline: 'large icons with rounded corners above every heading',
212 },
213
214 // ── Quality: general design and accessibility issues ──
215 {
216 id: 'pure-black-white',
217 category: 'quality',
218 name: 'Pure black background',
219 description:
220 '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.',
221 skillSection: 'Color & Contrast',
222 skillGuideline: 'pure black (#000)',
223 },
224 {
225 id: 'gray-on-color',
226 category: 'quality',
227 name: 'Gray text on colored background',
228 description:
229 'Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.',
230 skillSection: 'Color & Contrast',
231 skillGuideline: 'gray text on colored backgrounds',
232 },
233 {
234 id: 'low-contrast',
235 category: 'quality',
236 name: 'Low contrast text',
237 description:
238 '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.',
239 },
240 {
241 id: 'layout-transition',
242 category: 'quality',
243 name: 'Layout property animation',
244 description:
245 'Animating width, height, padding, or margin causes layout thrash and janky performance. Use transform and opacity instead, or grid-template-rows for height animations.',
246 skillSection: 'Motion',
247 skillGuideline: 'Animate layout properties',
248 },
249 {
250 id: 'line-length',
251 category: 'quality',
252 name: 'Line length too long',
253 description:
254 '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.',
255 skillSection: 'Layout & Space',
256 skillGuideline: 'wrap beyond ~80 characters',
257 },
258 {
259 id: 'cramped-padding',
260 category: 'quality',
261 name: 'Cramped padding',
262 description:
263 'Text is too close to the edge of its container. Add at least 8px (ideally 12-16px) of padding inside bordered or colored containers.',
264 },
265 {
266 id: 'tight-leading',
267 category: 'quality',
268 name: 'Tight line height',
269 description:
270 '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.',
271 },
272 {
273 id: 'skipped-heading',
274 category: 'quality',
275 name: 'Skipped heading level',
276 description:
277 '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.',
278 },
279 {
280 id: 'justified-text',
281 category: 'quality',
282 name: 'Justified text',
283 description:
284 '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.',
285 },
286 {
287 id: 'tiny-text',
288 category: 'quality',
289 name: 'Tiny body text',
290 description:
291 'Body text below 12px is hard to read, especially on high-DPI screens. Use at least 14px for body content, 16px is ideal.',
292 },
293 {
294 id: 'all-caps-body',
295 category: 'quality',
296 name: 'All-caps body text',
297 description:
298 '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.',
299 skillSection: 'Typography',
300 skillGuideline: 'long body passages in uppercase',
301 },
302 {
303 id: 'wide-tracking',
304 category: 'quality',
305 name: 'Wide letter spacing on body text',
306 description:
307 'Letter spacing above 0.05em on body text disrupts natural character groupings and slows reading. Reserve wide tracking for short uppercase labels only.',
308 },
309];
310
311// ─── Section 2: Color Utilities ─────────────────────────────────────────────
312
313function isNeutralColor(color) {
314 if (!color || color === 'transparent') return true;
315
316 // rgb/rgba — use channel spread. Threshold 30 ≈ 11.7% of the 0–255 range.
317 const rgb = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
318 if (rgb) {
319 return (Math.max(+rgb[1], +rgb[2], +rgb[3]) - Math.min(+rgb[1], +rgb[2], +rgb[3])) < 30;
320 }
321
322 // oklch()/lch() — chroma is the second numeric component.
323 // oklch chroma is ~0–0.4 in sRGB gamut; >= 0.02 reads as tinted, not gray.
324 // lch chroma is ~0–150; >= 3 reads as tinted. jsdom emits both formats
325 // literally (it does NOT convert them to rgb).
326 const oklch = color.match(/oklch\(\s*[\d.%-]+\s+([\d.-]+)/i);
327 if (oklch) return parseFloat(oklch[1]) < 0.02;
328 const lch = color.match(/lch\(\s*[\d.%-]+\s+([\d.-]+)/i);
329 if (lch) return parseFloat(lch[1]) < 3;
330
331 // oklab()/lab() — a and b are signed axes; chroma = sqrt(a² + b²).
332 // oklab a/b are ~-0.4..0.4, threshold 0.02. lab a/b are ~-128..127, threshold 3.
333 const oklab = color.match(/oklab\(\s*[\d.%-]+\s+([\d.-]+)\s+([\d.-]+)/i);
334 if (oklab) {
335 const a = parseFloat(oklab[1]), b = parseFloat(oklab[2]);
336 return Math.hypot(a, b) < 0.02;
337 }
338 const lab = color.match(/lab\(\s*[\d.%-]+\s+([\d.-]+)\s+([\d.-]+)/i);
339 if (lab) {
340 const a = parseFloat(lab[1]), b = parseFloat(lab[2]);
341 return Math.hypot(a, b) < 3;
342 }
343
344 // hsl/hsla — saturation is the second numeric component (percent).
345 // Modern jsdom usually converts hsl() to rgb, but handle it directly for
346 // safety across versions and for any engine that preserves the format.
347 const hsl = color.match(/hsla?\(\s*[\d.-]+\s*,?\s*([\d.]+)%/i);
348 if (hsl) return parseFloat(hsl[1]) < 10;
349
350 // hwb(hue whiteness% blackness%) — a pixel is fully gray when
351 // whiteness + blackness >= 100; chroma-like saturation = 1 - (w+b)/100.
352 const hwb = color.match(/hwb\(\s*[\d.-]+\s+([\d.]+)%\s+([\d.]+)%/i);
353 if (hwb) {
354 const w = parseFloat(hwb[1]), b = parseFloat(hwb[2]);
355 return (1 - Math.min(100, w + b) / 100) < 0.1;
356 }
357
358 // Unknown / unrecognized format — err on the side of DETECTING rather
359 // than silently skipping. This is the opposite of the previous default,
360 // which was the root cause of the oklch bug.
361 return false;
362}
363
364function parseRgb(color) {
365 if (!color || color === 'transparent') return null;
366 const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
367 if (!m) return null;
368 return { r: +m[1], g: +m[2], b: +m[3], a: m[4] !== undefined ? +m[4] : 1 };
369}
370
371function relativeLuminance({ r, g, b }) {
372 const [rs, gs, bs] = [r / 255, g / 255, b / 255].map(c =>
373 c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
374 );
375 return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
376}
377
378function contrastRatio(c1, c2) {
379 const l1 = relativeLuminance(c1);
380 const l2 = relativeLuminance(c2);
381 return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
382}
383
384function parseGradientColors(bgImage) {
385 if (!bgImage || !bgImage.includes('gradient')) return [];
386 const colors = [];
387 for (const m of bgImage.matchAll(/rgba?\([^)]+\)/g)) {
388 const c = parseRgb(m[0]);
389 if (c) colors.push(c);
390 }
391 for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
392 const h = m[1];
393 if (h.length === 6) {
394 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 });
395 } else {
396 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 });
397 }
398 }
399 return colors;
400}
401
402function hasChroma(c, threshold = 30) {
403 if (!c) return false;
404 return (Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b)) >= threshold;
405}
406
407function getHue(c) {
408 if (!c) return 0;
409 const r = c.r / 255, g = c.g / 255, b = c.b / 255;
410 const max = Math.max(r, g, b), min = Math.min(r, g, b);
411 if (max === min) return 0;
412 const d = max - min;
413 let h;
414 if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
415 else if (max === g) h = ((b - r) / d + 2) / 6;
416 else h = ((r - g) / d + 4) / 6;
417 return Math.round(h * 360);
418}
419
420function colorToHex(c) {
421 if (!c) return '?';
422 return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');
423}
424
425// ─── Section 3: Pure Detection ──────────────────────────────────────────────
426
427function checkBorders(tag, widths, colors, radius) {
428 if (BORDER_SAFE_TAGS.has(tag)) return [];
429 const findings = [];
430 const sides = ['Top', 'Right', 'Bottom', 'Left'];
431
432 for (const side of sides) {
433 const w = widths[side];
434 if (w < 1 || isNeutralColor(colors[side])) continue;
435
436 const otherSides = sides.filter(s => s !== side);
437 const maxOther = Math.max(...otherSides.map(s => widths[s]));
438 if (!(w >= 2 && (maxOther <= 1 || w >= maxOther * 2))) continue;
439
440 const sn = side.toLowerCase();
441 const isSide = side === 'Left' || side === 'Right';
442
443 if (isSide) {
444 if (radius > 0) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` });
445 else if (w >= 3) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px` });
446 } else {
447 if (radius > 0 && w >= 2) findings.push({ id: 'border-accent-on-rounded', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` });
448 }
449 }
450
451 return findings;
452}
453
454// Returns true if the given text is composed entirely of emoji characters
455// (plus whitespace / variation selectors). Emojis render as multicolor glyphs
456// regardless of CSS `color`, so contrast checks against the element's text
457// color are meaningless for these nodes.
458const 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;
459const 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;
460function isEmojiOnlyText(text) {
461 if (!text) return false;
462 if (!EMOJI_CHAR_RE.test(text)) return false;
463 return text.replace(EMOJI_CHARS_GLOBAL, '').trim() === '';
464}
465
466function checkColors(opts) {
467 const { tag, textColor, bgColor, effectiveBg, effectiveBgStops, fontSize, fontWeight, hasDirectText, isEmojiOnly, bgClip, bgImage, classList } = opts;
468 if (SAFE_TAGS.has(tag)) return [];
469 const findings = [];
470
471 // Pure black background (only solid or near-solid, not semi-transparent overlays)
472 if (bgColor && bgColor.a >= 0.9 && bgColor.r === 0 && bgColor.g === 0 && bgColor.b === 0) {
473 findings.push({ id: 'pure-black-white', snippet: '#000000 background' });
474 }
475
476 if (hasDirectText && textColor && !isEmojiOnly) {
477 // Run background-dependent checks against either a solid bg or, if the
478 // ancestor is a gradient, against every gradient stop (use the worst case).
479 const bgs = effectiveBg ? [effectiveBg] : (effectiveBgStops && effectiveBgStops.length ? effectiveBgStops : null);
480 if (bgs) {
481 // Gray on colored background — flag if every stop is chromatic
482 const textLum = relativeLuminance(textColor);
483 const isGray = !hasChroma(textColor, 20) && textLum > 0.05 && textLum < 0.85;
484 if (isGray && bgs.every(b => hasChroma(b, 40))) {
485 const bgLabel = effectiveBg ? colorToHex(effectiveBg) : `gradient(${bgs.map(colorToHex).join(', ')})`;
486 findings.push({ id: 'gray-on-color', snippet: `text ${colorToHex(textColor)} on bg ${bgLabel}` });
487 }
488
489 // Low contrast (WCAG AA) — worst case across all bg stops
490 const ratios = bgs.map(b => contrastRatio(textColor, b));
491 let worstIdx = 0;
492 for (let i = 1; i < ratios.length; i++) if (ratios[i] < ratios[worstIdx]) worstIdx = i;
493 const ratio = ratios[worstIdx];
494 const isHeading = ['h1', 'h2', 'h3'].includes(tag);
495 const isLargeText = fontSize >= 18 || (fontSize >= 14 && fontWeight >= 700) || isHeading;
496 const threshold = isLargeText ? 3.0 : 4.5;
497 if (ratio < threshold) {
498 findings.push({ id: 'low-contrast', snippet: `${ratio.toFixed(1)}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(bgs[worstIdx])}` });
499 }
500 }
501
502 // AI palette: purple/violet on headings
503 if (hasChroma(textColor, 50)) {
504 const hue = getHue(textColor);
505 if (hue >= 260 && hue <= 310 && (['h1', 'h2', 'h3'].includes(tag) || fontSize >= 20)) {
506 findings.push({ id: 'ai-color-palette', snippet: `Purple/violet text (${colorToHex(textColor)}) on heading` });
507 }
508 }
509 }
510
511 // Gradient text
512 if (bgClip === 'text' && bgImage && bgImage.includes('gradient')) {
513 findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
514 }
515
516 // Tailwind class checks
517 if (classList) {
518 const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
519 if (/\bbg-black\b(?!\/)/.test(classStr)) {
520 findings.push({ id: 'pure-black-white', snippet: 'bg-black' });
521 }
522
523 const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
524 const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
525 if (grayMatch && colorBgMatch) {
526 findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
527 }
528
529 if (/\bbg-clip-text\b/.test(classStr) && /\bbg-gradient-to-/.test(classStr)) {
530 findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' });
531 }
532
533 const purpleText = classStr.match(/\btext-(?:purple|violet|indigo)-\d+\b/);
534 if (purpleText && (['h1', 'h2', 'h3'].includes(tag) || /\btext-(?:[2-9]xl)\b/.test(classStr))) {
535 findings.push({ id: 'ai-color-palette', snippet: `${purpleText[0]} on heading` });
536 }
537
538 if (/\bfrom-(?:purple|violet|indigo)-\d+\b/.test(classStr) && /\bto-(?:purple|violet|indigo|blue|cyan|pink|fuchsia)-\d+\b/.test(classStr)) {
539 findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet gradient (Tailwind)' });
540 }
541 }
542
543 return findings;
544}
545
546function isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg) {
547 if (!hasShadow && !hasBorder) return false;
548 return hasRadius || hasBg;
549}
550
551const HEADING_TAGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
552
553// Pure check: given a heading and metrics about its previousElementSibling,
554// decide if the sibling is the canonical "icon-tile-stacked-above-heading" shape.
555//
556// Triggers when ALL of the following hold for the sibling:
557// • size 32–128px on both axes (not too small, not a hero image)
558// • aspect ratio 0.7–1.4 (squarish — excludes wide thumbnails / pill badges)
559// • has a non-transparent background-color, background-image, OR a visible border
560// (covers solid colors, white-with-border, gradients — anything that visually
561// defines a tile)
562// • border-radius < width/2 (excludes round avatars; rounded squares pass)
563// • contains an <svg> or icon-class <i> element that's smaller than the tile
564// • the tile sits above the heading (its bottom is above the heading's top)
565function checkIconTile(opts) {
566 const { headingTag, headingText, headingTop,
567 siblingTag, siblingWidth, siblingHeight, siblingBottom,
568 siblingBgColor, siblingBgImage, siblingBorderWidth, siblingBorderRadius,
569 hasIconChild, iconChildWidth } = opts;
570 if (!HEADING_TAGS.has(headingTag)) return [];
571 if (!siblingTag) return [];
572 // Don't recurse into nested headings (e.g. h2 above h3 in a section header)
573 if (HEADING_TAGS.has(siblingTag)) return [];
574
575 // Size window: 32–128px on each axis
576 if (!(siblingWidth >= 32 && siblingWidth <= 128)) return [];
577 if (!(siblingHeight >= 32 && siblingHeight <= 128)) return [];
578
579 // Squarish aspect ratio
580 const ratio = siblingWidth / siblingHeight;
581 if (ratio < 0.7 || ratio > 1.4) return [];
582
583 // Must have something that visually defines the tile
584 const bgVisible = (siblingBgColor && siblingBgColor.a > 0.1)
585 || (siblingBgImage && siblingBgImage !== 'none' && siblingBgImage !== '');
586 const borderVisible = siblingBorderWidth > 0;
587 if (!bgVisible && !borderVisible) return [];
588
589 // Exclude circles (avatars). Rounded squares pass.
590 if (siblingBorderRadius >= siblingWidth / 2) return [];
591
592 // Must contain an icon element smaller than the tile
593 if (!hasIconChild) return [];
594 if (iconChildWidth && iconChildWidth >= siblingWidth * 0.95) return [];
595
596 // Vertical stacking: tile must end above where the heading starts.
597 // (Allow the check to skip when both top/bottom are 0 — jsdom layout case.)
598 if (headingTop && siblingBottom && siblingBottom > headingTop + 4) return [];
599
600 const text = (headingText || '').trim().slice(0, 60);
601 return [{
602 id: 'icon-tile-stack',
603 snippet: `${Math.round(siblingWidth)}x${Math.round(siblingHeight)}px icon tile above ${headingTag} "${text}"`,
604 }];
605}
606
607const LAYOUT_TRANSITION_PROPS = new Set([
608 'width', 'height', 'padding', 'margin',
609 'max-height', 'max-width', 'min-height', 'min-width',
610 'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
611 'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
612]);
613
614function checkMotion(opts) {
615 const { tag, transitionProperty, animationName, timingFunctions, classList } = opts;
616 if (SAFE_TAGS.has(tag)) return [];
617 const findings = [];
618
619 // --- Bounce/elastic easing ---
620 if (animationName && animationName !== 'none' && /bounce|elastic|wobble|jiggle|spring/i.test(animationName)) {
621 findings.push({ id: 'bounce-easing', snippet: `animation: ${animationName}` });
622 }
623 if (classList && /\banimate-bounce\b/.test(classList)) {
624 findings.push({ id: 'bounce-easing', snippet: 'animate-bounce (Tailwind)' });
625 }
626
627 // Check timing functions for overshoot cubic-bezier (y values outside [0, 1])
628 if (timingFunctions) {
629 const bezierRe = /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g;
630 let m;
631 while ((m = bezierRe.exec(timingFunctions)) !== null) {
632 const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]);
633 if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) {
634 findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${m[1]}, ${m[2]}, ${m[3]}, ${m[4]})` });
635 break;
636 }
637 }
638 }
639
640 // --- Layout property transition ---
641 if (transitionProperty && transitionProperty !== 'all' && transitionProperty !== 'none') {
642 const props = transitionProperty.split(',').map(p => p.trim().toLowerCase());
643 const layoutFound = props.filter(p => LAYOUT_TRANSITION_PROPS.has(p));
644 if (layoutFound.length > 0) {
645 findings.push({ id: 'layout-transition', snippet: `transition: ${layoutFound.join(', ')}` });
646 }
647 }
648
649 return findings;
650}
651
652function checkGlow(opts) {
653 const { boxShadow, effectiveBg } = opts;
654 if (!boxShadow || boxShadow === 'none') return [];
655 if (!effectiveBg) return [];
656
657 // Only flag on dark backgrounds (luminance < 0.1)
658 const bgLum = relativeLuminance(effectiveBg);
659 if (bgLum >= 0.1) return [];
660
661 // Split multiple shadows (commas not inside parentheses)
662 const parts = boxShadow.split(/,(?![^(]*\))/);
663 for (const shadow of parts) {
664 const colorMatch = shadow.match(/rgba?\([^)]+\)/);
665 if (!colorMatch) continue;
666 const color = parseRgb(colorMatch[0]);
667 if (!color || !hasChroma(color, 30)) continue;
668
669 // Extract px values — in computed style: "color Xpx Ypx BLURpx [SPREADpx]"
670 const afterColor = shadow.substring(shadow.indexOf(colorMatch[0]) + colorMatch[0].length);
671 const beforeColor = shadow.substring(0, shadow.indexOf(colorMatch[0]));
672 const pxVals = [...beforeColor.matchAll(/([\d.]+)px/g), ...afterColor.matchAll(/([\d.]+)px/g)]
673 .map(m => parseFloat(m[1]));
674
675 // Third value is blur (offset-x, offset-y, blur, [spread])
676 if (pxVals.length >= 3 && pxVals[2] > 4) {
677 return [{ id: 'dark-glow', snippet: `Colored glow (${colorToHex(color)}) on dark background` }];
678 }
679 }
680
681 return [];
682}
683
684/**
685 * Regex-on-HTML checks shared between browser and Node page-level detection.
686 * These don't need DOM access, just the raw HTML string.
687 */
688function checkHtmlPatterns(html) {
689 const findings = [];
690
691 // --- Color ---
692
693 // Pure black background
694 const pureBlackBgRe = /background(?:-color)?\s*:\s*(?:#000000|#000|rgb\(\s*0,\s*0,\s*0\s*\))\b/gi;
695 if (pureBlackBgRe.test(html)) {
696 findings.push({ id: 'pure-black-white', snippet: 'Pure #000 background' });
697 }
698
699 // AI color palette: purple/violet
700 const purpleHexRe = /#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9|6366f1|764ba2|667eea)\b/gi;
701 if (purpleHexRe.test(html)) {
702 const purpleTextRe = /(?:(?:^|;)\s*color\s*:\s*(?:.*?)(?:#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9))|gradient.*?#(?:7c3aed|8b5cf6|a855f7|764ba2|667eea))/gi;
703 if (purpleTextRe.test(html)) {
704 findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected' });
705 }
706 }
707
708 // Gradient text (background-clip: text + gradient)
709 const gradientRe = /(?:-webkit-)?background-clip\s*:\s*text/gi;
710 let gm;
711 while ((gm = gradientRe.exec(html)) !== null) {
712 const start = Math.max(0, gm.index - 200);
713 const context = html.substring(start, gm.index + gm[0].length + 200);
714 if (/gradient/i.test(context)) {
715 findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
716 break;
717 }
718 }
719 if (/\bbg-clip-text\b/.test(html) && /\bbg-gradient-to-/.test(html)) {
720 findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' });
721 }
722
723 // --- Layout ---
724
725 // Monotonous spacing
726 const spacingValues = [];
727 const spacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*(\d+)px/gi;
728 let sm;
729 while ((sm = spacingRe.exec(html)) !== null) {
730 const v = parseInt(sm[1], 10);
731 if (v > 0 && v < 200) spacingValues.push(v);
732 }
733 const gapRe = /gap\s*:\s*(\d+)px/gi;
734 while ((sm = gapRe.exec(html)) !== null) {
735 spacingValues.push(parseInt(sm[1], 10));
736 }
737 const twSpaceRe = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+)\b/g;
738 while ((sm = twSpaceRe.exec(html)) !== null) {
739 spacingValues.push(parseInt(sm[1], 10) * 4);
740 }
741 const remSpacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*([\d.]+)rem/gi;
742 while ((sm = remSpacingRe.exec(html)) !== null) {
743 const v = Math.round(parseFloat(sm[1]) * 16);
744 if (v > 0 && v < 200) spacingValues.push(v);
745 }
746 const roundedSpacing = spacingValues.map(v => Math.round(v / 4) * 4);
747 if (roundedSpacing.length >= 10) {
748 const counts = {};
749 for (const v of roundedSpacing) counts[v] = (counts[v] || 0) + 1;
750 const maxCount = Math.max(...Object.values(counts));
751 const dominantPct = maxCount / roundedSpacing.length;
752 const unique = [...new Set(roundedSpacing)].filter(v => v > 0);
753 if (dominantPct > 0.6 && unique.length <= 3) {
754 const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
755 findings.push({
756 id: 'monotonous-spacing',
757 snippet: `~${dominant}px used ${maxCount}/${roundedSpacing.length} times (${Math.round(dominantPct * 100)}%)`,
758 });
759 }
760 }
761
762 // --- Motion ---
763
764 // Bounce/elastic animation names
765 const bounceRe = /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi;
766 if (bounceRe.test(html)) {
767 findings.push({ id: 'bounce-easing', snippet: 'Bounce/elastic animation in CSS' });
768 }
769
770 // Overshoot cubic-bezier
771 const bezierRe = /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g;
772 let bm;
773 while ((bm = bezierRe.exec(html)) !== null) {
774 const y1 = parseFloat(bm[2]), y2 = parseFloat(bm[4]);
775 if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) {
776 findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})` });
777 break;
778 }
779 }
780
781 // Layout property transitions
782 const transRe = /transition(?:-property)?\s*:\s*([^;{}]+)/gi;
783 let tm;
784 while ((tm = transRe.exec(html)) !== null) {
785 const val = tm[1].toLowerCase();
786 if (/\ball\b/.test(val)) continue;
787 const found = val.match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
788 if (found) {
789 findings.push({ id: 'layout-transition', snippet: `transition: ${found.join(', ')}` });
790 break;
791 }
792 }
793
794 // --- Dark glow ---
795
796 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;
797 const twDarkBg = /\bbg-(?:gray|slate|zinc|neutral|stone)-(?:9\d{2}|800)\b/;
798 if (darkBgRe.test(html) || twDarkBg.test(html)) {
799 const shadowRe = /box-shadow\s*:\s*([^;{}]+)/gi;
800 let shm;
801 while ((shm = shadowRe.exec(html)) !== null) {
802 const val = shm[1];
803 const colorMatch = val.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
804 if (!colorMatch) continue;
805 const [r, g, b] = [+colorMatch[1], +colorMatch[2], +colorMatch[3]];
806 if ((Math.max(r, g, b) - Math.min(r, g, b)) < 30) continue;
807 const pxVals = [...val.matchAll(/(\d+)px|(?<![.\d])\b(0)\b(?![.\d])/g)].map(p => +(p[1] || p[2]));
808 if (pxVals.length >= 3 && pxVals[2] > 4) {
809 findings.push({ id: 'dark-glow', snippet: `Colored glow (rgb(${r},${g},${b})) on dark page` });
810 break;
811 }
812 }
813 }
814
815 return findings;
816}
817
818// ─── Section 4: resolveBackground (unified) ─────────────────────────────────
819
820function resolveBackground(el, win) {
821 let current = el;
822 while (current && current.nodeType === 1) {
823 const style = IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
824
825 // If this element has a background-image (gradient or url), it's visually
826 // opaque but we can't determine the effective color — bail out so callers
827 // don't get a false solid-color answer.
828 const bgImage = style.backgroundImage || '';
829 if (bgImage && bgImage !== 'none' && (/gradient/i.test(bgImage) || /url\s*\(/i.test(bgImage))) {
830 return null;
831 }
832
833 let bg = parseRgb(style.backgroundColor);
834 if (!IS_BROWSER && (!bg || bg.a < 0.1)) {
835 // jsdom doesn't decompose background shorthand — parse raw style attr
836 const rawStyle = current.getAttribute?.('style') || '';
837 const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
838 const inlineBg = bgMatch ? bgMatch[1].trim() : '';
839 // Check for gradient or url() image in inline style too
840 if (/gradient/i.test(inlineBg) || /url\s*\(/i.test(inlineBg)) return null;
841 bg = parseRgb(inlineBg);
842 if (!bg && inlineBg) {
843 const hexMatch = inlineBg.match(/#([0-9a-f]{6}|[0-9a-f]{3})\b/i);
844 if (hexMatch) {
845 const h = hexMatch[1];
846 if (h.length === 6) {
847 bg = { r: parseInt(h.slice(0,2), 16), g: parseInt(h.slice(2,4), 16), b: parseInt(h.slice(4,6), 16), a: 1 };
848 } else {
849 bg = { r: parseInt(h[0]+h[0], 16), g: parseInt(h[1]+h[1], 16), b: parseInt(h[2]+h[2], 16), a: 1 };
850 }
851 }
852 }
853 }
854 if (bg && bg.a > 0.1) {
855 if (IS_BROWSER || bg.a >= 0.5) return bg;
856 }
857 current = current.parentElement;
858 }
859 return { r: 255, g: 255, b: 255 };
860}
861
862// Walk parents looking for a gradient background and return its color stops.
863// Used as a fallback when resolveBackground() returns null because the
864// effective background is a gradient (no single solid color to compare against).
865function resolveGradientStops(el, win) {
866 let current = el;
867 while (current && current.nodeType === 1) {
868 const style = IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
869 const bgImage = style.backgroundImage || '';
870 if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
871 const stops = parseGradientColors(bgImage);
872 if (stops.length > 0) return stops;
873 }
874 if (!IS_BROWSER) {
875 // jsdom doesn't decompose `background:` shorthand — peek at the raw inline style
876 const rawStyle = current.getAttribute?.('style') || '';
877 const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
878 if (bgMatch && /gradient/i.test(bgMatch[1])) {
879 const stops = parseGradientColors(bgMatch[1]);
880 if (stops.length > 0) return stops;
881 }
882 }
883 current = current.parentElement;
884 }
885 return null;
886}
887
888// ─── Section 5: Element Adapters ────────────────────────────────────────────
889
890// Browser adapters — call getComputedStyle/getBoundingClientRect on live DOM
891
892function checkElementBordersDOM(el) {
893 const tag = el.tagName.toLowerCase();
894 if (BORDER_SAFE_TAGS.has(tag)) return [];
895 const rect = el.getBoundingClientRect();
896 if (rect.width < 20 || rect.height < 20) return [];
897 const style = getComputedStyle(el);
898 const sides = ['Top', 'Right', 'Bottom', 'Left'];
899 const widths = {}, colors = {};
900 for (const s of sides) {
901 widths[s] = parseFloat(style[`border${s}Width`]) || 0;
902 colors[s] = style[`border${s}Color`] || '';
903 }
904 return checkBorders(tag, widths, colors, parseFloat(style.borderRadius) || 0);
905}
906
907function checkElementColorsDOM(el) {
908 const tag = el.tagName.toLowerCase();
909 if (SAFE_TAGS.has(tag)) return [];
910 const rect = el.getBoundingClientRect();
911 if (rect.width < 10 || rect.height < 10) return [];
912 const style = getComputedStyle(el);
913 const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
914 const hasDirectText = directText.trim().length > 0;
915 const effectiveBg = resolveBackground(el);
916 return checkColors({
917 tag,
918 textColor: parseRgb(style.color),
919 bgColor: parseRgb(style.backgroundColor),
920 effectiveBg,
921 effectiveBgStops: effectiveBg ? null : resolveGradientStops(el),
922 fontSize: parseFloat(style.fontSize) || 16,
923 fontWeight: parseInt(style.fontWeight) || 400,
924 hasDirectText,
925 isEmojiOnly: isEmojiOnlyText(directText),
926 bgClip: style.webkitBackgroundClip || style.backgroundClip || '',
927 bgImage: style.backgroundImage || '',
928 classList: el.getAttribute('class') || '',
929 });
930}
931
932function checkElementIconTileDOM(el) {
933 const tag = el.tagName.toLowerCase();
934 if (!HEADING_TAGS.has(tag)) return [];
935 const sibling = el.previousElementSibling;
936 if (!sibling) return [];
937
938 const sibRect = sibling.getBoundingClientRect();
939 const headRect = el.getBoundingClientRect();
940 const sibStyle = getComputedStyle(sibling);
941
942 // The tile may either contain an <svg>/<i> icon child, OR the tile itself
943 // may contain an emoji/symbol character directly as its only text content
944 // (the "card-icon" pattern from many AI-generated demos).
945 const iconChild = sibling.querySelector('svg, i[data-lucide], i[class*="fa-"], i[class*="icon"]');
946 const iconRect = iconChild?.getBoundingClientRect();
947 const sibDirectText = [...sibling.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
948 const hasInlineEmojiIcon = sibling.children.length === 0 && isEmojiOnlyText(sibDirectText);
949
950 return checkIconTile({
951 headingTag: tag,
952 headingText: el.textContent || '',
953 headingTop: headRect.top,
954 siblingTag: sibling.tagName.toLowerCase(),
955 siblingWidth: sibRect.width,
956 siblingHeight: sibRect.height,
957 siblingBottom: sibRect.bottom,
958 siblingBgColor: parseRgb(sibStyle.backgroundColor),
959 siblingBgImage: sibStyle.backgroundImage || '',
960 siblingBorderWidth: parseFloat(sibStyle.borderTopWidth) || 0,
961 siblingBorderRadius: parseFloat(sibStyle.borderRadius) || 0,
962 hasIconChild: !!iconChild || hasInlineEmojiIcon,
963 iconChildWidth: iconRect?.width || 0,
964 });
965}
966
967function checkElementMotionDOM(el) {
968 const tag = el.tagName.toLowerCase();
969 if (SAFE_TAGS.has(tag)) return [];
970 const style = getComputedStyle(el);
971 return checkMotion({
972 tag,
973 transitionProperty: style.transitionProperty || '',
974 animationName: style.animationName || '',
975 timingFunctions: [style.animationTimingFunction, style.transitionTimingFunction].filter(Boolean).join(' '),
976 classList: el.getAttribute('class') || '',
977 });
978}
979
980function checkElementGlowDOM(el) {
981 const tag = el.tagName.toLowerCase();
982 const style = getComputedStyle(el);
983 if (!style.boxShadow || style.boxShadow === 'none') return [];
984 // Use parent's background — glow radiates outward, so the surrounding context matters
985 // If resolveBackground returns null (gradient), try to infer from the gradient colors
986 let parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el);
987 if (!parentBg) {
988 // Gradient background — sample its colors to determine if it's dark
989 let cur = el.parentElement;
990 while (cur && cur.nodeType === 1) {
991 const bgImage = getComputedStyle(cur).backgroundImage || '';
992 const gradColors = parseGradientColors(bgImage);
993 if (gradColors.length > 0) {
994 // Average the gradient colors
995 const avg = { r: 0, g: 0, b: 0 };
996 for (const c of gradColors) { avg.r += c.r; avg.g += c.g; avg.b += c.b; }
997 avg.r = Math.round(avg.r / gradColors.length);
998 avg.g = Math.round(avg.g / gradColors.length);
999 avg.b = Math.round(avg.b / gradColors.length);
1000 parentBg = avg;
1001 break;
1002 }
1003 cur = cur.parentElement;
1004 }
1005 }
1006 return checkGlow({ tag, boxShadow: style.boxShadow, effectiveBg: parentBg });
1007}
1008
1009function checkElementAIPaletteDOM(el) {
1010 const style = getComputedStyle(el);
1011 const findings = [];
1012
1013 // Check gradient backgrounds for purple/violet or cyan
1014 const bgImage = style.backgroundImage || '';
1015 const gradColors = parseGradientColors(bgImage);
1016 for (const c of gradColors) {
1017 if (hasChroma(c, 50)) {
1018 const hue = getHue(c);
1019 if (hue >= 260 && hue <= 310) {
1020 findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet gradient background' });
1021 break;
1022 }
1023 if (hue >= 160 && hue <= 200) {
1024 findings.push({ id: 'ai-color-palette', snippet: 'Cyan gradient background' });
1025 break;
1026 }
1027 }
1028 }
1029
1030 // Check for neon text (vivid cyan/purple color on dark background)
1031 const textColor = parseRgb(style.color);
1032 if (textColor && hasChroma(textColor, 80)) {
1033 const hue = getHue(textColor);
1034 const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
1035 if (isAIPalette) {
1036 const parentBg = el.parentElement ? resolveBackground(el.parentElement) : null;
1037 // Also check gradient parents
1038 let effectiveBg = parentBg;
1039 if (!effectiveBg) {
1040 let cur = el.parentElement;
1041 while (cur && cur.nodeType === 1) {
1042 const gi = getComputedStyle(cur).backgroundImage || '';
1043 const gc = parseGradientColors(gi);
1044 if (gc.length > 0) {
1045 const avg = { r: 0, g: 0, b: 0 };
1046 for (const c of gc) { avg.r += c.r; avg.g += c.g; avg.b += c.b; }
1047 avg.r = Math.round(avg.r / gc.length);
1048 avg.g = Math.round(avg.g / gc.length);
1049 avg.b = Math.round(avg.b / gc.length);
1050 effectiveBg = avg;
1051 break;
1052 }
1053 cur = cur.parentElement;
1054 }
1055 }
1056 if (effectiveBg && relativeLuminance(effectiveBg) < 0.1) {
1057 const label = hue >= 260 ? 'Purple/violet' : 'Cyan';
1058 findings.push({ id: 'ai-color-palette', snippet: `${label} neon text on dark background` });
1059 }
1060 }
1061 }
1062
1063 return findings;
1064}
1065
1066const QUALITY_TEXT_TAGS = new Set(['p', 'li', 'td', 'th', 'dd', 'blockquote', 'figcaption']);
1067
1068// Resolve a CSS font-size value to pixels by walking up the parent chain.
1069// Browsers resolve em/rem/% to px in getComputedStyle, but jsdom returns the
1070// specified value verbatim — so for the Node path we walk parents ourselves.
1071function resolveFontSizePx(el, win) {
1072 const chain = []; // raw font-size strings, leaf → root
1073 let cur = el;
1074 while (cur && cur.nodeType === 1) {
1075 const fs = (win ? win.getComputedStyle(cur) : getComputedStyle(cur)).fontSize;
1076 chain.push(fs || '');
1077 cur = cur.parentElement;
1078 }
1079 // Walk root → leaf, resolving each value relative to its parent context.
1080 let px = 16; // root default
1081 for (let i = chain.length - 1; i >= 0; i--) {
1082 const v = chain[i];
1083 if (!v || v === 'inherit') continue;
1084 const num = parseFloat(v);
1085 if (isNaN(num)) continue;
1086 if (v.endsWith('px')) px = num;
1087 else if (v.endsWith('rem')) px = num * 16;
1088 else if (v.endsWith('em')) px = num * px;
1089 else if (v.endsWith('%')) px = (num / 100) * px;
1090 else px = num; // unitless — already resolved
1091 }
1092 return px;
1093}
1094
1095// Resolve a CSS length value (line-height, letter-spacing, etc.) given a
1096// known font-size context. Returns null for "normal" / unparseable values.
1097function resolveLengthPx(value, fontSizePx) {
1098 if (!value || value === 'normal' || value === 'auto' || value === 'inherit') return null;
1099 const num = parseFloat(value);
1100 if (isNaN(num)) return null;
1101 if (value.endsWith('px')) return num;
1102 if (value.endsWith('rem')) return num * 16;
1103 if (value.endsWith('em')) return num * fontSizePx;
1104 if (value.endsWith('%')) return (num / 100) * fontSizePx;
1105 // Unitless line-height = multiplier, return px equivalent
1106 return num * fontSizePx;
1107}
1108
1109// Pure quality checks. Most run on computed CSS and DOM-only inputs (work in
1110// jsdom and the browser). Two checks (line-length, cramped-padding) gate on
1111// element rect dimensions, which jsdom can't compute — pass `rect: null` from
1112// the Node adapter to skip those.
1113//
1114// Both adapters resolve font-size, line-height and letter-spacing to pixels
1115// before calling this so the pure function only deals with numbers.
1116function checkQuality(opts) {
1117 const { el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax = 80 } = opts;
1118 const findings = [];
1119 // Skip browser extension injected elements
1120 const elId = el.id || '';
1121 if (elId.startsWith('claude-') || elId.startsWith('cic-')) return findings;
1122
1123 // --- Line length too long --- (browser-only: needs rect.width)
1124 if (rect && hasDirectText && QUALITY_TEXT_TAGS.has(tag) && rect.width > 0 && textLen > lineMax) {
1125 const charsPerLine = rect.width / (fontSize * 0.5);
1126 if (charsPerLine > lineMax + 5) {
1127 findings.push({ id: 'line-length', snippet: `~${Math.round(charsPerLine)} chars/line (aim for <${lineMax})` });
1128 }
1129 }
1130
1131 // --- Cramped padding --- (browser-only: needs rect to skip small badges/labels)
1132 // Vertical and horizontal thresholds are independent because line-height
1133 // already provides built-in vertical breathing room (the line box is taller
1134 // than the cap height), but horizontal has no equivalent. Both scale with
1135 // font-size — bigger text demands proportionally more padding.
1136 // vertical: max(4px, fontSize × 0.3)
1137 // horizontal: max(8px, fontSize × 0.5)
1138 if (rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) {
1139 const borders = {
1140 top: parseFloat(style.borderTopWidth) || 0,
1141 right: parseFloat(style.borderRightWidth) || 0,
1142 bottom: parseFloat(style.borderBottomWidth) || 0,
1143 left: parseFloat(style.borderLeftWidth) || 0,
1144 };
1145 const borderCount = Object.values(borders).filter(w => w > 0).length;
1146 const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)';
1147 if (borderCount >= 2 || hasBg) {
1148 const vPads = [], hPads = [];
1149 if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0);
1150 if (hasBg || borders.bottom > 0) vPads.push(parseFloat(style.paddingBottom) || 0);
1151 if (hasBg || borders.left > 0) hPads.push(parseFloat(style.paddingLeft) || 0);
1152 if (hasBg || borders.right > 0) hPads.push(parseFloat(style.paddingRight) || 0);
1153
1154 const vMin = vPads.length ? Math.min(...vPads) : Infinity;
1155 const hMin = hPads.length ? Math.min(...hPads) : Infinity;
1156 const vThresh = Math.max(4, fontSize * 0.3);
1157 const hThresh = Math.max(8, fontSize * 0.5);
1158
1159 // Emit at most one finding per element — pick whichever axis is worse.
1160 if (vMin < vThresh) {
1161 findings.push({ id: 'cramped-padding', snippet: `${vMin}px vertical padding (need ≥${vThresh.toFixed(1)}px for ${fontSize}px text)` });
1162 } else if (hMin < hThresh) {
1163 findings.push({ id: 'cramped-padding', snippet: `${hMin}px horizontal padding (need ≥${hThresh.toFixed(1)}px for ${fontSize}px text)` });
1164 }
1165 }
1166 }
1167
1168 // --- Tight line height ---
1169 if (hasDirectText && textLen > 50 && !['h1','h2','h3','h4','h5','h6'].includes(tag)) {
1170 if (lineHeightPx != null && fontSize > 0) {
1171 const ratio = lineHeightPx / fontSize;
1172 if (ratio > 0 && ratio < 1.3) {
1173 findings.push({ id: 'tight-leading', snippet: `line-height ${ratio.toFixed(2)}x (need >=1.3)` });
1174 }
1175 }
1176 }
1177
1178 // --- Justified text (without hyphens) ---
1179 if (hasDirectText && style.textAlign === 'justify') {
1180 const hyphens = style.hyphens || style.webkitHyphens || '';
1181 if (hyphens !== 'auto') {
1182 findings.push({ id: 'justified-text', snippet: 'text-align: justify without hyphens: auto' });
1183 }
1184 }
1185
1186 // --- Tiny body text ---
1187 // Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.)
1188 if (hasDirectText && textLen > 20 && fontSize < 12) {
1189 const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption'];
1190 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]');
1191 const isUppercase = style.textTransform === 'uppercase';
1192 if (!skipTags.includes(tag) && !inUIContext && !isUppercase) {
1193 findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` });
1194 }
1195 }
1196
1197 // --- All-caps body text ---
1198 if (hasDirectText && textLen > 30 && style.textTransform === 'uppercase') {
1199 if (!['h1','h2','h3','h4','h5','h6'].includes(tag)) {
1200 findings.push({ id: 'all-caps-body', snippet: `text-transform: uppercase on ${textLen} chars of body text` });
1201 }
1202 }
1203
1204 // --- Wide letter spacing on body text ---
1205 if (hasDirectText && textLen > 20 && style.textTransform !== 'uppercase') {
1206 if (letterSpacingPx != null && letterSpacingPx > 0 && fontSize > 0) {
1207 const trackingEm = letterSpacingPx / fontSize;
1208 if (trackingEm > 0.05) {
1209 findings.push({ id: 'wide-tracking', snippet: `letter-spacing: ${trackingEm.toFixed(2)}em on body text` });
1210 }
1211 }
1212 }
1213
1214 return findings;
1215}
1216
1217function checkElementQualityDOM(el) {
1218 const tag = el.tagName.toLowerCase();
1219 const style = getComputedStyle(el);
1220 const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 10);
1221 const textLen = el.textContent?.trim().length || 0;
1222 // Browser getComputedStyle resolves everything to px — direct parseFloat
1223 // works.
1224 const fontSize = parseFloat(style.fontSize) || 16;
1225 const lineHeightPx = resolveLengthPx(style.lineHeight, fontSize);
1226 const letterSpacingPx = resolveLengthPx(style.letterSpacing, fontSize);
1227 const rect = el.getBoundingClientRect();
1228 const lineMax = (typeof window !== 'undefined' && window.__IMPECCABLE_CONFIG__?.lineLengthMax) || 80;
1229 return checkQuality({ el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax });
1230}
1231
1232// Pure page-level skipped-heading walk. Takes a Document so it works in both
1233// the browser and jsdom.
1234function checkPageQualityFromDoc(doc) {
1235 const findings = [];
1236 const headings = doc.querySelectorAll('h1, h2, h3, h4, h5, h6');
1237 let prevLevel = 0;
1238 let prevText = '';
1239 for (const h of headings) {
1240 const level = parseInt(h.tagName[1]);
1241 const text = (h.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 60);
1242 if (prevLevel > 0 && level > prevLevel + 1) {
1243 findings.push({
1244 id: 'skipped-heading',
1245 snippet: `<h${prevLevel}> "${prevText}" followed by <h${level}> "${text}" (missing h${prevLevel + 1})`,
1246 });
1247 }
1248 prevLevel = level;
1249 prevText = text;
1250 }
1251 return findings;
1252}
1253
1254// Browser adapter (returns the legacy { type, detail } shape used by the overlay loop)
1255function checkPageQualityDOM() {
1256 return checkPageQualityFromDoc(document).map(f => ({ type: f.id, detail: f.snippet }));
1257}
1258
1259// Node adapters — take pre-extracted jsdom computed style
1260
1261// jsdom doesn't lay out OR resolve em/rem/% to px — so we pre-resolve every
1262// CSS length the rule needs ourselves (walking the parent chain for
1263// font-size inheritance), and pass `rect: null` to skip the two rules that
1264// genuinely need element rects (line-length, cramped-padding).
1265function checkElementQuality(el, style, tag, window) {
1266 const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 10);
1267 const textLen = el.textContent?.trim().length || 0;
1268 const fontSize = resolveFontSizePx(el, window);
1269 const lineHeightPx = resolveLengthPx(style.lineHeight, fontSize);
1270 const letterSpacingPx = resolveLengthPx(style.letterSpacing, fontSize);
1271 return checkQuality({ el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect: null });
1272}
1273
1274function checkElementBorders(tag, style, overrides) {
1275 const sides = ['Top', 'Right', 'Bottom', 'Left'];
1276 const widths = {}, colors = {};
1277 for (const s of sides) {
1278 widths[s] = parseFloat(style[`border${s}Width`]) || 0;
1279 colors[s] = style[`border${s}Color`] || '';
1280 // jsdom silently drops any border shorthand containing var(), leaving
1281 // both width and color empty on the computed style. When the detectHtml
1282 // pre-pass pulled a resolved value off the rule, use it to fill in the
1283 // missing side so the side-tab check can run. Real browsers resolve
1284 // var() natively, so this fallback is a no-op in the browser path.
1285 if (widths[s] === 0 && overrides && overrides[s]) {
1286 widths[s] = overrides[s].width;
1287 colors[s] = overrides[s].color;
1288 } else if (colors[s] && colors[s].startsWith('var(') && overrides && overrides[s]) {
1289 // Longhand case: jsdom kept the width but left the color as the
1290 // literal `var(...)` string. Substitute the resolved color.
1291 colors[s] = overrides[s].color;
1292 }
1293 }
1294 return checkBorders(tag, widths, colors, parseFloat(style.borderRadius) || 0);
1295}
1296
1297function checkElementColors(el, style, tag, window) {
1298 const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
1299 const hasDirectText = directText.trim().length > 0;
1300
1301 const effectiveBg = resolveBackground(el, window);
1302 return checkColors({
1303 tag,
1304 textColor: parseRgb(style.color),
1305 bgColor: parseRgb(style.backgroundColor),
1306 effectiveBg,
1307 effectiveBgStops: effectiveBg ? null : resolveGradientStops(el, window),
1308 fontSize: parseFloat(style.fontSize) || 16,
1309 fontWeight: parseInt(style.fontWeight) || 400,
1310 hasDirectText,
1311 isEmojiOnly: isEmojiOnlyText(directText),
1312 bgClip: style.webkitBackgroundClip || style.backgroundClip || '',
1313 bgImage: style.backgroundImage || '',
1314 classList: el.getAttribute?.('class') || el.className || '',
1315 });
1316}
1317
1318function checkElementIconTile(el, tag, window) {
1319 if (!HEADING_TAGS.has(tag)) return [];
1320 const sibling = el.previousElementSibling;
1321 if (!sibling) return [];
1322
1323 const sibStyle = window.getComputedStyle(sibling);
1324 // jsdom doesn't lay out — read explicit pixel dimensions from CSS instead.
1325 const sibWidth = parseFloat(sibStyle.width) || 0;
1326 const sibHeight = parseFloat(sibStyle.height) || 0;
1327
1328 const iconChild = sibling.querySelector('svg, i[data-lucide], i[class*="fa-"], i[class*="icon"]');
1329 let iconWidth = 0;
1330 if (iconChild) {
1331 const iconStyle = window.getComputedStyle(iconChild);
1332 iconWidth = parseFloat(iconStyle.width) || parseFloat(iconChild.getAttribute('width')) || 0;
1333 }
1334 // Or: tile contains an emoji/symbol character directly as its only content
1335 const sibDirectText = [...sibling.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
1336 const hasInlineEmojiIcon = sibling.children.length === 0 && isEmojiOnlyText(sibDirectText);
1337
1338 return checkIconTile({
1339 headingTag: tag,
1340 headingText: el.textContent || '',
1341 headingTop: 0, // jsdom: no layout, skip vertical-stacking gate
1342 siblingTag: sibling.tagName.toLowerCase(),
1343 siblingWidth: sibWidth,
1344 siblingHeight: sibHeight,
1345 siblingBottom: 0,
1346 siblingBgColor: parseRgb(sibStyle.backgroundColor),
1347 siblingBgImage: sibStyle.backgroundImage || '',
1348 siblingBorderWidth: parseFloat(sibStyle.borderTopWidth) || 0,
1349 siblingBorderRadius: parseFloat(sibStyle.borderRadius) || 0,
1350 hasIconChild: !!iconChild || hasInlineEmojiIcon,
1351 iconChildWidth: iconWidth,
1352 });
1353}
1354
1355function checkElementMotion(tag, style) {
1356 return checkMotion({
1357 tag,
1358 transitionProperty: style.transitionProperty || '',
1359 animationName: style.animationName || '',
1360 timingFunctions: [style.animationTimingFunction, style.transitionTimingFunction].filter(Boolean).join(' '),
1361 classList: '',
1362 });
1363}
1364
1365function checkElementGlow(tag, style, effectiveBg) {
1366 if (!style.boxShadow || style.boxShadow === 'none') return [];
1367 return checkGlow({ tag, boxShadow: style.boxShadow, effectiveBg });
1368}
1369
1370// ─── Section 6: Page-Level Checks ───────────────────────────────────────────
1371
1372// Browser page-level checks — use document/getComputedStyle globals
1373
1374function checkTypography() {
1375 const findings = [];
1376
1377 // Walk actual text-bearing elements and tally font usage by *computed style*.
1378 // This is much more accurate than scanning CSS rules — it ignores rules that
1379 // exist in the stylesheet but apply to nothing (e.g. demo classes showing
1380 // anti-patterns), and counts what the user actually sees.
1381 const fontUsage = new Map(); // primary font name → count of elements
1382 let totalTextElements = 0;
1383 for (const el of document.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, td, th, dd, blockquote, figcaption, a, button, label, span')) {
1384 // Skip impeccable's own elements
1385 if (el.closest && el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue;
1386 // Only count elements that actually have visible direct text
1387 const hasText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
1388 if (!hasText) continue;
1389 const style = getComputedStyle(el);
1390 const ff = style.fontFamily;
1391 if (!ff) continue;
1392 const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
1393 const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
1394 if (!primary) continue;
1395 fontUsage.set(primary, (fontUsage.get(primary) || 0) + 1);
1396 totalTextElements++;
1397 }
1398
1399 if (totalTextElements >= 20) {
1400 // A font is "primary" if it's used by at least 15% of text elements
1401 const PRIMARY_THRESHOLD = 0.15;
1402 for (const [font, count] of fontUsage) {
1403 const share = count / totalTextElements;
1404 if (share < PRIMARY_THRESHOLD) continue;
1405 if (!OVERUSED_FONTS.has(font)) continue;
1406 if (isBrandFontOnOwnDomain(font)) continue;
1407 findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
1408 }
1409
1410 // Single-font check: only one distinct primary font across all text
1411 if (fontUsage.size === 1) {
1412 const only = [...fontUsage.keys()][0];
1413 findings.push({ type: 'single-font', detail: `only font used is ${only}` });
1414 }
1415 }
1416
1417 const sizes = new Set();
1418 for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) {
1419 const fs = parseFloat(getComputedStyle(el).fontSize);
1420 if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10);
1421 }
1422 if (sizes.size >= 3) {
1423 const sorted = [...sizes].sort((a, b) => a - b);
1424 const ratio = sorted[sorted.length - 1] / sorted[0];
1425 if (ratio < 2.0) {
1426 findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` });
1427 }
1428 }
1429
1430 return findings;
1431}
1432
1433function isCardLikeDOM(el) {
1434 const tag = el.tagName.toLowerCase();
1435 if (SAFE_TAGS.has(tag) || ['input','select','textarea','img','video','canvas','picture'].includes(tag)) return false;
1436 const style = getComputedStyle(el);
1437 const cls = el.getAttribute('class') || '';
1438 const hasShadow = (style.boxShadow && style.boxShadow !== 'none') || /\bshadow(?:-sm|-md|-lg|-xl|-2xl)?\b/.test(cls);
1439 const hasBorder = /\bborder\b/.test(cls);
1440 const hasRadius = parseFloat(style.borderRadius) > 0 || /\brounded(?:-sm|-md|-lg|-xl|-2xl|-full)?\b/.test(cls);
1441 const hasBg = (style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)') || /\bbg-(?:white|gray-\d+|slate-\d+)\b/.test(cls);
1442 return isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg);
1443}
1444
1445function checkLayout() {
1446 const findings = [];
1447 const flaggedEls = new Set();
1448
1449 for (const el of document.querySelectorAll('*')) {
1450 if (!isCardLikeDOM(el) || flaggedEls.has(el)) continue;
1451 const cls = el.getAttribute('class') || '';
1452 const style = getComputedStyle(el);
1453 if (style.position === 'absolute' || style.position === 'fixed') continue;
1454 if (/\b(?:dropdown|popover|tooltip|menu|modal|dialog)\b/i.test(cls)) continue;
1455 if ((el.textContent?.trim().length || 0) < 10) continue;
1456 const rect = el.getBoundingClientRect();
1457 if (rect.width < 50 || rect.height < 30) continue;
1458
1459 let parent = el.parentElement;
1460 while (parent) {
1461 if (isCardLikeDOM(parent)) { flaggedEls.add(el); break; }
1462 parent = parent.parentElement;
1463 }
1464 }
1465
1466 for (const el of flaggedEls) {
1467 let isAncestor = false;
1468 for (const other of flaggedEls) {
1469 if (other !== el && el.contains(other)) { isAncestor = true; break; }
1470 }
1471 if (!isAncestor) findings.push({ type: 'nested-cards', detail: 'Card inside card', el });
1472 }
1473
1474 return findings;
1475}
1476
1477// Node page-level checks — take document/window as parameters
1478
1479function checkPageTypography(doc, win) {
1480 const findings = [];
1481
1482 const fonts = new Set();
1483 const overusedFound = new Set();
1484
1485 for (const sheet of doc.styleSheets) {
1486 let rules;
1487 try { rules = sheet.cssRules || sheet.rules; } catch { continue; }
1488 if (!rules) continue;
1489 for (const rule of rules) {
1490 if (rule.type !== 1) continue;
1491 const ff = rule.style?.fontFamily;
1492 if (!ff) continue;
1493 const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
1494 const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
1495 if (primary) {
1496 fonts.add(primary);
1497 if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
1498 }
1499 }
1500 }
1501
1502 // Check Google Fonts links in HTML
1503 const html = doc.documentElement?.outerHTML || '';
1504 const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
1505 let m;
1506 while ((m = gfRe.exec(html)) !== null) {
1507 const families = m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase());
1508 for (const f of families) {
1509 fonts.add(f);
1510 if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
1511 }
1512 }
1513
1514 // Also parse raw HTML/style content for font-family (jsdom may not expose all via CSSOM)
1515 const ffRe = /font-family\s*:\s*([^;}]+)/gi;
1516 let fm;
1517 while ((fm = ffRe.exec(html)) !== null) {
1518 for (const f of fm[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) {
1519 if (f && !GENERIC_FONTS.has(f)) {
1520 fonts.add(f);
1521 if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
1522 }
1523 }
1524 }
1525
1526 for (const font of overusedFound) {
1527 findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` });
1528 }
1529
1530 // Single font
1531 if (fonts.size === 1) {
1532 const els = doc.querySelectorAll('*');
1533 if (els.length >= 20) {
1534 findings.push({ id: 'single-font', snippet: `only font used is ${[...fonts][0]}` });
1535 }
1536 }
1537
1538 // Flat type hierarchy
1539 const sizes = new Set();
1540 const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div');
1541 for (const el of textEls) {
1542 const fontSize = parseFloat(win.getComputedStyle(el).fontSize);
1543 // Filter out sub-8px values (jsdom doesn't resolve relative units properly)
1544 if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10);
1545 }
1546 if (sizes.size >= 3) {
1547 const sorted = [...sizes].sort((a, b) => a - b);
1548 const ratio = sorted[sorted.length - 1] / sorted[0];
1549 if (ratio < 2.0) {
1550 findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` });
1551 }
1552 }
1553
1554 return findings;
1555}
1556
1557function isCardLike(el, win) {
1558 const tag = el.tagName.toLowerCase();
1559 if (SAFE_TAGS.has(tag) || ['input', 'select', 'textarea', 'img', 'video', 'canvas', 'picture'].includes(tag)) return false;
1560
1561 const style = win.getComputedStyle(el);
1562 const rawStyle = el.getAttribute?.('style') || '';
1563 const cls = el.getAttribute?.('class') || '';
1564
1565 const hasShadow = (style.boxShadow && style.boxShadow !== 'none') ||
1566 /\bshadow(?:-sm|-md|-lg|-xl|-2xl)?\b/.test(cls) || /box-shadow/i.test(rawStyle);
1567 const hasBorder = /\bborder\b/.test(cls);
1568 const hasRadius = (parseFloat(style.borderRadius) || 0) > 0 ||
1569 /\brounded(?:-sm|-md|-lg|-xl|-2xl|-full)?\b/.test(cls) || /border-radius/i.test(rawStyle);
1570 const hasBg = /\bbg-(?:white|gray-\d+|slate-\d+)\b/.test(cls) ||
1571 /background(?:-color)?\s*:\s*(?!transparent)/i.test(rawStyle);
1572
1573 return isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg);
1574}
1575
1576function checkPageLayout(doc, win) {
1577 const findings = [];
1578
1579 // Nested cards
1580 const allEls = doc.querySelectorAll('*');
1581 const flaggedEls = new Set();
1582 for (const el of allEls) {
1583 if (!isCardLike(el, win)) continue;
1584 if (flaggedEls.has(el)) continue;
1585
1586 const tag = el.tagName.toLowerCase();
1587 const cls = el.getAttribute?.('class') || '';
1588 const rawStyle = el.getAttribute?.('style') || '';
1589
1590 if (['pre', 'code'].includes(tag)) continue;
1591 if (/\b(?:absolute|fixed)\b/.test(cls) || /position\s*:\s*(?:absolute|fixed)/i.test(rawStyle)) continue;
1592 if ((el.textContent?.trim().length || 0) < 10) continue;
1593 if (/\b(?:dropdown|popover|tooltip|menu|modal|dialog)\b/i.test(cls)) continue;
1594
1595 // Walk up to find card-like ancestor
1596 let parent = el.parentElement;
1597 while (parent) {
1598 if (isCardLike(parent, win)) {
1599 flaggedEls.add(el);
1600 break;
1601 }
1602 parent = parent.parentElement;
1603 }
1604 }
1605
1606 // Only report innermost nested cards
1607 for (const el of flaggedEls) {
1608 let isAncestorOfFlagged = false;
1609 for (const other of flaggedEls) {
1610 if (other !== el && el.contains(other)) {
1611 isAncestorOfFlagged = true;
1612 break;
1613 }
1614 }
1615 if (!isAncestorOfFlagged) {
1616 findings.push({ id: 'nested-cards', snippet: `Card inside card (${el.tagName.toLowerCase()})` });
1617 }
1618 }
1619
1620 // Everything centered
1621 const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, li, div, button');
1622 let centeredCount = 0;
1623 let totalText = 0;
1624 for (const el of textEls) {
1625 const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length >= 3);
1626 if (!hasDirectText) continue;
1627 totalText++;
1628
1629 let cur = el;
1630 let isCentered = false;
1631 while (cur && cur.nodeType === 1) {
1632 const rawStyle = cur.getAttribute?.('style') || '';
1633 const cls = cur.getAttribute?.('class') || '';
1634 if (/text-align\s*:\s*center/i.test(rawStyle) || /\btext-center\b/.test(cls)) {
1635 isCentered = true;
1636 break;
1637 }
1638 if (cur.tagName === 'BODY') break;
1639 cur = cur.parentElement;
1640 }
1641 if (isCentered) centeredCount++;
1642 }
1643
1644 if (totalText >= 5 && centeredCount / totalText > 0.7) {
1645 findings.push({
1646 id: 'everything-centered',
1647 snippet: `${centeredCount}/${totalText} text elements centered (${Math.round(centeredCount / totalText * 100)}%)`,
1648 });
1649 }
1650
1651 return findings;
1652}
1653
1654// ─── Section 7: Browser UI (IS_BROWSER only) ────────────────────────────────
1655
1656if (IS_BROWSER) {
1657 // Detect extension mode via the script tag's data attribute or the document element fallback.
1658 // currentScript is reliable for synchronously-executing scripts (which our IIFE is).
1659 const _myScript = document.currentScript;
1660 const EXTENSION_MODE = (_myScript && _myScript.dataset.impeccableExtension === 'true')
1661 || document.documentElement.dataset.impeccableExtension === 'true';
1662
1663 const BRAND_COLOR = 'oklch(55% 0.25 350)';
1664 const BRAND_COLOR_HOVER = 'oklch(45% 0.25 350)';
1665 const LABEL_BG = BRAND_COLOR;
1666 const OUTLINE_COLOR = BRAND_COLOR;
1667
1668 // Inject hover styles via CSS (more reliable than JS event listeners)
1669 const styleEl = document.createElement('style');
1670 styleEl.textContent = `
1671 @keyframes impeccable-reveal {
1672 from { opacity: 0; }
1673 to { opacity: 1; }
1674 }
1675 .impeccable-overlay:not(.impeccable-banner) {
1676 pointer-events: none;
1677 outline: 2px solid ${OUTLINE_COLOR};
1678 border-radius: 4px;
1679 transition: outline-color 0.15s ease;
1680 animation: impeccable-reveal 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
1681 animation-play-state: paused;
1682 border-top-left-radius: 0;
1683 }
1684 .impeccable-overlay.impeccable-visible {
1685 animation-play-state: running;
1686 }
1687 .impeccable-overlay.impeccable-hover {
1688 outline-color: ${BRAND_COLOR_HOVER};
1689 z-index: 100001 !important;
1690 }
1691 .impeccable-overlay.impeccable-hover .impeccable-label {
1692 background: ${BRAND_COLOR_HOVER};
1693 }
1694 .impeccable-overlay.impeccable-spotlight {
1695 z-index: 100002 !important;
1696 }
1697 .impeccable-overlay.impeccable-spotlight-dimmed {
1698 opacity: 0.15 !important;
1699 animation: none !important;
1700 filter: blur(3px);
1701 }
1702 .impeccable-spotlight-backdrop {
1703 position: fixed;
1704 top: 0; left: 0; right: 0; bottom: 0;
1705 backdrop-filter: blur(3px) brightness(0.6);
1706 -webkit-backdrop-filter: blur(3px) brightness(0.6);
1707 pointer-events: none;
1708 z-index: 99998;
1709 opacity: 0;
1710 outline: none !important;
1711 animation: none !important;
1712 }
1713 .impeccable-spotlight-backdrop.impeccable-visible {
1714 opacity: 1;
1715 }
1716 .impeccable-hidden .impeccable-overlay${EXTENSION_MODE ? '' : ':not(.impeccable-banner)'} {
1717 display: none !important;
1718 }
1719 .impeccable-hidden .impeccable-overlay${EXTENSION_MODE ? '' : ':not(.impeccable-banner)'} {
1720 display: none !important;
1721 }
1722 `;
1723 (document.head || document.documentElement).appendChild(styleEl);
1724
1725 // Spotlight backdrop element (created lazily on first use)
1726 let spotlightBackdrop = null;
1727 let spotlightTarget = null;
1728 let spotlightTimer = null;
1729
1730 function getSpotlightBackdrop() {
1731 if (!spotlightBackdrop) {
1732 spotlightBackdrop = document.createElement('div');
1733 spotlightBackdrop.className = 'impeccable-spotlight-backdrop';
1734 document.body.appendChild(spotlightBackdrop);
1735 }
1736 return spotlightBackdrop;
1737 }
1738
1739 function updateSpotlightClipPath() {
1740 if (!spotlightBackdrop || !spotlightTarget) return;
1741 const r = spotlightTarget.getBoundingClientRect();
1742 // Match the overlay's outer edge: element rect + 4px (2px overlay offset + 2px outline width)
1743 const inset = 4;
1744 const radius = 6; // outline border-radius (4) + outline width (2)
1745 const x1 = r.left - inset;
1746 const y1 = r.top - inset;
1747 const x2 = r.right + inset;
1748 const y2 = r.bottom + inset;
1749 const vw = window.innerWidth;
1750 const vh = window.innerHeight;
1751 // Outer rect + rounded inner rect (evenodd creates a hole)
1752 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`;
1753 spotlightBackdrop.style.clipPath = `path(evenodd, "${path}")`;
1754 }
1755
1756 function showSpotlight(target) {
1757 if (!target || !target.getBoundingClientRect) return;
1758 // Respect the spotlightBlur setting: if disabled, don't show the backdrop
1759 if (window.__IMPECCABLE_CONFIG__?.spotlightBlur === false) {
1760 spotlightTarget = target;
1761 return;
1762 }
1763 spotlightTarget = target;
1764 const bd = getSpotlightBackdrop();
1765 updateSpotlightClipPath();
1766 bd.classList.add('impeccable-visible');
1767 }
1768
1769 function hideSpotlight() {
1770 spotlightTarget = null;
1771 if (spotlightTimer) { clearTimeout(spotlightTimer); spotlightTimer = null; }
1772 if (spotlightBackdrop) spotlightBackdrop.classList.remove('impeccable-visible');
1773 }
1774
1775 function isInViewport(el) {
1776 const r = el.getBoundingClientRect();
1777 return r.top >= 0 && r.left >= 0 && r.bottom <= window.innerHeight && r.right <= window.innerWidth;
1778 }
1779
1780 // Reposition spotlight on scroll/resize
1781 window.addEventListener('scroll', () => {
1782 if (spotlightTarget) updateSpotlightClipPath();
1783 }, { passive: true });
1784 window.addEventListener('resize', () => {
1785 if (spotlightTarget) updateSpotlightClipPath();
1786 });
1787
1788 const overlays = [];
1789 const TYPE_LABELS = {};
1790 const RULE_CATEGORY = {};
1791 for (const ap of ANTIPATTERNS) {
1792 TYPE_LABELS[ap.id] = ap.name.toLowerCase();
1793 RULE_CATEGORY[ap.id] = ap.category || 'quality';
1794 }
1795
1796 function isInFixedContext(el) {
1797 let p = el;
1798 while (p && p !== document.body) {
1799 if (getComputedStyle(p).position === 'fixed') return true;
1800 p = p.parentElement;
1801 }
1802 return false;
1803 }
1804
1805 function positionOverlay(overlay) {
1806 const el = overlay._targetEl;
1807 if (!el) return;
1808 const rect = el.getBoundingClientRect();
1809 if (overlay._isFixed) {
1810 // Viewport-relative coords for fixed targets
1811 overlay.style.top = `${rect.top - 2}px`;
1812 overlay.style.left = `${rect.left - 2}px`;
1813 } else {
1814 // Document-relative coords for normal targets
1815 overlay.style.top = `${rect.top + scrollY - 2}px`;
1816 overlay.style.left = `${rect.left + scrollX - 2}px`;
1817 }
1818 overlay.style.width = `${rect.width + 4}px`;
1819 overlay.style.height = `${rect.height + 4}px`;
1820 }
1821
1822 function repositionOverlays() {
1823 for (const o of overlays) {
1824 if (!o._targetEl || o.classList.contains('impeccable-banner')) continue;
1825 // Skip overlays whose target is currently hidden (display: none on the overlay)
1826 if (o.style.display === 'none') continue;
1827 positionOverlay(o);
1828 }
1829 }
1830
1831 let resizeRAF;
1832 const onResize = () => {
1833 cancelAnimationFrame(resizeRAF);
1834 resizeRAF = requestAnimationFrame(repositionOverlays);
1835 };
1836 window.addEventListener('resize', onResize);
1837 // Reposition on scroll too -- catches sticky/parallax shifts
1838 window.addEventListener('scroll', onResize, { passive: true });
1839 // Reposition when body resizes (lazy-loaded images, dynamic content, fonts loading)
1840 if (typeof ResizeObserver !== 'undefined') {
1841 const bodyResizeObserver = new ResizeObserver(onResize);
1842 bodyResizeObserver.observe(document.body);
1843 }
1844
1845 // Track target element visibility via IntersectionObserver.
1846 // Uses a huge rootMargin so all *rendered* elements count as intersecting,
1847 // while display:none / closed <details> / hidden modals etc. do not.
1848 // This is event-driven -- no polling needed.
1849 let overlayIndex = 0;
1850 const visibilityObserver = new IntersectionObserver((entries) => {
1851 for (const entry of entries) {
1852 const overlay = entry.target._impeccableOverlay;
1853 if (!overlay) continue;
1854 if (entry.isIntersecting) {
1855 overlay.style.display = '';
1856 positionOverlay(overlay);
1857 if (!overlay._revealed) {
1858 overlay._revealed = true;
1859 if (firstScanDone) {
1860 // Subsequent reveals (re-scans, scroll-into-view): instant, no animation
1861 overlay.style.animation = 'none';
1862 } else {
1863 // Initial scan: staggered cascade reveal
1864 overlay.style.animationDelay = `${Math.min((overlay._staggerIndex || 0) * 60, 600)}ms`;
1865 }
1866 requestAnimationFrame(() => {
1867 overlay.classList.add('impeccable-visible');
1868 if (overlay._checkLabel) overlay._checkLabel();
1869 });
1870 }
1871 } else {
1872 overlay.style.display = 'none';
1873 }
1874 }
1875 }, { rootMargin: '99999px' });
1876
1877 // Reposition overlays after CSS transitions end (e.g. reveal animations).
1878 // Listens at document level so it catches transitions on ancestor elements
1879 // (the transform may be on a parent, not the flagged element itself).
1880 document.addEventListener('transitionend', (e) => {
1881 if (e.propertyName !== 'transform') return;
1882 for (const o of overlays) {
1883 if (!o._targetEl || o.classList.contains('impeccable-banner') || o.style.display === 'none') continue;
1884 if (e.target === o._targetEl || e.target.contains(o._targetEl)) {
1885 positionOverlay(o);
1886 }
1887 }
1888 });
1889
1890 const highlight = function(el, findings) {
1891 const hasSlop = findings.some(f => RULE_CATEGORY[f.type || f.id] === 'slop');
1892
1893 const fixed = isInFixedContext(el);
1894 const rect = el.getBoundingClientRect();
1895 const outline = document.createElement('div');
1896 outline.className = 'impeccable-overlay';
1897 outline._targetEl = el;
1898 outline._isFixed = fixed;
1899 Object.assign(outline.style, {
1900 position: fixed ? 'fixed' : 'absolute',
1901 top: fixed ? `${rect.top - 2}px` : `${rect.top + scrollY - 2}px`,
1902 left: fixed ? `${rect.left - 2}px` : `${rect.left + scrollX - 2}px`,
1903 width: `${rect.width + 4}px`, height: `${rect.height + 4}px`,
1904 zIndex: '99999', boxSizing: 'border-box',
1905 });
1906
1907 // Build per-finding label entries: ✦ prefix for slop
1908 const entries = findings.map(f => {
1909 const name = TYPE_LABELS[f.type || f.id] || f.type || f.id;
1910 const prefix = RULE_CATEGORY[f.type || f.id] === 'slop' ? '\u2726 ' : '';
1911 return { name: prefix + name, detail: f.detail || f.snippet };
1912 });
1913 const allText = entries.map(e => e.name).join(', ');
1914
1915 const label = document.createElement('div');
1916 label.className = 'impeccable-label';
1917 Object.assign(label.style, {
1918 position: 'absolute', bottom: '100%', left: '-2px',
1919 display: 'flex', alignItems: 'center',
1920 whiteSpace: 'nowrap',
1921 fontSize: '11px', fontWeight: '600', letterSpacing: '0.02em',
1922 color: 'white', lineHeight: '14px',
1923 background: LABEL_BG,
1924 fontFamily: 'system-ui, sans-serif',
1925 borderRadius: '4px 4px 0 0',
1926 });
1927
1928 const textSpan = document.createElement('span');
1929 textSpan.style.padding = '3px 8px';
1930 textSpan.textContent = allText;
1931 label.appendChild(textSpan);
1932
1933 // State for cycling mode
1934 let cycleMode = false;
1935 let cycleIndex = 0;
1936 let isHovered = false;
1937 let prevBtn, nextBtn;
1938
1939 function updateCycleText() {
1940 const e = entries[cycleIndex];
1941 textSpan.textContent = isHovered ? e.detail : e.name;
1942 }
1943
1944 function enableCycleMode() {
1945 if (cycleMode || entries.length < 2) return;
1946 cycleMode = true;
1947
1948 const btnStyle = {
1949 background: 'none', border: 'none', color: 'rgba(255,255,255,0.7)',
1950 fontSize: '11px', cursor: 'pointer', padding: '3px 4px',
1951 fontFamily: 'system-ui, sans-serif', lineHeight: '14px',
1952 pointerEvents: 'auto',
1953 };
1954
1955 const navGroup = document.createElement('span');
1956 Object.assign(navGroup.style, {
1957 display: 'inline-flex', alignItems: 'center', flexShrink: '0',
1958 });
1959
1960 prevBtn = document.createElement('button');
1961 prevBtn.textContent = '\u2039';
1962 Object.assign(prevBtn.style, btnStyle);
1963 prevBtn.style.paddingLeft = '6px';
1964 prevBtn.addEventListener('click', (e) => {
1965 e.stopPropagation();
1966 cycleIndex = (cycleIndex - 1 + entries.length) % entries.length;
1967 updateCycleText();
1968 });
1969
1970 nextBtn = document.createElement('button');
1971 nextBtn.textContent = '\u203A';
1972 Object.assign(nextBtn.style, btnStyle);
1973 nextBtn.style.paddingRight = '2px';
1974 nextBtn.addEventListener('click', (e) => {
1975 e.stopPropagation();
1976 cycleIndex = (cycleIndex + 1) % entries.length;
1977 updateCycleText();
1978 });
1979
1980 navGroup.appendChild(prevBtn);
1981 navGroup.appendChild(nextBtn);
1982 label.insertBefore(navGroup, textSpan);
1983 textSpan.style.padding = '3px 8px 3px 4px';
1984 updateCycleText();
1985 }
1986
1987 outline.appendChild(label);
1988
1989 // Start hidden; the IntersectionObserver will show it once the target is rendered
1990 outline.style.display = 'none';
1991 outline._staggerIndex = overlayIndex++;
1992 el._impeccableOverlay = outline;
1993 visibilityObserver.observe(el);
1994
1995 // After first paint, check label width vs outline
1996 outline._checkLabel = () => {
1997 if (entries.length > 1 && label.offsetWidth > outline.offsetWidth) {
1998 enableCycleMode();
1999 }
2000 };
2001
2002 // Hover: show detail text, darken
2003 el.addEventListener('mouseenter', () => {
2004 isHovered = true;
2005 outline.classList.add('impeccable-hover');
2006 outline.style.outlineColor = BRAND_COLOR_HOVER;
2007 label.style.background = BRAND_COLOR_HOVER;
2008 if (cycleMode) {
2009 updateCycleText();
2010 } else {
2011 textSpan.textContent = entries.map(e => e.detail).join(' | ');
2012 }
2013 });
2014 el.addEventListener('mouseleave', () => {
2015 isHovered = false;
2016 outline.classList.remove('impeccable-hover');
2017 outline.style.outlineColor = '';
2018 label.style.background = LABEL_BG;
2019 if (cycleMode) {
2020 updateCycleText();
2021 } else {
2022 textSpan.textContent = allText;
2023 }
2024 });
2025
2026 document.body.appendChild(outline);
2027 overlays.push(outline);
2028 };
2029
2030 const showPageBanner = function(findings) {
2031 if (!findings.length) return;
2032 const banner = document.createElement('div');
2033 banner.className = 'impeccable-overlay impeccable-banner';
2034 Object.assign(banner.style, {
2035 position: 'fixed', top: '0', left: '0', right: '0', zIndex: '100000',
2036 background: LABEL_BG, color: 'white',
2037 fontFamily: 'system-ui, sans-serif', fontSize: '13px',
2038 display: 'flex', alignItems: 'center', pointerEvents: 'auto',
2039 height: '36px', overflow: 'hidden', maxWidth: '100vw',
2040 transform: 'translateY(-100%)',
2041 transition: 'transform 0.4s cubic-bezier(0.16, 1, 0.3, 1)',
2042 });
2043 requestAnimationFrame(() => requestAnimationFrame(() => {
2044 banner.style.transform = 'translateY(0)';
2045 }));
2046
2047 // Scrollable findings area
2048 const scrollArea = document.createElement('div');
2049 Object.assign(scrollArea.style, {
2050 flex: '1', minWidth: '0', overflowX: 'auto', overflowY: 'hidden',
2051 display: 'flex', gap: '8px', alignItems: 'center',
2052 padding: '0 12px', scrollSnapType: 'x mandatory',
2053 scrollbarWidth: 'none',
2054 });
2055 for (const f of findings) {
2056 const prefix = RULE_CATEGORY[f.type] === 'slop' ? '\u2726 ' : '';
2057 const tag = document.createElement('span');
2058 tag.textContent = `${prefix}${TYPE_LABELS[f.type] || f.type}: ${f.detail}`;
2059 Object.assign(tag.style, {
2060 background: 'rgba(255,255,255,0.15)', padding: '2px 8px',
2061 borderRadius: '3px', fontSize: '12px', fontFamily: 'ui-monospace, monospace',
2062 whiteSpace: 'nowrap', flexShrink: '0', scrollSnapAlign: 'start',
2063 });
2064 scrollArea.appendChild(tag);
2065 }
2066 banner.appendChild(scrollArea);
2067
2068 // Controls area (only in standalone mode, not extension)
2069 if (!EXTENSION_MODE) {
2070 const controls = document.createElement('div');
2071 Object.assign(controls.style, {
2072 display: 'flex', alignItems: 'center', gap: '2px',
2073 padding: '0 8px', flexShrink: '0',
2074 });
2075
2076 // Toggle visibility button
2077 const toggle = document.createElement('button');
2078 toggle.textContent = '\u25C9'; // circle with dot (visible state)
2079 toggle.title = 'Toggle overlay visibility';
2080 Object.assign(toggle.style, {
2081 background: 'none', border: 'none',
2082 color: 'white', fontSize: '16px', cursor: 'pointer', padding: '0 4px',
2083 opacity: '0.85', transition: 'opacity 0.15s',
2084 });
2085 let overlaysVisible = true;
2086 toggle.addEventListener('click', () => {
2087 overlaysVisible = !overlaysVisible;
2088 document.body.classList.toggle('impeccable-hidden', !overlaysVisible);
2089 toggle.textContent = overlaysVisible ? '\u25C9' : '\u25CB'; // filled vs empty circle
2090 toggle.style.opacity = overlaysVisible ? '0.85' : '0.5';
2091 });
2092 controls.appendChild(toggle);
2093
2094 // Close button
2095 const close = document.createElement('button');
2096 close.textContent = '\u00d7';
2097 close.title = 'Dismiss banner';
2098 Object.assign(close.style, {
2099 background: 'none', border: 'none',
2100 color: 'white', fontSize: '18px', cursor: 'pointer', padding: '0 4px',
2101 });
2102 close.addEventListener('click', () => banner.remove());
2103 controls.appendChild(close);
2104
2105 banner.appendChild(controls);
2106 }
2107 document.body.appendChild(banner);
2108 overlays.push(banner);
2109 };
2110
2111 // Heuristic for skipping CSS-in-JS hashed class names like "css-1a2b3c" or "_2x4hG_".
2112 // These change between builds and produce brittle, ugly selectors.
2113 function isLikelyHashedClass(c) {
2114 if (!c) return true;
2115 if (/^(css|sc|emotion|jsx|module)-[\w-]{4,}$/i.test(c)) return true;
2116 if (/^_[\w-]{5,}$/.test(c)) return true;
2117 if (/^[a-z0-9]{6,}$/i.test(c) && /\d/.test(c)) return true;
2118 return false;
2119 }
2120
2121 function buildSelectorSegment(el) {
2122 const tag = el.tagName.toLowerCase();
2123 let sel = tag;
2124
2125 if (el.classList && el.classList.length > 0) {
2126 const classes = [...el.classList]
2127 .filter(c => !c.startsWith('impeccable-') && !isLikelyHashedClass(c))
2128 .slice(0, 2);
2129 if (classes.length > 0) {
2130 sel += '.' + classes.map(c => CSS.escape(c)).join('.');
2131 }
2132 }
2133
2134 // Disambiguate among siblings only if the parent has multiple matches
2135 const parent = el.parentElement;
2136 if (parent) {
2137 try {
2138 const matching = parent.querySelectorAll(':scope > ' + sel);
2139 if (matching.length > 1) {
2140 const sameType = [...parent.children].filter(c => c.tagName === el.tagName);
2141 const idx = sameType.indexOf(el) + 1;
2142 sel += `:nth-of-type(${idx})`;
2143 }
2144 } catch {
2145 const idx = [...parent.children].indexOf(el) + 1;
2146 sel = `${tag}:nth-child(${idx})`;
2147 }
2148 }
2149 return sel;
2150 }
2151
2152 function generateSelector(el) {
2153 if (el === document.body) return 'body';
2154 if (el === document.documentElement) return 'html';
2155 if (el.id) return '#' + CSS.escape(el.id);
2156
2157 const parts = [];
2158 let current = el;
2159 let depth = 0;
2160 const MAX_DEPTH = 10;
2161
2162 while (current && current !== document.body && current !== document.documentElement && depth < MAX_DEPTH) {
2163 parts.unshift(buildSelectorSegment(current));
2164
2165 // Anchor on an ancestor's ID and stop walking up
2166 if (current.id) {
2167 parts[0] = '#' + CSS.escape(current.id);
2168 break;
2169 }
2170
2171 // Stop as soon as the partial selector uniquely identifies the target
2172 const trySelector = parts.join(' > ');
2173 try {
2174 const matches = document.querySelectorAll(trySelector);
2175 if (matches.length === 1 && matches[0] === el) {
2176 return trySelector;
2177 }
2178 } catch { /* invalid selector — keep walking */ }
2179
2180 current = current.parentElement;
2181 depth++;
2182 }
2183
2184 return parts.join(' > ');
2185 }
2186
2187 function isElementHidden(el) {
2188 if (!el || el === document.body || el === document.documentElement) return false;
2189 if (typeof el.checkVisibility === 'function') return !el.checkVisibility({ checkOpacity: false, checkVisibilityCSS: true });
2190 // Fallback: zero size or no offsetParent (covers display:none and detached subtrees)
2191 return el.offsetWidth === 0 && el.offsetHeight === 0;
2192 }
2193
2194 function serializeFindings(allFindings) {
2195 return allFindings.map(({ el, findings }) => ({
2196 selector: generateSelector(el),
2197 tagName: el.tagName?.toLowerCase() || 'unknown',
2198 rect: (el !== document.body && el !== document.documentElement && el.getBoundingClientRect)
2199 ? el.getBoundingClientRect().toJSON() : null,
2200 isPageLevel: el === document.body || el === document.documentElement,
2201 isHidden: isElementHidden(el),
2202 findings: findings.map(f => {
2203 const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id));
2204 return {
2205 type: f.type || f.id,
2206 category: ap ? ap.category : 'quality',
2207 detail: f.detail || f.snippet,
2208 name: ap ? ap.name : (f.type || f.id),
2209 description: ap ? ap.description : '',
2210 };
2211 }),
2212 }));
2213 }
2214
2215 const printSummary = function(allFindings) {
2216 if (allFindings.length === 0) {
2217 console.log('%c[impeccable] No anti-patterns found.', 'color: #22c55e; font-weight: bold');
2218 return;
2219 }
2220 console.group(
2221 `%c[impeccable] ${allFindings.length} anti-pattern${allFindings.length === 1 ? '' : 's'} found`,
2222 'color: oklch(60% 0.25 350); font-weight: bold'
2223 );
2224 for (const { el, findings } of allFindings) {
2225 for (const f of findings) {
2226 console.log(`%c${f.type || f.id}%c ${f.detail || f.snippet}`,
2227 'color: oklch(55% 0.25 350); font-weight: bold', 'color: inherit', el);
2228 }
2229 }
2230 console.groupEnd();
2231 };
2232
2233 let firstScanDone = false;
2234 const scan = function() {
2235 for (const o of overlays) o.remove();
2236 overlays.length = 0;
2237 visibilityObserver.disconnect();
2238 overlayIndex = 0;
2239 const allFindings = [];
2240 const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : [];
2241 const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id);
2242
2243 for (const el of document.querySelectorAll('*')) {
2244 // Skip impeccable's own elements and any descendants (overlays, labels, banner, nav buttons)
2245 if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue;
2246 // Skip browser extension elements (Claude, etc.)
2247 const elId = el.id || '';
2248 if (elId.startsWith('claude-') || elId.startsWith('cic-')) continue;
2249 // Skip html/body -- page-level findings go in the banner, not a full-page overlay
2250 if (el === document.body || el === document.documentElement) continue;
2251
2252 const findings = [
2253 ...checkElementBordersDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
2254 ...checkElementColorsDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
2255 ...checkElementMotionDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
2256 ...checkElementGlowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
2257 ...checkElementAIPaletteDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
2258 ...checkElementIconTileDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
2259 ...checkElementQualityDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
2260 ].filter(f => _ruleOk(f.type));
2261
2262 if (findings.length > 0) {
2263 highlight(el, findings);
2264 allFindings.push({ el, findings });
2265 }
2266 }
2267
2268 const pageLevelFindings = [];
2269
2270 const typoFindings = checkTypography().filter(f => _ruleOk(f.type));
2271 if (typoFindings.length > 0) {
2272 pageLevelFindings.push(...typoFindings);
2273 allFindings.push({ el: document.body, findings: typoFindings });
2274 }
2275
2276 const layoutFindings = checkLayout().filter(f => _ruleOk(f.type));
2277 for (const f of layoutFindings) {
2278 const el = f.el || document.body;
2279 delete f.el;
2280 // Merge into existing overlay if this element already has one
2281 const existing = el._impeccableOverlay;
2282 if (existing) {
2283 const nameRow = existing.querySelector('.impeccable-label-name');
2284 const detailRow = existing.querySelector('.impeccable-label-detail');
2285 const newType = TYPE_LABELS[f.type] || f.type;
2286 if (nameRow) nameRow.textContent += ', ' + newType;
2287 if (detailRow) detailRow.textContent += ' | ' + (f.detail || '');
2288 } else {
2289 highlight(el, [f]);
2290 }
2291 allFindings.push({ el, findings: [f] });
2292 }
2293
2294 // Page-level quality checks (headings, etc.)
2295 const qualityFindings = checkPageQualityDOM().filter(f => _ruleOk(f.type));
2296 if (qualityFindings.length > 0) {
2297 pageLevelFindings.push(...qualityFindings);
2298 allFindings.push({ el: document.body, findings: qualityFindings });
2299 }
2300
2301 // Regex-on-HTML checks (shared with Node)
2302 const htmlPatternFindings = checkHtmlPatterns(document.documentElement.outerHTML);
2303 if (htmlPatternFindings.length > 0) {
2304 const mapped = htmlPatternFindings.map(f => ({ type: f.id, detail: f.snippet })).filter(f => _ruleOk(f.type));
2305 pageLevelFindings.push(...mapped);
2306 allFindings.push({ el: document.body, findings: mapped });
2307 }
2308
2309 if (pageLevelFindings.length > 0) {
2310 showPageBanner(pageLevelFindings);
2311 }
2312
2313 if (!EXTENSION_MODE) printSummary(allFindings);
2314
2315 // In extension mode, post serialized results for the DevTools panel
2316 if (EXTENSION_MODE) {
2317 window.postMessage({
2318 source: 'impeccable-results',
2319 findings: serializeFindings(allFindings),
2320 count: allFindings.length,
2321 }, '*');
2322 }
2323
2324 // After this scan completes, all subsequent reveals are instant (no stagger, no animation)
2325 setTimeout(() => { firstScanDone = true; }, 1000);
2326
2327 return allFindings;
2328 };
2329
2330 if (EXTENSION_MODE) {
2331 // Extension mode: listen for commands, don't auto-scan
2332 window.addEventListener('message', (e) => {
2333 if (e.source !== window || !e.data || e.data.source !== 'impeccable-command') return;
2334 if (e.data.action === 'scan') {
2335 if (e.data.config) window.__IMPECCABLE_CONFIG__ = e.data.config;
2336 scan();
2337 }
2338 if (e.data.action === 'toggle-overlays') {
2339 const visible = !document.body.classList.contains('impeccable-hidden');
2340 document.body.classList.toggle('impeccable-hidden', visible);
2341 window.postMessage({ source: 'impeccable-overlays-toggled', visible: !visible }, '*');
2342 }
2343 if (e.data.action === 'remove') {
2344 for (const o of overlays) o.remove();
2345 overlays.length = 0;
2346 visibilityObserver.disconnect();
2347 styleEl.remove();
2348 if (spotlightBackdrop) { spotlightBackdrop.remove(); spotlightBackdrop = null; }
2349 document.body.classList.remove('impeccable-hidden');
2350 }
2351 if (e.data.action === 'highlight') {
2352 if (spotlightTimer) { clearTimeout(spotlightTimer); spotlightTimer = null; }
2353 try {
2354 const target = e.data.selector ? document.querySelector(e.data.selector) : null;
2355 if (target) {
2356 // Scroll first so positionOverlay reads the post-scroll rect
2357 if (!isInViewport(target) && target.scrollIntoView) {
2358 target.scrollIntoView({ behavior: 'instant', block: 'center' });
2359 }
2360 for (const o of overlays) {
2361 if (o.classList.contains('impeccable-banner')) continue;
2362 const isMatch = o._targetEl === target;
2363 o.classList.toggle('impeccable-spotlight', isMatch);
2364 o.classList.toggle('impeccable-spotlight-dimmed', !isMatch);
2365 if (isMatch) {
2366 // Force the matching overlay visible immediately, don't wait for IntersectionObserver
2367 o.style.display = '';
2368 o.style.animation = 'none';
2369 o.classList.add('impeccable-visible');
2370 o._revealed = true;
2371 positionOverlay(o);
2372 }
2373 }
2374 showSpotlight(target);
2375 }
2376 } catch { /* invalid selector */ }
2377 }
2378 if (e.data.action === 'unhighlight') {
2379 hideSpotlight();
2380 for (const o of overlays) {
2381 o.classList.remove('impeccable-spotlight');
2382 o.classList.remove('impeccable-spotlight-dimmed');
2383 }
2384 }
2385 });
2386 window.postMessage({ source: 'impeccable-ready' }, '*');
2387 } else {
2388 if (document.readyState === 'loading') {
2389 document.addEventListener('DOMContentLoaded', () => setTimeout(scan, 100));
2390 } else {
2391 setTimeout(scan, 100);
2392 }
2393 }
2394
2395 window.impeccableScan = scan;
2396}
2397
2398// ─── Section 8: Node Engine ─────────────────────────────────────────────────
2399
2400// ─── Section 9: Exports ─────────────────────────────────────────────────────
2401
2402})();