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: cli/engine/browser/injected/index.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// --- cli/engine/shared/constants.mjs ---
15// ─── Section 1: Constants ───────────────────────────────────────────────────
16
17const SAFE_TAGS = new Set([
18 'blockquote', 'nav', 'a', 'input', 'textarea', 'select',
19 'pre', 'code', 'span', 'th', 'td', 'tr', 'li', 'label',
20 'button', 'hr', 'html', 'head', 'body', 'script', 'style',
21 'link', 'meta', 'title', 'br', 'img', 'svg', 'path', 'circle',
22 'rect', 'line', 'polyline', 'polygon', 'g', 'defs', 'use',
23]);
24
25// Per-check safe-tags override for the border (side-tab / border-accent)
26// rule. We intentionally re-allow <label> here because card-shaped clickable
27// labels (e.g. .checklist-item wrapping a checkbox + content) are one of the
28// canonical side-tab anti-pattern shapes and must be detected. The rule's
29// other preconditions (non-neutral color, width >= 2px on a single side,
30// radius > 0 or width >= 3, element size >= 20x20 in the browser path)
31// already filter out plain inline form labels so this does not introduce
32// false positives. See modern-color-borders.html for the test matrix.
33const BORDER_SAFE_TAGS = new Set(
34 [...SAFE_TAGS].filter(t => t !== 'label')
35);
36
37const OVERUSED_FONTS = new Set([
38 // Older monoculture (still ubiquitous):
39 'inter', 'roboto', 'open sans', 'lato', 'montserrat', 'arial', 'helvetica',
40 // Newer monoculture (the Anthropic-skill / Vercel / GitHub default wave):
41 'fraunces', 'instrument sans',
42 'geist', 'geist sans', 'geist mono',
43 'mona sans',
44 'plus jakarta sans', 'space grotesk', 'recoleta',
45]);
46
47// Brand-associated fonts: don't flag these as "overused" on the brand's own domains.
48// Keys are font names, values are arrays of hostname suffixes where the font is allowed.
49const GOOGLE_DOMAINS = [
50 'google.com', 'youtube.com', 'android.com', 'chromium.org',
51 'chrome.com', 'web.dev', 'gstatic.com', 'firebase.google.com',
52];
53const VERCEL_DOMAINS = ['vercel.com', 'nextjs.org', 'v0.app'];
54const GITHUB_DOMAINS = ['github.com', 'githubnext.com'];
55const BRAND_FONT_DOMAINS = {
56 'roboto': GOOGLE_DOMAINS,
57 'google sans': GOOGLE_DOMAINS,
58 'product sans': GOOGLE_DOMAINS,
59 'geist': VERCEL_DOMAINS,
60 'geist sans': VERCEL_DOMAINS,
61 'geist mono': VERCEL_DOMAINS,
62 'mona sans': GITHUB_DOMAINS,
63};
64
65function isBrandFontOnOwnDomain(font) {
66 if (typeof location === 'undefined') return false;
67 const allowed = BRAND_FONT_DOMAINS[font];
68 if (!allowed) return false;
69 const host = location.hostname.toLowerCase();
70 return allowed.some(suffix => host === suffix || host.endsWith('.' + suffix));
71}
72
73const GENERIC_FONTS = new Set([
74 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy',
75 'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
76 '-apple-system', 'blinkmacsystemfont', 'segoe ui',
77 'inherit', 'initial', 'unset', 'revert',
78]);
79
80// WCAG large text thresholds are defined in points: 18pt normal text and
81// 14pt bold text. Browsers expose font-size in CSS pixels at 96px per inch.
82const WCAG_LARGE_TEXT_PX = 18 * (96 / 72);
83const WCAG_LARGE_BOLD_TEXT_PX = 14 * (96 / 72);
84
85// Serif faces that show up in italic-display heroes. The rule also fires when
86// the primary face is unknown but the stack ends in the generic `serif` token,
87// which catches custom/private faces with a serif fallback.
88const KNOWN_SERIF_FONTS = new Set([
89 'fraunces', 'recoleta', 'newsreader', 'playfair display', 'playfair',
90 'cormorant', 'cormorant garamond', 'garamond', 'eb garamond',
91 'tiempos', 'tiempos headline', 'tiempos text',
92 'lora', 'vollkorn', 'spectral',
93 'source serif pro', 'source serif 4', 'source serif',
94 'ibm plex serif', 'merriweather',
95 'libre caslon', 'libre baskerville', 'baskerville',
96 'georgia', 'times new roman', 'times',
97 'dm serif display', 'dm serif text',
98 'instrument serif', 'gt sectra', 'ogg', 'canela',
99 'freight display', 'freight text',
100]);
101
102// --- cli/engine/registry/antipatterns.mjs ---
103const ANTIPATTERNS = [
104 // ── AI slop: tells that something was AI-generated ──
105 {
106 id: 'side-tab',
107 category: 'slop',
108 name: 'Side-tab accent border',
109 description:
110 '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.',
111 skillSection: 'Visual Details',
112 skillGuideline: 'colored accent stripe',
113 },
114 {
115 id: 'border-accent-on-rounded',
116 category: 'slop',
117 name: 'Border accent on rounded element',
118 description:
119 'Thick accent border on a rounded card — the border clashes with the rounded corners. Remove the border or the border-radius.',
120 skillSection: 'Visual Details',
121 skillGuideline: 'colored accent stripe',
122 },
123 {
124 id: 'overused-font',
125 category: 'slop',
126 name: 'Overused font',
127 description:
128 'Inter, Roboto, Fraunces, Geist, Plus Jakarta Sans, and Space Grotesk are used on so many sites they no longer feel distinctive. Each new wave of AI-generated UIs converges on the same handful of faces. Choose a face that gives your interface personality.',
129 skillSection: 'Typography',
130 skillGuideline: 'overused fonts like Inter',
131 },
132 {
133 id: 'single-font',
134 category: 'slop',
135 name: 'Single font for everything',
136 description:
137 'Only one font family is used for the entire page. Pair a distinctive display font with a refined body font to create typographic hierarchy.',
138 skillSection: 'Typography',
139 skillGuideline: 'only one font family for the entire page',
140 },
141 {
142 id: 'flat-type-hierarchy',
143 category: 'slop',
144 name: 'Flat type hierarchy',
145 description:
146 '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).',
147 skillSection: 'Typography',
148 skillGuideline: 'flat type hierarchy',
149 },
150 {
151 id: 'gradient-text',
152 category: 'slop',
153 name: 'Gradient text',
154 description:
155 'Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.',
156 skillSection: 'Color & Contrast',
157 skillGuideline: 'gradient text for',
158 },
159 {
160 id: 'ai-color-palette',
161 category: 'slop',
162 name: 'AI color palette',
163 description:
164 'Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.',
165 skillSection: 'Color & Contrast',
166 skillGuideline: 'AI color palette',
167 },
168 {
169 id: 'nested-cards',
170 category: 'slop',
171 name: 'Nested cards',
172 description:
173 'Cards inside cards create visual noise and excessive depth. Flatten the hierarchy — use spacing, typography, and dividers instead of nesting containers.',
174 skillSection: 'Layout & Space',
175 skillGuideline: 'Nest cards inside cards',
176 },
177 {
178 id: 'monotonous-spacing',
179 category: 'slop',
180 name: 'Monotonous spacing',
181 description:
182 'The same spacing value used everywhere — no rhythm, no variation. Use tight groupings for related items and generous separations between sections.',
183 skillSection: 'Layout & Space',
184 skillGuideline: 'same spacing everywhere',
185 },
186 {
187 id: 'everything-centered',
188 category: 'slop',
189 name: 'Everything centered',
190 description:
191 'Every text element is center-aligned. Left-aligned text with asymmetric layouts feels more designed. Center only hero sections and CTAs.',
192 skillSection: 'Layout & Space',
193 skillGuideline: 'Center everything',
194 },
195 {
196 id: 'bounce-easing',
197 category: 'slop',
198 name: 'Bounce or elastic easing',
199 description:
200 'Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.',
201 skillSection: 'Motion',
202 skillGuideline: 'bounce or elastic easing',
203 },
204 {
205 id: 'dark-glow',
206 category: 'slop',
207 name: 'Dark mode with glowing accents',
208 description:
209 '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.',
210 skillSection: 'Color & Contrast',
211 skillGuideline: 'dark mode with glowing accents',
212 },
213 {
214 id: 'icon-tile-stack',
215 category: 'slop',
216 name: 'Icon tile stacked above heading',
217 description:
218 '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.',
219 skillSection: 'Typography',
220 skillGuideline: 'large icons with rounded corners above every heading',
221 },
222 {
223 id: 'italic-serif-display',
224 category: 'slop',
225 name: 'Italic serif display headline',
226 description:
227 'Oversized italic serif (Fraunces, Recoleta, Playfair, Newsreader-italic) as the primary hero headline reads as taste in isolation but has become the universal AI-startup landing page hero. Set roman, or move to a non-serif display face. Editorial / magazine register may legitimately want this — judge by context.',
228 skillSection: 'Typography',
229 skillGuideline: 'oversized italic serif as the hero headline',
230 },
231 {
232 id: 'hero-eyebrow-chip',
233 category: 'slop',
234 name: 'Hero eyebrow / pill chip',
235 description:
236 'A tiny uppercase letter-spaced label sitting immediately above an oversized hero headline — or the same shape rendered as a pill chip — is now the default AI SaaS hero. Drop the eyebrow, integrate the kicker into the headline, or run it as a navigation breadcrumb instead.',
237 skillSection: 'Typography',
238 skillGuideline: 'tiny uppercase tracked label above the hero headline',
239 },
240 {
241 id: 'repeated-section-kickers',
242 category: 'slop',
243 severity: 'advisory',
244 name: 'Repeated section kicker labels',
245 description:
246 'Repeating tiny uppercase tracked labels above section headings turns a brand page into AI editorial scaffolding. Replace them with stronger structure, artifacts, imagery, or a deliberate brand system.',
247 skillSection: 'Typography',
248 skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding',
249 },
250
251 // ── Quality: general design and accessibility issues ──
252 {
253 id: 'pure-black-white',
254 category: 'quality',
255 name: 'Pure black background',
256 description:
257 '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.',
258 skillSection: 'Color & Contrast',
259 skillGuideline: 'pure black (#000)',
260 },
261 {
262 id: 'gray-on-color',
263 category: 'quality',
264 name: 'Gray text on colored background',
265 description:
266 'Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.',
267 skillSection: 'Color & Contrast',
268 skillGuideline: 'gray text on colored backgrounds',
269 },
270 {
271 id: 'low-contrast',
272 category: 'quality',
273 name: 'Low contrast text',
274 description:
275 '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.',
276 },
277 {
278 id: 'layout-transition',
279 category: 'quality',
280 name: 'Layout property animation',
281 description:
282 'Animating width, height, padding, or margin causes layout thrash and janky performance. Use transform and opacity instead, or grid-template-rows for height animations.',
283 skillSection: 'Motion',
284 skillGuideline: 'Animate layout properties',
285 },
286 {
287 id: 'line-length',
288 category: 'quality',
289 name: 'Line length too long',
290 description:
291 '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.',
292 skillSection: 'Layout & Space',
293 skillGuideline: 'wrap beyond ~80 characters',
294 },
295 {
296 id: 'cramped-padding',
297 category: 'quality',
298 name: 'Cramped padding',
299 description:
300 'Text is too close to the edge of its container. Add at least 8px (ideally 12-16px) of padding inside bordered or colored containers.',
301 },
302 {
303 id: 'body-text-viewport-edge',
304 category: 'quality',
305 name: 'Body text touching viewport edge',
306 description:
307 'Body paragraphs render flush against the left or right viewport edge with no container providing horizontal padding. Wrap content in a container with at least 16px (ideally 24-32px) of horizontal padding, or apply max-width with mx-auto.',
308 },
309 {
310 id: 'tight-leading',
311 category: 'quality',
312 name: 'Tight line height',
313 description:
314 '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.',
315 },
316 {
317 id: 'skipped-heading',
318 category: 'quality',
319 name: 'Skipped heading level',
320 description:
321 '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.',
322 },
323 {
324 id: 'justified-text',
325 category: 'quality',
326 name: 'Justified text',
327 description:
328 '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.',
329 },
330 {
331 id: 'tiny-text',
332 category: 'quality',
333 name: 'Tiny body text',
334 description:
335 'Body text below 12px is hard to read, especially on high-DPI screens. Use at least 14px for body content, 16px is ideal.',
336 },
337 {
338 id: 'all-caps-body',
339 category: 'quality',
340 name: 'All-caps body text',
341 description:
342 '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.',
343 skillSection: 'Typography',
344 skillGuideline: 'long body passages in uppercase',
345 },
346 {
347 id: 'wide-tracking',
348 category: 'quality',
349 name: 'Wide letter spacing on body text',
350 description:
351 'Letter spacing above 0.05em on body text disrupts natural character groupings and slows reading. Reserve wide tracking for short uppercase labels only.',
352 },
353];
354
355// --- cli/engine/shared/color.mjs ---
356// ─── Section 2: Color Utilities ─────────────────────────────────────────────
357
358function isNeutralColor(color) {
359 if (!color || color === 'transparent') return true;
360
361 // rgb/rgba — use channel spread. Threshold 30 ≈ 11.7% of the 0–255 range.
362 const rgb = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
363 if (rgb) {
364 return (Math.max(+rgb[1], +rgb[2], +rgb[3]) - Math.min(+rgb[1], +rgb[2], +rgb[3])) < 30;
365 }
366
367 // oklch()/lch() — chroma is the second numeric component.
368 // oklch chroma is ~0–0.4 in sRGB gamut; >= 0.02 reads as tinted, not gray.
369 // lch chroma is ~0–150; >= 3 reads as tinted. jsdom emits both formats
370 // literally (it does NOT convert them to rgb).
371 const oklch = color.match(/oklch\(\s*[\d.]+%?\s*([\d.-]+)/i);
372 if (oklch) return parseFloat(oklch[1]) < 0.02;
373 const lch = color.match(/lch\(\s*[\d.]+%?\s*([\d.-]+)/i);
374 if (lch) return parseFloat(lch[1]) < 3;
375
376 // oklab()/lab() — a and b are signed axes; chroma = sqrt(a² + b²).
377 // oklab a/b are ~-0.4..0.4, threshold 0.02. lab a/b are ~-128..127, threshold 3.
378 const oklab = color.match(/oklab\(\s*[\d.]+%?\s*([\d.-]+)\s+([\d.-]+)/i);
379 if (oklab) {
380 const a = parseFloat(oklab[1]), b = parseFloat(oklab[2]);
381 return Math.hypot(a, b) < 0.02;
382 }
383 const lab = color.match(/lab\(\s*[\d.]+%?\s*([\d.-]+)\s+([\d.-]+)/i);
384 if (lab) {
385 const a = parseFloat(lab[1]), b = parseFloat(lab[2]);
386 return Math.hypot(a, b) < 3;
387 }
388
389 // hsl/hsla — saturation is the second numeric component (percent).
390 // Modern jsdom usually converts hsl() to rgb, but handle it directly for
391 // safety across versions and for any engine that preserves the format.
392 const hsl = color.match(/hsla?\(\s*[\d.-]+\s*,?\s*([\d.]+)%/i);
393 if (hsl) return parseFloat(hsl[1]) < 10;
394
395 // hwb(hue whiteness% blackness%) — a pixel is fully gray when
396 // whiteness + blackness >= 100; chroma-like saturation = 1 - (w+b)/100.
397 const hwb = color.match(/hwb\(\s*[\d.-]+\s+([\d.]+)%\s+([\d.]+)%/i);
398 if (hwb) {
399 const w = parseFloat(hwb[1]), b = parseFloat(hwb[2]);
400 return (1 - Math.min(100, w + b) / 100) < 0.1;
401 }
402
403 // Unknown / unrecognized format — err on the side of DETECTING rather
404 // than silently skipping. This is the opposite of the previous default,
405 // which was the root cause of the oklch bug.
406 return false;
407}
408
409function parseRgb(color) {
410 if (!color || color === 'transparent') return null;
411 const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
412 if (!m) return null;
413 return { r: +m[1], g: +m[2], b: +m[3], a: m[4] !== undefined ? +m[4] : 1 };
414}
415
416function relativeLuminance({ r, g, b }) {
417 const [rs, gs, bs] = [r / 255, g / 255, b / 255].map(c =>
418 c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
419 );
420 return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
421}
422
423function contrastRatio(c1, c2) {
424 const l1 = relativeLuminance(c1);
425 const l2 = relativeLuminance(c2);
426 return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
427}
428
429function parseGradientColors(bgImage) {
430 if (!bgImage || !bgImage.includes('gradient')) return [];
431 const colors = [];
432 for (const m of bgImage.matchAll(/rgba?\([^)]+\)/g)) {
433 const c = parseRgb(m[0]);
434 if (c) colors.push(c);
435 }
436 for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
437 const h = m[1];
438 if (h.length === 6) {
439 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 });
440 } else {
441 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 });
442 }
443 }
444 return colors;
445}
446
447function hasChroma(c, threshold = 30) {
448 if (!c) return false;
449 return (Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b)) >= threshold;
450}
451
452function getHue(c) {
453 if (!c) return 0;
454 const r = c.r / 255, g = c.g / 255, b = c.b / 255;
455 const max = Math.max(r, g, b), min = Math.min(r, g, b);
456 if (max === min) return 0;
457 const d = max - min;
458 let h;
459 if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
460 else if (max === g) h = ((b - r) / d + 2) / 6;
461 else h = ((r - g) / d + 4) / 6;
462 return Math.round(h * 360);
463}
464
465function colorToHex(c) {
466 if (!c) return '?';
467 return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');
468}
469
470// --- cli/engine/rules/checks.mjs ---
471const DETECTOR_IS_BROWSER = typeof window !== 'undefined';
472
473// ─── Section 3: Pure Detection ──────────────────────────────────────────────
474
475function checkBorders(tag, widths, colors, radius) {
476 if (BORDER_SAFE_TAGS.has(tag)) return [];
477 const findings = [];
478 const sides = ['Top', 'Right', 'Bottom', 'Left'];
479
480 for (const side of sides) {
481 const w = widths[side];
482 if (w < 1 || isNeutralColor(colors[side])) continue;
483
484 const otherSides = sides.filter(s => s !== side);
485 const maxOther = Math.max(...otherSides.map(s => widths[s]));
486 if (!(w >= 2 && (maxOther <= 1 || w >= maxOther * 2))) continue;
487
488 const sn = side.toLowerCase();
489 const isSide = side === 'Left' || side === 'Right';
490
491 if (isSide) {
492 if (radius > 0) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` });
493 else if (w >= 3) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px` });
494 } else {
495 if (radius > 0 && w >= 2) findings.push({ id: 'border-accent-on-rounded', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` });
496 }
497 }
498
499 return findings;
500}
501
502// Returns true if the given text is composed entirely of emoji characters
503// (plus whitespace / variation selectors). Emojis render as multicolor glyphs
504// regardless of CSS `color`, so contrast checks against the element's text
505// color are meaningless for these nodes.
506const 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;
507const 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;
508function isEmojiOnlyText(text) {
509 if (!text) return false;
510 if (!EMOJI_CHAR_RE.test(text)) return false;
511 return text.replace(EMOJI_CHARS_GLOBAL, '').trim() === '';
512}
513
514function checkColors(opts) {
515 const { tag, textColor, bgColor, effectiveBg, effectiveBgStops, fontSize, fontWeight, hasDirectText, isEmojiOnly, bgClip, bgImage, classList } = opts;
516 if (SAFE_TAGS.has(tag)) {
517 // Exception for <a> and <button> elements styled as buttons. SAFE_TAGS
518 // exists to suppress contrast noise on inline links and unstyled controls,
519 // where the element has no own background and the contrast against the
520 // ancestor surface is already the intended visual. When the element has
521 // its own opaque background and direct text, it is a styled button — and
522 // contrast on its own surface is a real, frequent bug worth flagging.
523 const isStyledButton = (tag === 'a' || tag === 'button')
524 && hasDirectText
525 && bgColor && bgColor.a > 0.5;
526 if (!isStyledButton) return [];
527 }
528 const findings = [];
529
530 // Pure black background (only solid or near-solid, not semi-transparent overlays)
531 if (bgColor && bgColor.a >= 0.9 && bgColor.r === 0 && bgColor.g === 0 && bgColor.b === 0) {
532 findings.push({ id: 'pure-black-white', snippet: '#000000 background' });
533 }
534
535 if (hasDirectText && textColor && !isEmojiOnly) {
536 // Run background-dependent checks against either a solid bg or, if the
537 // ancestor is a gradient, against every gradient stop (use the worst case).
538 const bgs = effectiveBg ? [effectiveBg] : (effectiveBgStops && effectiveBgStops.length ? effectiveBgStops : null);
539 if (bgs) {
540 // Gray on colored background — flag if every stop is chromatic
541 const textLum = relativeLuminance(textColor);
542 const isGray = !hasChroma(textColor, 20) && textLum > 0.05 && textLum < 0.85;
543 if (isGray && bgs.every(b => hasChroma(b, 40))) {
544 const bgLabel = effectiveBg ? colorToHex(effectiveBg) : `gradient(${bgs.map(colorToHex).join(', ')})`;
545 findings.push({ id: 'gray-on-color', snippet: `text ${colorToHex(textColor)} on bg ${bgLabel}` });
546 }
547
548 // Low contrast (WCAG AA) — worst case across all bg stops
549 const ratios = bgs.map(b => contrastRatio(textColor, b));
550 let worstIdx = 0;
551 for (let i = 1; i < ratios.length; i++) if (ratios[i] < ratios[worstIdx]) worstIdx = i;
552 const ratio = ratios[worstIdx];
553 const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700);
554 const threshold = isLargeText ? 3.0 : 4.5;
555 if (ratio < threshold) {
556 // Skip the false-positive class where text has alpha < 1 AND we
557 // couldn't find an opaque ancestor (effectiveBg is null, we're
558 // comparing against gradient-stop fallback). In jsdom mode the
559 // detector can't resolve `var(--X)` color tokens, so a dark
560 // section sitting between the text and the body's decorative
561 // gradient is invisible to us — we end up measuring contrast
562 // against the body's paper-grain noise instead of the real
563 // local bg. Real low-contrast bugs use alpha=1 and have a
564 // resolvable opaque ancestor; semi-transparent Tailwind tokens
565 // like `text-paper/60` on `bg-ink` sections are the FP pattern.
566 const isAlphaFallbackFP = !DETECTOR_IS_BROWSER && !effectiveBg && (textColor.a != null && textColor.a < 1);
567 if (!isAlphaFallbackFP) {
568 findings.push({ id: 'low-contrast', snippet: `${ratio.toFixed(1)}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(bgs[worstIdx])}` });
569 }
570 }
571 }
572
573 // AI palette: purple/violet on headings
574 if (hasChroma(textColor, 50)) {
575 const hue = getHue(textColor);
576 if (hue >= 260 && hue <= 310 && (['h1', 'h2', 'h3'].includes(tag) || fontSize >= 20)) {
577 findings.push({ id: 'ai-color-palette', snippet: `Purple/violet text (${colorToHex(textColor)}) on heading` });
578 }
579 }
580 }
581
582 // Gradient text
583 if (bgClip === 'text' && bgImage && bgImage.includes('gradient')) {
584 findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
585 }
586
587 // Tailwind class checks
588 if (classList) {
589 const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
590 if (/\bbg-black\b(?!\/)/.test(classStr)) {
591 findings.push({ id: 'pure-black-white', snippet: 'bg-black' });
592 }
593
594 const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
595 const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
596 if (grayMatch && colorBgMatch) {
597 findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
598 }
599
600 if (/\bbg-clip-text\b/.test(classStr) && /\bbg-gradient-to-/.test(classStr)) {
601 findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' });
602 }
603
604 const purpleText = classStr.match(/\btext-(?:purple|violet|indigo)-\d+\b/);
605 if (purpleText && (['h1', 'h2', 'h3'].includes(tag) || /\btext-(?:[2-9]xl)\b/.test(classStr))) {
606 findings.push({ id: 'ai-color-palette', snippet: `${purpleText[0]} on heading` });
607 }
608
609 if (/\bfrom-(?:purple|violet|indigo)-\d+\b/.test(classStr) && /\bto-(?:purple|violet|indigo|blue|cyan|pink|fuchsia)-\d+\b/.test(classStr)) {
610 findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet gradient (Tailwind)' });
611 }
612 }
613
614 return findings;
615}
616
617function isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg) {
618 if (!hasShadow && !hasBorder) return false;
619 return hasRadius || hasBg;
620}
621
622const HEADING_TAGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
623
624// Pure check: given a heading and metrics about its previousElementSibling,
625// decide if the sibling is the canonical "icon-tile-stacked-above-heading" shape.
626//
627// Triggers when ALL of the following hold for the sibling:
628// • size 32–128px on both axes (not too small, not a hero image)
629// • aspect ratio 0.7–1.4 (squarish — excludes wide thumbnails / pill badges)
630// • has a non-transparent background-color, background-image, OR a visible border
631// (covers solid colors, white-with-border, gradients — anything that visually
632// defines a tile)
633// • border-radius < width/2 (excludes round avatars; rounded squares pass)
634// • contains an <svg> or icon-class <i> element that's smaller than the tile
635// • the tile sits above the heading (its bottom is above the heading's top)
636function checkIconTile(opts) {
637 const { headingTag, headingText, headingTop,
638 siblingTag, siblingWidth, siblingHeight, siblingBottom,
639 siblingBgColor, siblingBgImage, siblingBorderWidth, siblingBorderRadius,
640 hasIconChild, iconChildWidth } = opts;
641 if (!HEADING_TAGS.has(headingTag)) return [];
642 if (!siblingTag) return [];
643 // Don't recurse into nested headings (e.g. h2 above h3 in a section header)
644 if (HEADING_TAGS.has(siblingTag)) return [];
645
646 // Size window: 32–128px on each axis
647 if (!(siblingWidth >= 32 && siblingWidth <= 128)) return [];
648 if (!(siblingHeight >= 32 && siblingHeight <= 128)) return [];
649
650 // Squarish aspect ratio
651 const ratio = siblingWidth / siblingHeight;
652 if (ratio < 0.7 || ratio > 1.4) return [];
653
654 // Must have something that visually defines the tile
655 const bgVisible = (siblingBgColor && siblingBgColor.a > 0.1)
656 || (siblingBgImage && siblingBgImage !== 'none' && siblingBgImage !== '');
657 const borderVisible = siblingBorderWidth > 0;
658 if (!bgVisible && !borderVisible) return [];
659
660 // Exclude circles (avatars). Rounded squares pass.
661 if (siblingBorderRadius >= siblingWidth / 2) return [];
662
663 // Must contain an icon element smaller than the tile
664 if (!hasIconChild) return [];
665 if (iconChildWidth && iconChildWidth >= siblingWidth * 0.95) return [];
666
667 // Vertical stacking: tile must end above where the heading starts.
668 // (Allow the check to skip when both top/bottom are 0 — jsdom layout case.)
669 if (headingTop && siblingBottom && siblingBottom > headingTop + 4) return [];
670
671 const text = (headingText || '').trim().slice(0, 60);
672 return [{
673 id: 'icon-tile-stack',
674 snippet: `${Math.round(siblingWidth)}x${Math.round(siblingHeight)}px icon tile above ${headingTag} "${text}"`,
675 }];
676}
677
678// Resolve the primary (non-generic) face from a font-family string and return
679// whether the resolved primary is serif. Two paths:
680// 1. Primary face is in KNOWN_SERIF_FONTS → serif.
681// 2. Primary face is unknown but the stack ends in the generic `serif`
682// token → treat as serif. Authors who declare `font-family: 'X', serif`
683// almost always have a serif primary; a sans declared with a serif
684// fallback is a code smell, not the common case.
685// Returns { primary, isSerif } so the snippet can name the face.
686function resolveSerif(fontFamily) {
687 if (!fontFamily) return { primary: null, isSerif: false };
688 const tokens = fontFamily.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
689 const primary = tokens.find(f => f && !GENERIC_FONTS.has(f)) || null;
690 if (!primary) return { primary: null, isSerif: false };
691 if (KNOWN_SERIF_FONTS.has(primary)) return { primary, isSerif: true };
692 if (tokens.includes('serif')) return { primary, isSerif: true };
693 return { primary, isSerif: false };
694}
695
696function checkItalicSerif(opts) {
697 const { tag, fontStyle, fontFamily, fontSize, headingText } = opts;
698 if (fontStyle !== 'italic') return [];
699 // Anchor the rule on hero-scale text. h1 is the canonical hero element;
700 // h2 ≥ 48px catches the cases where the design demotes the visual hero
701 // to an h2 but keeps the size.
702 if (tag !== 'h1' && !(tag === 'h2' && fontSize >= 48)) return [];
703 if (fontSize < 48) return [];
704 const { primary, isSerif } = resolveSerif(fontFamily);
705 if (!isSerif) return [];
706
707 const text = (headingText || '').trim().slice(0, 60);
708 return [{
709 id: 'italic-serif-display',
710 snippet: `italic serif ${tag} (${primary || 'serif'}) at ${Math.round(fontSize)}px "${text}"`,
711 }];
712}
713
714// Color saturation check. Returns true when the color has visible
715// chroma — i.e., it's an "accent color" rather than near-neutral.
716// Handles rgb()/rgba(), #hex, oklch(), and hsl(). var() refs are
717// expected to be pre-resolved by the caller.
718function isAccentColor(cssColor) {
719 if (!cssColor) return false;
720 const s = String(cssColor).trim();
721 // rgb / rgba — direct channel-distance check.
722 const rgbM = /rgba?\(\s*(\d+)\s*,?\s+|\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(s.replace(/rgba?\(\s*/, 'rgb(').replace(/,/g, ', '));
723 const rgbStrict = /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(s);
724 if (rgbStrict) {
725 const r = +rgbStrict[1], g = +rgbStrict[2], b = +rgbStrict[3];
726 return (Math.max(r, g, b) - Math.min(r, g, b)) >= 40;
727 }
728 // #hex — 3, 4, 6, or 8 digit.
729 const hexM = /^#([0-9a-f]{3,8})\b/i.exec(s);
730 if (hexM) {
731 let h = hexM[1];
732 if (h.length === 3 || h.length === 4) h = h.split('').map((c) => c + c).join('').slice(0, 6);
733 else h = h.slice(0, 6);
734 if (h.length === 6) {
735 const r = parseInt(h.slice(0, 2), 16);
736 const g = parseInt(h.slice(2, 4), 16);
737 const b = parseInt(h.slice(4, 6), 16);
738 return (Math.max(r, g, b) - Math.min(r, g, b)) >= 40;
739 }
740 }
741 // oklch(L C H) — chroma C is what matters. Typical neutral grays
742 // have C < 0.02; visible accents are 0.05+. CSS minification can
743 // collapse spaces between L% and C ("oklch(43%.15 34)"), so we
744 // extract all numbers and take the second rather than matching a
745 // strict L-then-whitespace-then-C pattern.
746 if (/^oklch\(/i.test(s)) {
747 const nums = s.match(/\d*\.\d+|\d+/g);
748 if (nums && nums.length >= 2) {
749 const c = parseFloat(nums[1]);
750 return !Number.isNaN(c) && c >= 0.05;
751 }
752 }
753 // hsl(H, S%, L%) — saturation > 20% reads as accent.
754 const hslM = /hsla?\(\s*[\d.]+\s*,\s*([\d.]+)%/i.exec(s);
755 if (hslM) {
756 const sat = parseFloat(hslM[1]);
757 return !Number.isNaN(sat) && sat >= 20;
758 }
759 return false;
760}
761
762// Sibling-relationship rule. Anchor on a hero-scale h1, look at the
763// previousElementSibling, and gate on EITHER the classic tracked-
764// uppercase eyebrow OR the modern accent-colored bold eyebrow.
765function checkHeroEyebrow(opts) {
766 const {
767 headingTag, headingText, headingFontSize,
768 siblingTag, siblingText, siblingTextTransform,
769 siblingFontSize, siblingLetterSpacing,
770 siblingFontWeight, siblingColor,
771 } = opts;
772 if (headingTag !== 'h1') return [];
773 // We previously gated on headingFontSize >= 48 to anchor "hero scale".
774 // But modern hero h1s use clamp() / vw / var(--text-*), none of which
775 // jsdom can resolve — the computed value comes back as "2em" or
776 // "var(--text-9xl)" and parseFloat returns 2 or NaN. The gate fails
777 // on virtually every Tailwind v4 / framework build. The other gates
778 // (sibling text 2-60 chars, font-size ≤ 14px, accent-bold OR
779 // tracked-caps) are tight enough to avoid false positives on non-
780 // hero h1s — a tiny tan label directly above any h1 is the
781 // antipattern regardless of how big the h1 ends up.
782 if (!siblingTag) return [];
783 // An h2 above an h1 is a different anti-pattern (heading hierarchy / dual
784 // headings) — never an eyebrow.
785 if (HEADING_TAGS.has(siblingTag)) return [];
786
787 const text = (siblingText || '').trim();
788 if (text.length < 2 || text.length > 60) return [];
789 if (!(siblingFontSize > 0 && siblingFontSize <= 14)) return [];
790
791 // Branch A: classic tracked-uppercase eyebrow.
792 const isUppercased = siblingTextTransform === 'uppercase'
793 || (/[A-Z]/.test(text) && !/[a-z]/.test(text));
794 const isClassicTracked = isUppercased && siblingLetterSpacing >= 1.6;
795
796 // Branch B: modern accent-bold eyebrow — sentence case, low
797 // tracking, but bold + accent-colored. The style choices changed;
798 // the pattern is the same kicker-above-headline anti-pattern.
799 const weight = Number(siblingFontWeight) || 400;
800 const isAccentBold = weight >= 700 && isAccentColor(siblingColor || '');
801
802 if (!isClassicTracked && !isAccentBold) return [];
803
804 const headingTextSnippet = (headingText || '').trim().slice(0, 60);
805 const eyebrowSnippet = text.slice(0, 40);
806 const style = isClassicTracked ? 'tracked-caps' : 'accent-bold';
807 return [{
808 id: 'hero-eyebrow-chip',
809 snippet: `eyebrow chip (${style}) "${eyebrowSnippet}" above ${headingTag} "${headingTextSnippet}"`,
810 }];
811}
812
813function checkRepeatedSectionKickers(opts) {
814 const { candidates, minCount = 3 } = opts;
815 if (!Array.isArray(candidates) || candidates.length < minCount) return [];
816 return candidates.map(candidate => ({
817 id: 'repeated-section-kickers',
818 snippet: `repeated section kicker "${candidate.kickerText}" before ${candidate.headingTag} "${candidate.headingText}" (${candidates.length} on page)`,
819 }));
820}
821
822const LAYOUT_TRANSITION_PROPS = new Set([
823 'width', 'height', 'padding', 'margin',
824 'max-height', 'max-width', 'min-height', 'min-width',
825 'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
826 'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
827]);
828
829function checkMotion(opts) {
830 const { tag, transitionProperty, animationName, timingFunctions, classList } = opts;
831 if (SAFE_TAGS.has(tag)) return [];
832 const findings = [];
833
834 // --- Bounce/elastic easing ---
835 if (animationName && animationName !== 'none' && /bounce|elastic|wobble|jiggle|spring/i.test(animationName)) {
836 findings.push({ id: 'bounce-easing', snippet: `animation: ${animationName}` });
837 }
838 if (classList && /\banimate-bounce\b/.test(classList)) {
839 findings.push({ id: 'bounce-easing', snippet: 'animate-bounce (Tailwind)' });
840 }
841
842 // Check timing functions for overshoot cubic-bezier (y values outside [0, 1])
843 if (timingFunctions) {
844 const bezierRe = /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g;
845 let m;
846 while ((m = bezierRe.exec(timingFunctions)) !== null) {
847 const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]);
848 if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) {
849 findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${m[1]}, ${m[2]}, ${m[3]}, ${m[4]})` });
850 break;
851 }
852 }
853 }
854
855 // --- Layout property transition ---
856 if (transitionProperty && transitionProperty !== 'all' && transitionProperty !== 'none') {
857 const props = transitionProperty.split(',').map(p => p.trim().toLowerCase());
858 const layoutFound = props.filter(p => LAYOUT_TRANSITION_PROPS.has(p));
859 if (layoutFound.length > 0) {
860 findings.push({ id: 'layout-transition', snippet: `transition: ${layoutFound.join(', ')}` });
861 }
862 }
863
864 return findings;
865}
866
867function checkGlow(opts) {
868 const { boxShadow, effectiveBg } = opts;
869 if (!boxShadow || boxShadow === 'none') return [];
870 if (!effectiveBg) return [];
871
872 // Only flag on dark backgrounds (luminance < 0.1)
873 const bgLum = relativeLuminance(effectiveBg);
874 if (bgLum >= 0.1) return [];
875
876 // Split multiple shadows (commas not inside parentheses)
877 const parts = boxShadow.split(/,(?![^(]*\))/);
878 for (const shadow of parts) {
879 const colorMatch = shadow.match(/rgba?\([^)]+\)/);
880 if (!colorMatch) continue;
881 const color = parseRgb(colorMatch[0]);
882 if (!color || !hasChroma(color, 30)) continue;
883
884 // Extract px values — in computed style: "color Xpx Ypx BLURpx [SPREADpx]"
885 const afterColor = shadow.substring(shadow.indexOf(colorMatch[0]) + colorMatch[0].length);
886 const beforeColor = shadow.substring(0, shadow.indexOf(colorMatch[0]));
887 const pxVals = [...beforeColor.matchAll(/([\d.]+)px/g), ...afterColor.matchAll(/([\d.]+)px/g)]
888 .map(m => parseFloat(m[1]));
889
890 // Third value is blur (offset-x, offset-y, blur, [spread])
891 if (pxVals.length >= 3 && pxVals[2] > 4) {
892 return [{ id: 'dark-glow', snippet: `Colored glow (${colorToHex(color)}) on dark background` }];
893 }
894 }
895
896 return [];
897}
898
899/**
900 * Regex-on-HTML checks shared between browser and Node page-level detection.
901 * These don't need DOM access, just the raw HTML string.
902 */
903function checkHtmlPatterns(html) {
904 const findings = [];
905
906 // --- Color ---
907
908 // Pure black background
909 const pureBlackBgRe = /background(?:-color)?\s*:\s*(?:#000000|#000|rgb\(\s*0,\s*0,\s*0\s*\))\b/gi;
910 if (pureBlackBgRe.test(html)) {
911 findings.push({ id: 'pure-black-white', snippet: 'Pure #000 background' });
912 }
913
914 // AI color palette: purple/violet
915 const purpleHexRe = /#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9|6366f1|764ba2|667eea)\b/gi;
916 if (purpleHexRe.test(html)) {
917 const purpleTextRe = /(?:(?:^|;)\s*color\s*:\s*(?:.*?)(?:#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9))|gradient.*?#(?:7c3aed|8b5cf6|a855f7|764ba2|667eea))/gi;
918 if (purpleTextRe.test(html)) {
919 findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected' });
920 }
921 }
922
923 // Gradient text (background-clip: text + gradient)
924 const gradientRe = /(?:-webkit-)?background-clip\s*:\s*text/gi;
925 let gm;
926 while ((gm = gradientRe.exec(html)) !== null) {
927 const start = Math.max(0, gm.index - 200);
928 const context = html.substring(start, gm.index + gm[0].length + 200);
929 if (/gradient/i.test(context)) {
930 findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
931 break;
932 }
933 }
934 if (/\bbg-clip-text\b/.test(html) && /\bbg-gradient-to-/.test(html)) {
935 findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' });
936 }
937
938 // --- Layout ---
939
940 // Monotonous spacing
941 const spacingValues = [];
942 const spacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*(\d+)px/gi;
943 let sm;
944 while ((sm = spacingRe.exec(html)) !== null) {
945 const v = parseInt(sm[1], 10);
946 if (v > 0 && v < 200) spacingValues.push(v);
947 }
948 const gapRe = /gap\s*:\s*(\d+)px/gi;
949 while ((sm = gapRe.exec(html)) !== null) {
950 spacingValues.push(parseInt(sm[1], 10));
951 }
952 const twSpaceRe = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+)\b/g;
953 while ((sm = twSpaceRe.exec(html)) !== null) {
954 spacingValues.push(parseInt(sm[1], 10) * 4);
955 }
956 const remSpacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*([\d.]+)rem/gi;
957 while ((sm = remSpacingRe.exec(html)) !== null) {
958 const v = Math.round(parseFloat(sm[1]) * 16);
959 if (v > 0 && v < 200) spacingValues.push(v);
960 }
961 const roundedSpacing = spacingValues.map(v => Math.round(v / 4) * 4);
962 if (roundedSpacing.length >= 10) {
963 const counts = {};
964 for (const v of roundedSpacing) counts[v] = (counts[v] || 0) + 1;
965 const maxCount = Math.max(...Object.values(counts));
966 const dominantPct = maxCount / roundedSpacing.length;
967 const unique = [...new Set(roundedSpacing)].filter(v => v > 0);
968 if (dominantPct > 0.6 && unique.length <= 3) {
969 const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
970 findings.push({
971 id: 'monotonous-spacing',
972 snippet: `~${dominant}px used ${maxCount}/${roundedSpacing.length} times (${Math.round(dominantPct * 100)}%)`,
973 });
974 }
975 }
976
977 // --- Motion ---
978
979 // Bounce/elastic animation names
980 const bounceRe = /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi;
981 if (bounceRe.test(html)) {
982 findings.push({ id: 'bounce-easing', snippet: 'Bounce/elastic animation in CSS' });
983 }
984
985 // Overshoot cubic-bezier
986 const bezierRe = /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g;
987 let bm;
988 while ((bm = bezierRe.exec(html)) !== null) {
989 const y1 = parseFloat(bm[2]), y2 = parseFloat(bm[4]);
990 if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) {
991 findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})` });
992 break;
993 }
994 }
995
996 // Layout property transitions
997 const transRe = /transition(?:-property)?\s*:\s*([^;{}]+)/gi;
998 let tm;
999 while ((tm = transRe.exec(html)) !== null) {
1000 const val = tm[1].toLowerCase();
1001 if (/\ball\b/.test(val)) continue;
1002 const found = val.match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
1003 if (found) {
1004 findings.push({ id: 'layout-transition', snippet: `transition: ${found.join(', ')}` });
1005 break;
1006 }
1007 }
1008
1009 // --- Dark glow ---
1010
1011 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;
1012 const twDarkBg = /\bbg-(?:gray|slate|zinc|neutral|stone)-(?:9\d{2}|800)\b/;
1013 if (darkBgRe.test(html) || twDarkBg.test(html)) {
1014 const shadowRe = /box-shadow\s*:\s*([^;{}]+)/gi;
1015 let shm;
1016 while ((shm = shadowRe.exec(html)) !== null) {
1017 const val = shm[1];
1018 const colorMatch = val.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
1019 if (!colorMatch) continue;
1020 const [r, g, b] = [+colorMatch[1], +colorMatch[2], +colorMatch[3]];
1021 if ((Math.max(r, g, b) - Math.min(r, g, b)) < 30) continue;
1022 const pxVals = [...val.matchAll(/(\d+)px|(?<![.\d])\b(0)\b(?![.\d])/g)].map(p => +(p[1] || p[2]));
1023 if (pxVals.length >= 3 && pxVals[2] > 4) {
1024 findings.push({ id: 'dark-glow', snippet: `Colored glow (rgb(${r},${g},${b})) on dark page` });
1025 break;
1026 }
1027 }
1028 }
1029
1030 return findings;
1031}
1032
1033// ─── Section 4: resolveBackground (unified) ─────────────────────────────────
1034
1035// Read the element's own background color, computed-style first, with a
1036// jsdom-friendly fallback that parses the inline `background:` shorthand
1037// from the raw style attribute. jsdom (~v29) does not decompose the
1038// shorthand into `backgroundColor`, so without this fallback the CLI silently
1039// returns null for any element styled via `background: rgb(...)` or
1040// `background: #abc`. Real browsers always decompose, so the fallback is
1041// a no-op there.
1042function readOwnBackgroundColor(el, computedStyle) {
1043 const bg = parseRgb(computedStyle.backgroundColor);
1044 if (DETECTOR_IS_BROWSER || (bg && bg.a >= 0.1)) return bg;
1045 const rawStyle = el.getAttribute?.('style') || '';
1046 const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
1047 const inlineBg = bgMatch ? bgMatch[1].trim() : '';
1048 if (!inlineBg) return bg;
1049 if (/gradient/i.test(inlineBg) || /url\s*\(/i.test(inlineBg)) return bg;
1050 const fromRgb = parseRgb(inlineBg);
1051 if (fromRgb) return fromRgb;
1052 const hexMatch = inlineBg.match(/#([0-9a-f]{6}|[0-9a-f]{3})\b/i);
1053 if (hexMatch) {
1054 const h = hexMatch[1];
1055 if (h.length === 6) {
1056 return { r: parseInt(h.slice(0, 2), 16), g: parseInt(h.slice(2, 4), 16), b: parseInt(h.slice(4, 6), 16), a: 1 };
1057 }
1058 return { r: parseInt(h[0] + h[0], 16), g: parseInt(h[1] + h[1], 16), b: parseInt(h[2] + h[2], 16), a: 1 };
1059 }
1060 return bg;
1061}
1062
1063function resolveBackground(el, win, customPropMap) {
1064 let current = el;
1065 while (current && current.nodeType === 1) {
1066 const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
1067 const bgImage = style.backgroundImage || '';
1068 const hasGradientOrUrl = bgImage && bgImage !== 'none' && (/gradient/i.test(bgImage) || /url\s*\(/i.test(bgImage));
1069
1070 // Try the solid bg-color FIRST. If the element has both a solid color
1071 // and a gradient/url overlay (a common pattern: `background: var(--paper)
1072 // radial-gradient(...)` for paper-grain texture), the solid color is the
1073 // dominant visible surface for contrast purposes; the overlay is
1074 // decorative. The old behavior bailed on any gradient ancestor, which
1075 // caused massive false-positive contrast findings on grain-textured
1076 // body backgrounds.
1077 let bg = parseRgb(style.backgroundColor);
1078 if (!DETECTOR_IS_BROWSER && (!bg || bg.a < 0.1)) {
1079 // jsdom returns literal "var(--X)" / "oklch(...)" strings. Resolve
1080 // through customPropMap so Tailwind v4 color tokens become RGB.
1081 if (customPropMap) {
1082 bg = parseColorResolved(style.backgroundColor, customPropMap);
1083 }
1084 if (!bg || bg.a < 0.1) {
1085 // Inline-style fallback. jsdom doesn't decompose background
1086 // shorthand, so colors set via inline style are otherwise invisible.
1087 const rawStyle = current.getAttribute?.('style') || '';
1088 const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
1089 const inlineBg = bgMatch ? bgMatch[1].trim() : '';
1090 if (inlineBg && !/gradient/i.test(inlineBg) && !/url\s*\(/i.test(inlineBg)) {
1091 bg = parseColorResolved(inlineBg, customPropMap) || parseAnyColor(inlineBg);
1092 }
1093 }
1094 }
1095
1096 if (bg && bg.a > 0.1) {
1097 if (DETECTOR_IS_BROWSER || bg.a >= 0.5) return bg;
1098 }
1099 // No solid bg-color at this level. If THIS level has a gradient/url
1100 // with no underlying solid color we can read:
1101 // • on body/html: assume white. Body-level gradients are almost
1102 // always decorative texture (paper grain, noise) on top of a
1103 // solid bg-color the page set via `background: var(--paper)`
1104 // shorthand — which jsdom can't decompose into bg-color. The
1105 // downstream gradient-stops fallback path produces catastrophic
1106 // false positives in this case (gradient noise stops have
1107 // accidental browns/blacks that look like card backgrounds).
1108 // • on other elements: bail to null and let the caller fall back
1109 // to gradient stops (gradient buttons / hero sections are real
1110 // bgs worth checking against).
1111 if (hasGradientOrUrl) {
1112 if (current.tagName === 'BODY' || current.tagName === 'HTML') {
1113 return { r: 255, g: 255, b: 255, a: 1 };
1114 }
1115 return null;
1116 }
1117 current = current.parentElement;
1118 }
1119 return { r: 255, g: 255, b: 255 };
1120}
1121
1122// Walk parents looking for a gradient background and return its color stops.
1123// Used as a fallback when resolveBackground() returns null because the
1124// effective background is a gradient (no single solid color to compare against).
1125function resolveGradientStops(el, win) {
1126 let current = el;
1127 while (current && current.nodeType === 1) {
1128 const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
1129 const bgImage = style.backgroundImage || '';
1130 if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
1131 const stops = parseGradientColors(bgImage);
1132 if (stops.length > 0) return stops;
1133 }
1134 if (!DETECTOR_IS_BROWSER) {
1135 // jsdom doesn't decompose `background:` shorthand — peek at the raw inline style
1136 const rawStyle = current.getAttribute?.('style') || '';
1137 const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
1138 if (bgMatch && /gradient/i.test(bgMatch[1])) {
1139 const stops = parseGradientColors(bgMatch[1]);
1140 if (stops.length > 0) return stops;
1141 }
1142 }
1143 current = current.parentElement;
1144 }
1145 return null;
1146}
1147
1148// Parse a single CSS length token to pixels. Accepts "12px", "50%", a
1149// shorthand like "12px 4px" (uses the first value), or empty / null.
1150// Returns the pixel value, or null when the input is unparseable.
1151// Percentages convert against `widthPx` when one is supplied. Without a
1152// usable width (jsdom returns "auto" for many real-world elements,
1153// which parseFloat collapses to 0), fall back to the raw percentage
1154// number so callers gating on `> 0` (border-accent-on-rounded,
1155// isCardLike's hasRadius) still see a positive value, matching the
1156// original parseFloat("50%") === 50 behavior.
1157function parseRadiusToPx(value, widthPx) {
1158 if (!value || typeof value !== 'string') return null;
1159 const trimmed = value.trim();
1160 if (!trimmed) return null;
1161 const first = trimmed.split(/\s+/)[0];
1162 const num = parseFloat(first);
1163 if (Number.isNaN(num)) return null;
1164 if (/%$/.test(first)) {
1165 if (widthPx && widthPx > 0) return (num / 100) * widthPx;
1166 return num;
1167 }
1168 return num;
1169}
1170
1171function resolveBorderRadiusPx(el, style, widthPx, win) {
1172 const fromComputed = parseRadiusToPx(style.borderRadius, widthPx);
1173 if (fromComputed !== null) return fromComputed;
1174 return 0;
1175}
1176
1177// ─── Section 5: Element Adapters ────────────────────────────────────────────
1178
1179// Browser adapters — call getComputedStyle/getBoundingClientRect on live DOM
1180
1181function checkElementBordersDOM(el) {
1182 const tag = el.tagName.toLowerCase();
1183 if (BORDER_SAFE_TAGS.has(tag)) return [];
1184 const rect = el.getBoundingClientRect();
1185 if (rect.width < 20 || rect.height < 20) return [];
1186 const style = getComputedStyle(el);
1187 const sides = ['Top', 'Right', 'Bottom', 'Left'];
1188 const widths = {}, colors = {};
1189 for (const s of sides) {
1190 widths[s] = parseFloat(style[`border${s}Width`]) || 0;
1191 colors[s] = style[`border${s}Color`] || '';
1192 }
1193 return checkBorders(tag, widths, colors, parseFloat(style.borderRadius) || 0);
1194}
1195
1196function checkElementColorsDOM(el) {
1197 const tag = el.tagName.toLowerCase();
1198 // No early SAFE_TAGS bail here — checkColors() does its own gating that
1199 // includes the styled-button exception for <a> / <button> with their own
1200 // opaque background. Bailing here would prevent that exception from firing.
1201 const rect = el.getBoundingClientRect();
1202 if (rect.width < 10 || rect.height < 10) return [];
1203 const style = getComputedStyle(el);
1204 const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
1205 const hasDirectText = directText.trim().length > 0;
1206 const effectiveBg = resolveBackground(el);
1207 return checkColors({
1208 tag,
1209 textColor: parseRgb(style.color),
1210 bgColor: readOwnBackgroundColor(el, style),
1211 effectiveBg,
1212 effectiveBgStops: effectiveBg ? null : resolveGradientStops(el),
1213 fontSize: parseFloat(style.fontSize) || 16,
1214 fontWeight: parseInt(style.fontWeight) || 400,
1215 hasDirectText,
1216 isEmojiOnly: isEmojiOnlyText(directText),
1217 bgClip: style.webkitBackgroundClip || style.backgroundClip || '',
1218 bgImage: style.backgroundImage || '',
1219 classList: el.getAttribute('class') || '',
1220 });
1221}
1222
1223function checkElementIconTileDOM(el) {
1224 const tag = el.tagName.toLowerCase();
1225 if (!HEADING_TAGS.has(tag)) return [];
1226 const sibling = el.previousElementSibling;
1227 if (!sibling) return [];
1228
1229 const sibRect = sibling.getBoundingClientRect();
1230 const headRect = el.getBoundingClientRect();
1231 const sibStyle = getComputedStyle(sibling);
1232
1233 // The tile may either contain an <svg>/<i> icon child, OR the tile itself
1234 // may contain an emoji/symbol character directly as its only text content
1235 // (the "card-icon" pattern from many AI-generated demos).
1236 const iconChild = sibling.querySelector('svg, i[data-lucide], i[class*="fa-"], i[class*="icon"]');
1237 const iconRect = iconChild?.getBoundingClientRect();
1238 const sibDirectText = [...sibling.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
1239 const hasInlineEmojiIcon = sibling.children.length === 0 && isEmojiOnlyText(sibDirectText);
1240
1241 return checkIconTile({
1242 headingTag: tag,
1243 headingText: el.textContent || '',
1244 headingTop: headRect.top,
1245 siblingTag: sibling.tagName.toLowerCase(),
1246 siblingWidth: sibRect.width,
1247 siblingHeight: sibRect.height,
1248 siblingBottom: sibRect.bottom,
1249 siblingBgColor: parseRgb(sibStyle.backgroundColor),
1250 siblingBgImage: sibStyle.backgroundImage || '',
1251 siblingBorderWidth: parseFloat(sibStyle.borderTopWidth) || 0,
1252 siblingBorderRadius: parseFloat(sibStyle.borderRadius) || 0,
1253 hasIconChild: !!iconChild || hasInlineEmojiIcon,
1254 iconChildWidth: iconRect?.width || 0,
1255 });
1256}
1257
1258function checkElementItalicSerifDOM(el) {
1259 const tag = el.tagName.toLowerCase();
1260 if (tag !== 'h1' && tag !== 'h2') return [];
1261 const style = getComputedStyle(el);
1262 return checkItalicSerif({
1263 tag,
1264 fontStyle: style.fontStyle || '',
1265 fontFamily: style.fontFamily || '',
1266 fontSize: parseFloat(style.fontSize) || 0,
1267 headingText: el.textContent || '',
1268 });
1269}
1270
1271function checkElementHeroEyebrowDOM(el) {
1272 const tag = el.tagName.toLowerCase();
1273 if (tag !== 'h1') return [];
1274 const sibling = el.previousElementSibling;
1275 if (!sibling) return [];
1276 const headStyle = getComputedStyle(el);
1277 const sibStyle = getComputedStyle(sibling);
1278 return checkHeroEyebrow({
1279 headingTag: tag,
1280 headingText: el.textContent || '',
1281 headingFontSize: parseFloat(headStyle.fontSize) || 0,
1282 siblingTag: sibling.tagName.toLowerCase(),
1283 siblingText: sibling.textContent || '',
1284 siblingTextTransform: sibStyle.textTransform || '',
1285 siblingFontSize: parseFloat(sibStyle.fontSize) || 0,
1286 siblingLetterSpacing: parseFloat(sibStyle.letterSpacing) || 0,
1287 siblingFontWeight: sibStyle.fontWeight || '',
1288 siblingColor: sibStyle.color || '',
1289 });
1290}
1291
1292// Build a map of CSS custom properties declared on :root / :host / html.
1293// Used to resolve var(--X) refs that jsdom returns verbatim in
1294// getComputedStyle. Tailwind v4 routes every utility class through
1295// CSS vars (font-weight: var(--font-weight-bold), font-size:
1296// var(--text-xs), letter-spacing: var(--tracking-widest)), so without
1297// resolution every style-based check silently fails on Tailwind v4
1298// builds — the values come back as literal "var(--font-weight-bold)"
1299// strings and parseFloat returns NaN.
1300function buildCustomPropMap(document) {
1301 const map = new Map();
1302 let sheets;
1303 try { sheets = Array.from(document.styleSheets || []); }
1304 catch { return map; }
1305 for (const sheet of sheets) {
1306 let rules;
1307 try { rules = Array.from(sheet.cssRules || []); }
1308 catch { continue; }
1309 for (const rule of rules) {
1310 // Style rules only (type 1). Walk @media / @supports if present.
1311 if (rule.type === 4 /* MEDIA_RULE */ || rule.type === 12 /* SUPPORTS_RULE */) {
1312 try { rules.push(...Array.from(rule.cssRules || [])); } catch { /* ignore */ }
1313 continue;
1314 }
1315 if (rule.type !== 1 /* STYLE_RULE */) continue;
1316 const sel = rule.selectorText || '';
1317 if (!/(^|,\s*)(:root|html|:host)\b/i.test(sel)) continue;
1318 const style = rule.style;
1319 if (!style) continue;
1320 for (let i = 0; i < style.length; i++) {
1321 const prop = style[i];
1322 if (!prop || !prop.startsWith('--')) continue;
1323 const val = style.getPropertyValue(prop).trim();
1324 if (val) map.set(prop, val);
1325 }
1326 }
1327 }
1328 return map;
1329}
1330
1331// Resolve var(--X[, fallback]) refs in a computed-style value string.
1332// Recurses up to 8 levels for chained refs (--a: var(--b)). Returns
1333// the original string when no refs are present or the chain doesn't
1334// resolve. Safe to call on already-resolved values.
1335function resolveVarRefs(raw, customPropMap, depth = 0) {
1336 if (typeof raw !== 'string' || !raw.includes('var(')) return raw;
1337 if (depth > 8) return raw;
1338 return raw.replace(/var\(\s*(--[a-zA-Z0-9_-]+)\s*(?:,\s*([^)]+))?\)/g, (_m, name, fallback) => {
1339 const v = customPropMap.get(name);
1340 if (v != null) return resolveVarRefs(v, customPropMap, depth + 1);
1341 return fallback ? resolveVarRefs(fallback.trim(), customPropMap, depth + 1) : _m;
1342 });
1343}
1344
1345// OKLCH → sRGB conversion (Björn Ottosson's matrices). L in 0..1 (or %),
1346// C in 0..~0.4 typical, H in degrees. Returns clamped {r,g,b,a:1} in 0..255.
1347// Needed because jsdom doesn't compute oklch() values — getComputedStyle
1348// returns the literal "oklch(...)" string. Without this, the entire
1349// Tailwind v4 color palette (which is OKLCH-based) is invisible to the
1350// detector's contrast / color checks.
1351function oklchToRgb(L, C, H) {
1352 const hRad = (H * Math.PI) / 180;
1353 const a = C * Math.cos(hRad);
1354 const b = C * Math.sin(hRad);
1355 const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
1356 const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
1357 const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
1358 const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;
1359 const rLin = 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc;
1360 const gLin = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc;
1361 const bLin = -0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc;
1362 const enc = (x) => {
1363 const c = Math.max(0, Math.min(1, x));
1364 return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
1365 };
1366 return {
1367 r: Math.round(enc(rLin) * 255),
1368 g: Math.round(enc(gLin) * 255),
1369 b: Math.round(enc(bLin) * 255),
1370 a: 1,
1371 };
1372}
1373
1374// Extended color parser: rgb/rgba/hex/oklch. Returns null on no match.
1375// Use this when the input might be any CSS color form; use plain parseRgb
1376// when you only expect computed rgb() values from real browsers.
1377function parseAnyColor(s) {
1378 if (!s || typeof s !== 'string') return null;
1379 const str = s.trim();
1380 if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;
1381 let m;
1382 m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+))?\s*\)/);
1383 if (m) return { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: m[4] !== undefined ? +m[4] : 1 };
1384 m = str.match(/^#([0-9a-f]{3,8})$/i);
1385 if (m) {
1386 const h = m[1];
1387 if (h.length === 3 || h.length === 4) {
1388 return {
1389 r: parseInt(h[0] + h[0], 16),
1390 g: parseInt(h[1] + h[1], 16),
1391 b: parseInt(h[2] + h[2], 16),
1392 a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
1393 };
1394 }
1395 if (h.length === 6 || h.length === 8) {
1396 return {
1397 r: parseInt(h.slice(0, 2), 16),
1398 g: parseInt(h.slice(2, 4), 16),
1399 b: parseInt(h.slice(4, 6), 16),
1400 a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
1401 };
1402 }
1403 }
1404 // OKLCH parser. Tailwind v4's CSS minifier squishes the space after
1405 // `%` ("21.5%.02 50"), so the separator between L and C may be absent.
1406 // Match L (with optional %), then C and H separated permissively.
1407 m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?\s*\)/i);
1408 if (m) {
1409 const Lnum = parseFloat(m[1]);
1410 const L = m[2] === '%' ? Lnum / 100 : Lnum;
1411 return oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
1412 }
1413 return null;
1414}
1415
1416// Resolve var() refs in a color string (via customPropMap), then parse.
1417// Returns null on any failure. Used in jsdom-mode paths where
1418// getComputedStyle returns literal "var(--X)" or "oklch(...)" strings.
1419function parseColorResolved(str, customPropMap) {
1420 if (!str) return null;
1421 const resolved = customPropMap ? resolveVarRefs(str, customPropMap) : str;
1422 return parseAnyColor(resolved);
1423}
1424
1425const REPEATED_KICKER_SKIP_SELECTOR = [
1426 'nav',
1427 'form',
1428 'table',
1429 'thead',
1430 'tbody',
1431 'tfoot',
1432 'figure',
1433 'figcaption',
1434 'ol',
1435 'ul',
1436 'li',
1437 '[role="navigation"]',
1438 '[aria-label*="breadcrumb" i]',
1439 '[class*="breadcrumb" i]',
1440 '[data-impeccable-allow-kickers]',
1441].join(',');
1442
1443function cleanInlineText(el) {
1444 return [...el.childNodes]
1445 .filter(n => n.nodeType === 3)
1446 .map(n => n.textContent)
1447 .join(' ')
1448 .replace(/\s+/g, ' ')
1449 .trim();
1450}
1451
1452function isRepeatedKickerCandidate(opts) {
1453 const {
1454 headingTag,
1455 headingText,
1456 headingFontSize,
1457 kickerTag,
1458 kickerText,
1459 kickerTextTransform,
1460 kickerFontSize,
1461 kickerLetterSpacing,
1462 } = opts;
1463 if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
1464 if (!headingText || headingText.length < 3) return false;
1465 if (!(headingFontSize >= 20)) return false;
1466 if (!kickerTag || HEADING_TAGS.has(kickerTag)) return false;
1467 if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false;
1468 if (!kickerText || kickerText.length < 2 || kickerText.length > 34) return false;
1469 if (/^step\s*\d+/i.test(kickerText) || /^\d{1,2}$/.test(kickerText)) return false;
1470
1471 const isUppercased = kickerTextTransform === 'uppercase'
1472 || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText));
1473 if (!isUppercased) return false;
1474 if (!(kickerFontSize > 0 && kickerFontSize <= 14)) return false;
1475 const minTrackedSpacing = Math.max(1, kickerFontSize * 0.08);
1476 if (!(kickerLetterSpacing >= minTrackedSpacing)) return false;
1477 return true;
1478}
1479
1480function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpacing) {
1481 const candidates = [];
1482 for (const heading of doc.querySelectorAll('h2, h3, h4')) {
1483 if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
1484 const kicker = heading.previousElementSibling;
1485 if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
1486
1487 const headingStyle = getStyle(heading);
1488 const kickerStyle = getStyle(kicker);
1489 const headingText = (heading.textContent || '').replace(/\s+/g, ' ').trim();
1490 const kickerText = cleanInlineText(kicker) || (kicker.textContent || '').replace(/\s+/g, ' ').trim();
1491 const headingFontSize = resolveLetterSpacing(headingStyle.fontSize || '', 16) || parseFloat(headingStyle.fontSize) || 0;
1492 const kickerFontSize = resolveLetterSpacing(kickerStyle.fontSize || '', 16) || parseFloat(kickerStyle.fontSize) || 0;
1493 const kickerLetterSpacing = resolveLetterSpacing(kickerStyle.letterSpacing || '', kickerFontSize);
1494
1495 if (!isRepeatedKickerCandidate({
1496 headingTag: heading.tagName.toLowerCase(),
1497 headingText,
1498 headingFontSize,
1499 kickerTag: kicker.tagName.toLowerCase(),
1500 kickerText,
1501 kickerTextTransform: kickerStyle.textTransform || '',
1502 kickerFontSize,
1503 kickerLetterSpacing,
1504 })) {
1505 continue;
1506 }
1507
1508 candidates.push({
1509 headingTag: heading.tagName.toLowerCase(),
1510 headingText: headingText.replace(/^"|"$/g, '').slice(0, 60),
1511 kickerText: kickerText.slice(0, 40),
1512 });
1513 }
1514 return candidates;
1515}
1516
1517function checkRepeatedSectionKickersDOM() {
1518 const candidates = collectRepeatedSectionKickerCandidates(
1519 document,
1520 (el) => getComputedStyle(el),
1521 (value, fontSize) => resolveLengthPx(value, fontSize) || 0,
1522 );
1523 return checkRepeatedSectionKickers({ candidates });
1524}
1525
1526function checkElementMotionDOM(el) {
1527 const tag = el.tagName.toLowerCase();
1528 if (SAFE_TAGS.has(tag)) return [];
1529 const style = getComputedStyle(el);
1530 return checkMotion({
1531 tag,
1532 transitionProperty: style.transitionProperty || '',
1533 animationName: style.animationName || '',
1534 timingFunctions: [style.animationTimingFunction, style.transitionTimingFunction].filter(Boolean).join(' '),
1535 classList: el.getAttribute('class') || '',
1536 });
1537}
1538
1539function checkElementGlowDOM(el) {
1540 const tag = el.tagName.toLowerCase();
1541 const style = getComputedStyle(el);
1542 if (!style.boxShadow || style.boxShadow === 'none') return [];
1543 // Use parent's background — glow radiates outward, so the surrounding context matters
1544 // If resolveBackground returns null (gradient), try to infer from the gradient colors
1545 let parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el);
1546 if (!parentBg) {
1547 // Gradient background — sample its colors to determine if it's dark
1548 let cur = el.parentElement;
1549 while (cur && cur.nodeType === 1) {
1550 const bgImage = getComputedStyle(cur).backgroundImage || '';
1551 const gradColors = parseGradientColors(bgImage);
1552 if (gradColors.length > 0) {
1553 // Average the gradient colors
1554 const avg = { r: 0, g: 0, b: 0 };
1555 for (const c of gradColors) { avg.r += c.r; avg.g += c.g; avg.b += c.b; }
1556 avg.r = Math.round(avg.r / gradColors.length);
1557 avg.g = Math.round(avg.g / gradColors.length);
1558 avg.b = Math.round(avg.b / gradColors.length);
1559 parentBg = avg;
1560 break;
1561 }
1562 cur = cur.parentElement;
1563 }
1564 }
1565 return checkGlow({ tag, boxShadow: style.boxShadow, effectiveBg: parentBg });
1566}
1567
1568function checkElementAIPaletteDOM(el) {
1569 const style = getComputedStyle(el);
1570 const findings = [];
1571
1572 // Check gradient backgrounds for purple/violet or cyan
1573 const bgImage = style.backgroundImage || '';
1574 const gradColors = parseGradientColors(bgImage);
1575 for (const c of gradColors) {
1576 if (hasChroma(c, 50)) {
1577 const hue = getHue(c);
1578 if (hue >= 260 && hue <= 310) {
1579 findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet gradient background' });
1580 break;
1581 }
1582 if (hue >= 160 && hue <= 200) {
1583 findings.push({ id: 'ai-color-palette', snippet: 'Cyan gradient background' });
1584 break;
1585 }
1586 }
1587 }
1588
1589 // Check for neon text (vivid cyan/purple color on dark background)
1590 const textColor = parseRgb(style.color);
1591 if (textColor && hasChroma(textColor, 80)) {
1592 const hue = getHue(textColor);
1593 const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
1594 if (isAIPalette) {
1595 const parentBg = el.parentElement ? resolveBackground(el.parentElement) : null;
1596 // Also check gradient parents
1597 let effectiveBg = parentBg;
1598 if (!effectiveBg) {
1599 let cur = el.parentElement;
1600 while (cur && cur.nodeType === 1) {
1601 const gi = getComputedStyle(cur).backgroundImage || '';
1602 const gc = parseGradientColors(gi);
1603 if (gc.length > 0) {
1604 const avg = { r: 0, g: 0, b: 0 };
1605 for (const c of gc) { avg.r += c.r; avg.g += c.g; avg.b += c.b; }
1606 avg.r = Math.round(avg.r / gc.length);
1607 avg.g = Math.round(avg.g / gc.length);
1608 avg.b = Math.round(avg.b / gc.length);
1609 effectiveBg = avg;
1610 break;
1611 }
1612 cur = cur.parentElement;
1613 }
1614 }
1615 if (effectiveBg && relativeLuminance(effectiveBg) < 0.1) {
1616 const label = hue >= 260 ? 'Purple/violet' : 'Cyan';
1617 findings.push({ id: 'ai-color-palette', snippet: `${label} neon text on dark background` });
1618 }
1619 }
1620 }
1621
1622 return findings;
1623}
1624
1625const QUALITY_TEXT_TAGS = new Set(['p', 'li', 'td', 'th', 'dd', 'blockquote', 'figcaption']);
1626
1627// Resolve a CSS font-size value to pixels by walking up the parent chain.
1628// Browsers resolve em/rem/% to px in getComputedStyle, but jsdom returns the
1629// specified value verbatim — so for the Node path we walk parents ourselves.
1630function resolveFontSizePx(el, win) {
1631 const chain = []; // raw font-size strings, leaf → root
1632 let cur = el;
1633 while (cur && cur.nodeType === 1) {
1634 const fs = (win ? win.getComputedStyle(cur) : getComputedStyle(cur)).fontSize;
1635 chain.push(fs || '');
1636 cur = cur.parentElement;
1637 }
1638 // Walk root → leaf, resolving each value relative to its parent context.
1639 let px = 16; // root default
1640 for (let i = chain.length - 1; i >= 0; i--) {
1641 const v = chain[i];
1642 if (!v || v === 'inherit') continue;
1643 const num = parseFloat(v);
1644 if (isNaN(num)) continue;
1645 if (v.endsWith('px')) px = num;
1646 else if (v.endsWith('rem')) px = num * 16;
1647 else if (v.endsWith('em')) px = num * px;
1648 else if (v.endsWith('%')) px = (num / 100) * px;
1649 else px = num; // unitless — already resolved
1650 }
1651 return px;
1652}
1653
1654// Resolve a CSS length value (line-height, letter-spacing, etc.) given a
1655// known font-size context. Returns null for "normal" / unparseable values.
1656function resolveLengthPx(value, fontSizePx) {
1657 if (!value || value === 'normal' || value === 'auto' || value === 'inherit') return null;
1658 const num = parseFloat(value);
1659 if (isNaN(num)) return null;
1660 if (value.endsWith('px')) return num;
1661 if (value.endsWith('rem')) return num * 16;
1662 if (value.endsWith('em')) return num * fontSizePx;
1663 if (value.endsWith('%')) return (num / 100) * fontSizePx;
1664 // Unitless line-height = multiplier, return px equivalent
1665 return num * fontSizePx;
1666}
1667
1668// Pure quality checks. Most run on computed CSS and DOM-only inputs (work in
1669// jsdom and the browser). Two checks (line-length, cramped-padding) gate on
1670// element rect dimensions, which jsdom can't compute — pass `rect: null` from
1671// the Node adapter to skip those.
1672//
1673// Both adapters resolve font-size, line-height and letter-spacing to pixels
1674// before calling this so the pure function only deals with numbers.
1675function checkQuality(opts) {
1676 const { el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax = 80, viewportWidth = 0 } = opts;
1677 const findings = [];
1678 // Skip browser extension injected elements
1679 const elId = el.id || '';
1680 if (elId.startsWith('claude-') || elId.startsWith('cic-')) return findings;
1681
1682 // --- Line length too long --- (browser-only: needs rect.width)
1683 if (rect && hasDirectText && QUALITY_TEXT_TAGS.has(tag) && rect.width > 0 && textLen > lineMax) {
1684 const charsPerLine = rect.width / (fontSize * 0.5);
1685 if (charsPerLine > lineMax + 5) {
1686 findings.push({ id: 'line-length', snippet: `~${Math.round(charsPerLine)} chars/line (aim for <${lineMax})` });
1687 }
1688 }
1689
1690 // --- Cramped padding --- (browser-only: needs rect to skip small badges/labels)
1691 // Vertical and horizontal thresholds are independent because line-height
1692 // already provides built-in vertical breathing room (the line box is taller
1693 // than the cap height), but horizontal has no equivalent. Both scale with
1694 // font-size — bigger text demands proportionally more padding.
1695 // vertical: max(4px, fontSize × 0.3)
1696 // horizontal: max(8px, fontSize × 0.5)
1697 if (rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) {
1698 const borders = {
1699 top: parseFloat(style.borderTopWidth) || 0,
1700 right: parseFloat(style.borderRightWidth) || 0,
1701 bottom: parseFloat(style.borderBottomWidth) || 0,
1702 left: parseFloat(style.borderLeftWidth) || 0,
1703 };
1704 const borderCount = Object.values(borders).filter(w => w > 0).length;
1705 const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)';
1706 if (borderCount >= 2 || hasBg) {
1707 const vPads = [], hPads = [];
1708 if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0);
1709 if (hasBg || borders.bottom > 0) vPads.push(parseFloat(style.paddingBottom) || 0);
1710 if (hasBg || borders.left > 0) hPads.push(parseFloat(style.paddingLeft) || 0);
1711 if (hasBg || borders.right > 0) hPads.push(parseFloat(style.paddingRight) || 0);
1712
1713 const vMin = vPads.length ? Math.min(...vPads) : Infinity;
1714 const hMin = hPads.length ? Math.min(...hPads) : Infinity;
1715 const vThresh = Math.max(4, fontSize * 0.3);
1716 const hThresh = Math.max(8, fontSize * 0.5);
1717
1718 // Emit at most one finding per element — pick whichever axis is worse.
1719 if (vMin < vThresh) {
1720 findings.push({ id: 'cramped-padding', snippet: `${vMin}px vertical padding (need ≥${vThresh.toFixed(1)}px for ${fontSize}px text)` });
1721 } else if (hMin < hThresh) {
1722 findings.push({ id: 'cramped-padding', snippet: `${hMin}px horizontal padding (need ≥${hThresh.toFixed(1)}px for ${fontSize}px text)` });
1723 }
1724 }
1725 }
1726
1727 // --- Body text touching viewport edge --- (browser-only: needs rect)
1728 // Catches the failure mode where the agent ships body paragraphs
1729 // with NO container providing horizontal padding — text bleeds
1730 // directly to the viewport edge. Different from cramped-padding,
1731 // which requires a colored/bordered container. Here the failure
1732 // is the absence of the container entirely.
1733 //
1734 // Gate aggressively to avoid false positives:
1735 // - <p> or <li> only (body content; not headings, not nav, not
1736 // wrappers)
1737 // - text > 40 chars (paragraph-like, not a label)
1738 // - rect.width > 50% of viewport (real body, not a pull-quote)
1739 // - rect.left < 16 OR rect.right > viewport - 16 (actually
1740 // touching the edge)
1741 // - not inside <nav> or <header> (those legitimately bleed)
1742 // - element itself has no background-color (intentional full-bleed
1743 // sections set a bg-color and provide their own internal padding)
1744 if (rect && hasDirectText && textLen > 40 && ['P', 'LI'].includes(tag.toUpperCase()) && viewportWidth > 0) {
1745 const inNavHeader = el.closest && (el.closest('nav') || el.closest('header'));
1746 const hasOwnBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)' && style.backgroundColor !== 'transparent';
1747 const isPositioned = ['fixed', 'absolute'].includes(style.position || '');
1748 const widthRatio = rect.width / viewportWidth;
1749 const leftClose = rect.left < 16;
1750 const rightClose = rect.right > viewportWidth - 16;
1751 if (!inNavHeader && !hasOwnBg && !isPositioned && widthRatio > 0.5 && (leftClose || rightClose)) {
1752 const which = leftClose && rightClose
1753 ? `left ${Math.round(rect.left)}px / right ${Math.round(viewportWidth - rect.right)}px`
1754 : leftClose
1755 ? `left ${Math.round(rect.left)}px`
1756 : `right ${Math.round(viewportWidth - rect.right)}px`;
1757 findings.push({ id: 'body-text-viewport-edge', snippet: `<${tag.toLowerCase()}> with ${textLen}-char body bleeds to viewport edge (${which})` });
1758 }
1759 }
1760
1761 // --- Tight line height ---
1762 if (hasDirectText && textLen > 50 && !['h1','h2','h3','h4','h5','h6'].includes(tag)) {
1763 if (lineHeightPx != null && fontSize > 0) {
1764 const ratio = lineHeightPx / fontSize;
1765 if (ratio > 0 && ratio < 1.3) {
1766 findings.push({ id: 'tight-leading', snippet: `line-height ${ratio.toFixed(2)}x (need >=1.3)` });
1767 }
1768 }
1769 }
1770
1771 // --- Justified text (without hyphens) ---
1772 if (hasDirectText && style.textAlign === 'justify') {
1773 const hyphens = style.hyphens || style.webkitHyphens || '';
1774 if (hyphens !== 'auto') {
1775 findings.push({ id: 'justified-text', snippet: 'text-align: justify without hyphens: auto' });
1776 }
1777 }
1778
1779 // --- Tiny body text ---
1780 // Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.)
1781 if (hasDirectText && textLen > 20 && fontSize < 12) {
1782 const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption'];
1783 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]');
1784 const isUppercase = style.textTransform === 'uppercase';
1785 if (!skipTags.includes(tag) && !inUIContext && !isUppercase) {
1786 findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` });
1787 }
1788 }
1789
1790 // --- All-caps body text ---
1791 if (hasDirectText && textLen > 30 && style.textTransform === 'uppercase') {
1792 if (!['h1','h2','h3','h4','h5','h6'].includes(tag)) {
1793 findings.push({ id: 'all-caps-body', snippet: `text-transform: uppercase on ${textLen} chars of body text` });
1794 }
1795 }
1796
1797 // --- Wide letter spacing on body text ---
1798 if (hasDirectText && textLen > 20 && style.textTransform !== 'uppercase') {
1799 if (letterSpacingPx != null && letterSpacingPx > 0 && fontSize > 0) {
1800 const trackingEm = letterSpacingPx / fontSize;
1801 if (trackingEm > 0.05) {
1802 findings.push({ id: 'wide-tracking', snippet: `letter-spacing: ${trackingEm.toFixed(2)}em on body text` });
1803 }
1804 }
1805 }
1806
1807 return findings;
1808}
1809
1810function checkElementQualityDOM(el) {
1811 const tag = el.tagName.toLowerCase();
1812 const style = getComputedStyle(el);
1813 const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 10);
1814 const textLen = el.textContent?.trim().length || 0;
1815 // Browser getComputedStyle resolves everything to px — direct parseFloat
1816 // works.
1817 const fontSize = parseFloat(style.fontSize) || 16;
1818 const lineHeightPx = resolveLengthPx(style.lineHeight, fontSize);
1819 const letterSpacingPx = resolveLengthPx(style.letterSpacing, fontSize);
1820 const rect = el.getBoundingClientRect();
1821 const lineMax = (typeof window !== 'undefined' && window.__IMPECCABLE_CONFIG__?.lineLengthMax) || 80;
1822 const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0;
1823 return checkQuality({ el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax, viewportWidth });
1824}
1825
1826// Pure page-level skipped-heading walk. Takes a Document so it works in both
1827// the browser and jsdom.
1828function checkPageQualityFromDoc(doc) {
1829 const findings = [];
1830 const headings = doc.querySelectorAll('h1, h2, h3, h4, h5, h6');
1831 let prevLevel = 0;
1832 let prevText = '';
1833 for (const h of headings) {
1834 const level = parseInt(h.tagName[1]);
1835 const text = (h.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 60);
1836 if (prevLevel > 0 && level > prevLevel + 1) {
1837 findings.push({
1838 id: 'skipped-heading',
1839 snippet: `<h${prevLevel}> "${prevText}" followed by <h${level}> "${text}" (missing h${prevLevel + 1})`,
1840 });
1841 }
1842 prevLevel = level;
1843 prevText = text;
1844 }
1845 return findings;
1846}
1847
1848// Browser adapter (returns the legacy { type, detail } shape used by the overlay loop)
1849function checkPageQualityDOM() {
1850 return checkPageQualityFromDoc(document).map(f => ({ type: f.id, detail: f.snippet }));
1851}
1852
1853// Node adapters — take pre-extracted jsdom computed style
1854
1855// jsdom doesn't lay out OR resolve em/rem/% to px — so we pre-resolve every
1856// CSS length the rule needs ourselves (walking the parent chain for
1857// font-size inheritance), and pass `rect: null` to skip the two rules that
1858// genuinely need element rects (line-length, cramped-padding).
1859function checkElementQuality(el, style, tag, window) {
1860 const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 10);
1861 const textLen = el.textContent?.trim().length || 0;
1862 const fontSize = resolveFontSizePx(el, window);
1863 const lineHeightPx = resolveLengthPx(style.lineHeight, fontSize);
1864 const letterSpacingPx = resolveLengthPx(style.letterSpacing, fontSize);
1865 return checkQuality({ el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect: null });
1866}
1867
1868function checkElementBorders(tag, style, overrides, resolvedRadius) {
1869 const sides = ['Top', 'Right', 'Bottom', 'Left'];
1870 const widths = {}, colors = {};
1871 for (const s of sides) {
1872 widths[s] = parseFloat(style[`border${s}Width`]) || 0;
1873 colors[s] = style[`border${s}Color`] || '';
1874 // jsdom silently drops any border shorthand containing var(), leaving
1875 // both width and color empty on the computed style. When the detectHtml
1876 // pre-pass pulled a resolved value off the rule, use it to fill in the
1877 // missing side so the side-tab check can run. Real browsers resolve
1878 // var() natively, so this fallback is a no-op in the browser path.
1879 if (widths[s] === 0 && overrides && overrides[s]) {
1880 widths[s] = overrides[s].width;
1881 colors[s] = overrides[s].color;
1882 } else if (colors[s] && colors[s].startsWith('var(') && overrides && overrides[s]) {
1883 // Longhand case: jsdom kept the width but left the color as the
1884 // literal `var(...)` string. Substitute the resolved color.
1885 colors[s] = overrides[s].color;
1886 }
1887 }
1888 // resolvedRadius lets the caller pre-resolve the radius via
1889 // resolveBorderRadiusPx so the value survives jsdom 29.1.0's broken
1890 // shorthand serialization. Falls back to the computed value for tests
1891 // and browser callers that don't pre-resolve.
1892 const radius = resolvedRadius != null
1893 ? resolvedRadius
1894 : (parseFloat(style.borderRadius) || 0);
1895 return checkBorders(tag, widths, colors, radius);
1896}
1897
1898function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInheritRule) {
1899 const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
1900 const hasDirectText = directText.trim().length > 0;
1901
1902 const effectiveBg = resolveBackground(el, window, customPropMap);
1903 // jsdom returns literal "var(--X)" / "oklch(...)" for color, so plain
1904 // parseRgb misses Tailwind-tokenized text colors. Resolve through the
1905 // customPropMap first; fall back to parseRgb for vanilla rgb() pages.
1906 let textColor = customPropMap ? parseColorResolved(style.color, customPropMap) : null;
1907 if (!textColor) textColor = parseRgb(style.color);
1908
1909 // Anchor-inherit FP workaround: jsdom's UA stylesheet has `:link { color:
1910 // blue }` at high specificity. The page's `a { color: inherit }` rule
1911 // (Tailwind v4 preflight) loses to jsdom even though it WINS in real
1912 // browsers (Chrome's UA wraps :link in :where() — zero specificity).
1913 // When the page declares the inherit rule AND we see jsdom's default
1914 // link blue on an anchor, walk to the nearest non-anchor ancestor and
1915 // use its color instead.
1916 if (
1917 hasAnchorInheritRule &&
1918 textColor &&
1919 textColor.r === 0 && textColor.g === 0 && textColor.b === 238 &&
1920 (tag === 'a' || el.closest?.('a'))
1921 ) {
1922 let cur = el.parentElement;
1923 while (cur && cur.tagName !== 'HTML') {
1924 if (cur.tagName !== 'A') {
1925 const ps = window.getComputedStyle(cur);
1926 const inh = (customPropMap ? parseColorResolved(ps.color, customPropMap) : null) || parseRgb(ps.color);
1927 if (inh && !(inh.r === 0 && inh.g === 0 && inh.b === 238)) {
1928 textColor = inh;
1929 break;
1930 }
1931 }
1932 cur = cur.parentElement;
1933 }
1934 }
1935
1936 return checkColors({
1937 tag,
1938 textColor,
1939 bgColor: readOwnBackgroundColor(el, style),
1940 effectiveBg,
1941 effectiveBgStops: effectiveBg ? null : resolveGradientStops(el, window),
1942 fontSize: parseFloat(style.fontSize) || 16,
1943 fontWeight: parseInt(style.fontWeight) || 400,
1944 hasDirectText,
1945 isEmojiOnly: isEmojiOnlyText(directText),
1946 bgClip: style.webkitBackgroundClip || style.backgroundClip || '',
1947 bgImage: style.backgroundImage || '',
1948 classList: el.getAttribute?.('class') || el.className || '',
1949 });
1950}
1951
1952function checkElementIconTile(el, tag, window) {
1953 if (!HEADING_TAGS.has(tag)) return [];
1954 const sibling = el.previousElementSibling;
1955 if (!sibling) return [];
1956
1957 const sibStyle = window.getComputedStyle(sibling);
1958 // jsdom doesn't lay out — read explicit pixel dimensions from CSS instead.
1959 const sibWidth = parseFloat(sibStyle.width) || 0;
1960 const sibHeight = parseFloat(sibStyle.height) || 0;
1961
1962 const iconChild = sibling.querySelector('svg, i[data-lucide], i[class*="fa-"], i[class*="icon"]');
1963 let iconWidth = 0;
1964 if (iconChild) {
1965 const iconStyle = window.getComputedStyle(iconChild);
1966 iconWidth = parseFloat(iconStyle.width) || parseFloat(iconChild.getAttribute('width')) || 0;
1967 }
1968 // Or: tile contains an emoji/symbol character directly as its only content
1969 const sibDirectText = [...sibling.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
1970 const hasInlineEmojiIcon = sibling.children.length === 0 && isEmojiOnlyText(sibDirectText);
1971
1972 return checkIconTile({
1973 headingTag: tag,
1974 headingText: el.textContent || '',
1975 headingTop: 0, // jsdom: no layout, skip vertical-stacking gate
1976 siblingTag: sibling.tagName.toLowerCase(),
1977 siblingWidth: sibWidth,
1978 siblingHeight: sibHeight,
1979 siblingBottom: 0,
1980 siblingBgColor: parseRgb(sibStyle.backgroundColor),
1981 siblingBgImage: sibStyle.backgroundImage || '',
1982 siblingBorderWidth: parseFloat(sibStyle.borderTopWidth) || 0,
1983 siblingBorderRadius: resolveBorderRadiusPx(sibling, sibStyle, sibWidth, window),
1984 hasIconChild: !!iconChild || hasInlineEmojiIcon,
1985 iconChildWidth: iconWidth,
1986 });
1987}
1988
1989function checkElementItalicSerif(el, style, tag) {
1990 if (tag !== 'h1' && tag !== 'h2') return [];
1991 return checkItalicSerif({
1992 tag,
1993 fontStyle: style.fontStyle || '',
1994 fontFamily: style.fontFamily || '',
1995 fontSize: parseFloat(style.fontSize) || 0,
1996 headingText: el.textContent || '',
1997 });
1998}
1999
2000function checkElementHeroEyebrow(el, style, tag, window, customPropMap) {
2001 if (tag !== 'h1') return [];
2002 const sibling = el.previousElementSibling;
2003 if (!sibling) return [];
2004 const sibStyle = window.getComputedStyle(sibling);
2005 // Resolve Tailwind v4 CSS-variable wrappers (font-weight:var(--font-weight-bold)
2006 // etc.) before parsing. jsdom returns these verbatim from getComputedStyle;
2007 // without resolution every style-based gate fails silently on Tailwind v4 builds.
2008 const fontSizeRaw = customPropMap ? resolveVarRefs(sibStyle.fontSize, customPropMap) : sibStyle.fontSize;
2009 const fontWeightRaw = customPropMap ? resolveVarRefs(sibStyle.fontWeight, customPropMap) : sibStyle.fontWeight;
2010 const letterSpacingRaw = customPropMap ? resolveVarRefs(sibStyle.letterSpacing, customPropMap) : sibStyle.letterSpacing;
2011 const colorRaw = customPropMap ? resolveVarRefs(sibStyle.color, customPropMap) : sibStyle.color;
2012 const headingFontSizeRaw = customPropMap ? resolveVarRefs(style.fontSize, customPropMap) : style.fontSize;
2013 const siblingFontSize = parseFloat(fontSizeRaw) || 0;
2014 // resolveLengthPx returns null for 'normal' / 'auto'; coerce to 0 so the
2015 // gate falls through cleanly. jsdom returns letter-spacing verbatim
2016 // (e.g. '0.15em'), unlike real browsers, so this conversion is required.
2017 return checkHeroEyebrow({
2018 headingTag: tag,
2019 headingText: el.textContent || '',
2020 headingFontSize: parseFloat(headingFontSizeRaw) || 0,
2021 siblingTag: sibling.tagName.toLowerCase(),
2022 siblingText: sibling.textContent || '',
2023 siblingTextTransform: sibStyle.textTransform || '',
2024 siblingFontSize,
2025 siblingLetterSpacing: resolveLengthPx(letterSpacingRaw, siblingFontSize) || 0,
2026 siblingFontWeight: fontWeightRaw || '',
2027 siblingColor: colorRaw || '',
2028 });
2029}
2030
2031function checkRepeatedSectionKickersFromDoc(doc, win) {
2032 const candidates = collectRepeatedSectionKickerCandidates(
2033 doc,
2034 (el) => win.getComputedStyle(el),
2035 (value, fontSize) => resolveLengthPx(value, fontSize) || 0,
2036 );
2037 return checkRepeatedSectionKickers({ candidates });
2038}
2039
2040function checkElementMotion(tag, style) {
2041 return checkMotion({
2042 tag,
2043 transitionProperty: style.transitionProperty || '',
2044 animationName: style.animationName || '',
2045 timingFunctions: [style.animationTimingFunction, style.transitionTimingFunction].filter(Boolean).join(' '),
2046 classList: '',
2047 });
2048}
2049
2050function checkElementGlow(tag, style, effectiveBg) {
2051 if (!style.boxShadow || style.boxShadow === 'none') return [];
2052 return checkGlow({ tag, boxShadow: style.boxShadow, effectiveBg });
2053}
2054
2055// ─── Section 6: Page-Level Checks ───────────────────────────────────────────
2056
2057// Browser page-level checks — use document/getComputedStyle globals
2058
2059function checkTypography() {
2060 const findings = [];
2061
2062 // Walk actual text-bearing elements and tally font usage by *computed style*.
2063 // This is much more accurate than scanning CSS rules — it ignores rules that
2064 // exist in the stylesheet but apply to nothing (e.g. demo classes showing
2065 // anti-patterns), and counts what the user actually sees.
2066 const fontUsage = new Map(); // primary font name → count of elements
2067 let totalTextElements = 0;
2068 for (const el of document.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, td, th, dd, blockquote, figcaption, a, button, label, span')) {
2069 // Skip impeccable's own elements
2070 if (el.closest && el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue;
2071 // Only count elements that actually have visible direct text
2072 const hasText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
2073 if (!hasText) continue;
2074 const style = getComputedStyle(el);
2075 const ff = style.fontFamily;
2076 if (!ff) continue;
2077 const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
2078 const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
2079 if (!primary) continue;
2080 fontUsage.set(primary, (fontUsage.get(primary) || 0) + 1);
2081 totalTextElements++;
2082 }
2083
2084 if (totalTextElements >= 20) {
2085 // A font is "primary" if it's used by at least 15% of text elements
2086 const PRIMARY_THRESHOLD = 0.15;
2087 for (const [font, count] of fontUsage) {
2088 const share = count / totalTextElements;
2089 if (share < PRIMARY_THRESHOLD) continue;
2090 if (!OVERUSED_FONTS.has(font)) continue;
2091 if (isBrandFontOnOwnDomain(font)) continue;
2092 findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
2093 }
2094
2095 // Single-font check: only one distinct primary font across all text
2096 if (fontUsage.size === 1) {
2097 const only = [...fontUsage.keys()][0];
2098 findings.push({ type: 'single-font', detail: `only font used is ${only}` });
2099 }
2100 }
2101
2102 const sizes = new Set();
2103 for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) {
2104 const fs = parseFloat(getComputedStyle(el).fontSize);
2105 if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10);
2106 }
2107 if (sizes.size >= 3) {
2108 const sorted = [...sizes].sort((a, b) => a - b);
2109 const ratio = sorted[sorted.length - 1] / sorted[0];
2110 if (ratio < 2.0) {
2111 findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` });
2112 }
2113 }
2114
2115 return findings;
2116}
2117
2118function isCardLikeDOM(el) {
2119 const tag = el.tagName.toLowerCase();
2120 if (SAFE_TAGS.has(tag) || ['input','select','textarea','img','video','canvas','picture'].includes(tag)) return false;
2121 const style = getComputedStyle(el);
2122 const cls = el.getAttribute('class') || '';
2123 const hasShadow = (style.boxShadow && style.boxShadow !== 'none') || /\bshadow(?:-sm|-md|-lg|-xl|-2xl)?\b/.test(cls);
2124 const hasBorder = /\bborder\b/.test(cls);
2125 const hasRadius = parseFloat(style.borderRadius) > 0 || /\brounded(?:-sm|-md|-lg|-xl|-2xl|-full)?\b/.test(cls);
2126 const hasBg = (style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)') || /\bbg-(?:white|gray-\d+|slate-\d+)\b/.test(cls);
2127 return isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg);
2128}
2129
2130function checkLayout() {
2131 const findings = [];
2132 const flaggedEls = new Set();
2133
2134 for (const el of document.querySelectorAll('*')) {
2135 if (!isCardLikeDOM(el) || flaggedEls.has(el)) continue;
2136 const cls = el.getAttribute('class') || '';
2137 const style = getComputedStyle(el);
2138 if (style.position === 'absolute' || style.position === 'fixed') continue;
2139 if (/\b(?:dropdown|popover|tooltip|menu|modal|dialog)\b/i.test(cls)) continue;
2140 if ((el.textContent?.trim().length || 0) < 10) continue;
2141 const rect = el.getBoundingClientRect();
2142 if (rect.width < 50 || rect.height < 30) continue;
2143
2144 let parent = el.parentElement;
2145 while (parent) {
2146 if (isCardLikeDOM(parent)) { flaggedEls.add(el); break; }
2147 parent = parent.parentElement;
2148 }
2149 }
2150
2151 for (const el of flaggedEls) {
2152 let isAncestor = false;
2153 for (const other of flaggedEls) {
2154 if (other !== el && el.contains(other)) { isAncestor = true; break; }
2155 }
2156 if (!isAncestor) findings.push({ type: 'nested-cards', detail: 'Card inside card', el });
2157 }
2158
2159 return findings;
2160}
2161
2162// Node page-level checks — take document/window as parameters
2163
2164function checkPageTypography(doc, win) {
2165 const findings = [];
2166
2167 const fonts = new Set();
2168 const overusedFound = new Set();
2169
2170 for (const sheet of doc.styleSheets) {
2171 let rules;
2172 try { rules = sheet.cssRules || sheet.rules; } catch { continue; }
2173 if (!rules) continue;
2174 for (const rule of rules) {
2175 if (rule.type !== 1) continue;
2176 const ff = rule.style?.fontFamily;
2177 if (!ff) continue;
2178 const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
2179 const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
2180 if (primary) {
2181 fonts.add(primary);
2182 if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
2183 }
2184 }
2185 }
2186
2187 // Check Google Fonts links in HTML
2188 const html = doc.documentElement?.outerHTML || '';
2189 const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
2190 let m;
2191 while ((m = gfRe.exec(html)) !== null) {
2192 const families = m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase());
2193 for (const f of families) {
2194 fonts.add(f);
2195 if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
2196 }
2197 }
2198
2199 // Also parse raw HTML/style content for font-family (jsdom may not expose all via CSSOM)
2200 const ffRe = /font-family\s*:\s*([^;}]+)/gi;
2201 let fm;
2202 while ((fm = ffRe.exec(html)) !== null) {
2203 for (const f of fm[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) {
2204 if (f && !GENERIC_FONTS.has(f)) {
2205 fonts.add(f);
2206 if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
2207 }
2208 }
2209 }
2210
2211 for (const font of overusedFound) {
2212 findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` });
2213 }
2214
2215 // Single font
2216 if (fonts.size === 1) {
2217 const els = doc.querySelectorAll('*');
2218 if (els.length >= 20) {
2219 findings.push({ id: 'single-font', snippet: `only font used is ${[...fonts][0]}` });
2220 }
2221 }
2222
2223 // Flat type hierarchy
2224 const sizes = new Set();
2225 const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div');
2226 for (const el of textEls) {
2227 const fontSize = parseFloat(win.getComputedStyle(el).fontSize);
2228 // Filter out sub-8px values (jsdom doesn't resolve relative units properly)
2229 if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10);
2230 }
2231 if (sizes.size >= 3) {
2232 const sorted = [...sizes].sort((a, b) => a - b);
2233 const ratio = sorted[sorted.length - 1] / sorted[0];
2234 if (ratio < 2.0) {
2235 findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` });
2236 }
2237 }
2238
2239 return findings;
2240}
2241
2242function isCardLike(el, win) {
2243 const tag = el.tagName.toLowerCase();
2244 if (SAFE_TAGS.has(tag) || ['input', 'select', 'textarea', 'img', 'video', 'canvas', 'picture'].includes(tag)) return false;
2245
2246 const style = win.getComputedStyle(el);
2247 const rawStyle = el.getAttribute?.('style') || '';
2248 const cls = el.getAttribute?.('class') || '';
2249
2250 const hasShadow = (style.boxShadow && style.boxShadow !== 'none') ||
2251 /\bshadow(?:-sm|-md|-lg|-xl|-2xl)?\b/.test(cls) || /box-shadow/i.test(rawStyle);
2252 const hasBorder = /\bborder\b/.test(cls);
2253 const widthPx = parseFloat(style.width) || 0;
2254 const hasRadius = resolveBorderRadiusPx(el, style, widthPx, win) > 0 ||
2255 /\brounded(?:-sm|-md|-lg|-xl|-2xl|-full)?\b/.test(cls) || /border-radius/i.test(rawStyle);
2256 const hasBg = /\bbg-(?:white|gray-\d+|slate-\d+)\b/.test(cls) ||
2257 /background(?:-color)?\s*:\s*(?!transparent)/i.test(rawStyle);
2258
2259 return isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg);
2260}
2261
2262function checkPageLayout(doc, win) {
2263 const findings = [];
2264
2265 // Nested cards
2266 const allEls = doc.querySelectorAll('*');
2267 const flaggedEls = new Set();
2268 for (const el of allEls) {
2269 if (!isCardLike(el, win)) continue;
2270 if (flaggedEls.has(el)) continue;
2271
2272 const tag = el.tagName.toLowerCase();
2273 const cls = el.getAttribute?.('class') || '';
2274 const rawStyle = el.getAttribute?.('style') || '';
2275
2276 if (['pre', 'code'].includes(tag)) continue;
2277 if (/\b(?:absolute|fixed)\b/.test(cls) || /position\s*:\s*(?:absolute|fixed)/i.test(rawStyle)) continue;
2278 if ((el.textContent?.trim().length || 0) < 10) continue;
2279 if (/\b(?:dropdown|popover|tooltip|menu|modal|dialog)\b/i.test(cls)) continue;
2280
2281 // Walk up to find card-like ancestor
2282 let parent = el.parentElement;
2283 while (parent) {
2284 if (isCardLike(parent, win)) {
2285 flaggedEls.add(el);
2286 break;
2287 }
2288 parent = parent.parentElement;
2289 }
2290 }
2291
2292 // Only report innermost nested cards
2293 for (const el of flaggedEls) {
2294 let isAncestorOfFlagged = false;
2295 for (const other of flaggedEls) {
2296 if (other !== el && el.contains(other)) {
2297 isAncestorOfFlagged = true;
2298 break;
2299 }
2300 }
2301 if (!isAncestorOfFlagged) {
2302 findings.push({ id: 'nested-cards', snippet: `Card inside card (${el.tagName.toLowerCase()})` });
2303 }
2304 }
2305
2306 // Everything centered
2307 const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, li, div, button');
2308 let centeredCount = 0;
2309 let totalText = 0;
2310 for (const el of textEls) {
2311 const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length >= 3);
2312 if (!hasDirectText) continue;
2313 totalText++;
2314
2315 let cur = el;
2316 let isCentered = false;
2317 while (cur && cur.nodeType === 1) {
2318 const rawStyle = cur.getAttribute?.('style') || '';
2319 const cls = cur.getAttribute?.('class') || '';
2320 if (/text-align\s*:\s*center/i.test(rawStyle) || /\btext-center\b/.test(cls)) {
2321 isCentered = true;
2322 break;
2323 }
2324 if (cur.tagName === 'BODY') break;
2325 cur = cur.parentElement;
2326 }
2327 if (isCentered) centeredCount++;
2328 }
2329
2330 if (totalText >= 5 && centeredCount / totalText > 0.7) {
2331 findings.push({
2332 id: 'everything-centered',
2333 snippet: `${centeredCount}/${totalText} text elements centered (${Math.round(centeredCount / totalText * 100)}%)`,
2334 });
2335 }
2336
2337 return findings;
2338}
2339
2340// --- cli/engine/browser/injected/index.mjs ---
2341const IS_BROWSER = typeof window !== 'undefined';
2342
2343// ─── Section 7: Browser UI (IS_BROWSER only) ────────────────────────────────
2344
2345if (IS_BROWSER) {
2346 // Detect extension mode via the script tag's data attribute or the document element fallback.
2347 // currentScript is reliable for synchronously-executing scripts (which our IIFE is).
2348 const _myScript = document.currentScript;
2349 const EXTENSION_MODE = (_myScript && _myScript.dataset.impeccableExtension === 'true')
2350 || document.documentElement.dataset.impeccableExtension === 'true';
2351
2352 const BRAND_COLOR = 'oklch(55% 0.25 350)';
2353 const BRAND_COLOR_HOVER = 'oklch(45% 0.25 350)';
2354 const LABEL_BG = BRAND_COLOR;
2355 const OUTLINE_COLOR = BRAND_COLOR;
2356
2357 // Inject hover styles via CSS (more reliable than JS event listeners)
2358 const styleEl = document.createElement('style');
2359 styleEl.textContent = `
2360 @keyframes impeccable-reveal {
2361 from { opacity: 0; }
2362 to { opacity: 1; }
2363 }
2364 .impeccable-overlay:not(.impeccable-banner) {
2365 pointer-events: none;
2366 outline: 2px solid ${OUTLINE_COLOR};
2367 border-radius: 4px;
2368 transition: outline-color 0.15s ease;
2369 animation: impeccable-reveal 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
2370 animation-play-state: paused;
2371 border-top-left-radius: 0;
2372 }
2373 .impeccable-overlay.impeccable-visible {
2374 animation-play-state: running;
2375 }
2376 .impeccable-overlay.impeccable-hover {
2377 outline-color: ${BRAND_COLOR_HOVER};
2378 z-index: 100001 !important;
2379 }
2380 .impeccable-overlay.impeccable-hover .impeccable-label {
2381 background: ${BRAND_COLOR_HOVER};
2382 }
2383 .impeccable-overlay.impeccable-spotlight {
2384 z-index: 100002 !important;
2385 }
2386 .impeccable-overlay.impeccable-spotlight-dimmed {
2387 opacity: 0.15 !important;
2388 animation: none !important;
2389 filter: blur(3px);
2390 }
2391 .impeccable-spotlight-backdrop {
2392 position: fixed;
2393 top: 0; left: 0; right: 0; bottom: 0;
2394 backdrop-filter: blur(3px) brightness(0.6);
2395 -webkit-backdrop-filter: blur(3px) brightness(0.6);
2396 pointer-events: none;
2397 z-index: 99998;
2398 opacity: 0;
2399 outline: none !important;
2400 animation: none !important;
2401 }
2402 .impeccable-spotlight-backdrop.impeccable-visible {
2403 opacity: 1;
2404 }
2405 .impeccable-hidden .impeccable-overlay${EXTENSION_MODE ? '' : ':not(.impeccable-banner)'} {
2406 display: none !important;
2407 }
2408 `;
2409 (document.head || document.documentElement).appendChild(styleEl);
2410
2411 // Spotlight backdrop element (created lazily on first use)
2412 let spotlightBackdrop = null;
2413 let spotlightTarget = null;
2414
2415 function getSpotlightBackdrop() {
2416 if (!spotlightBackdrop) {
2417 spotlightBackdrop = document.createElement('div');
2418 spotlightBackdrop.className = 'impeccable-spotlight-backdrop';
2419 document.body.appendChild(spotlightBackdrop);
2420 }
2421 return spotlightBackdrop;
2422 }
2423
2424 function updateSpotlightClipPath() {
2425 if (!spotlightBackdrop || !spotlightTarget) return;
2426 const r = spotlightTarget.getBoundingClientRect();
2427 // Match the overlay's outer edge: element rect + 4px (2px overlay offset + 2px outline width)
2428 const inset = 4;
2429 const radius = 6; // outline border-radius (4) + outline width (2)
2430 const x1 = r.left - inset;
2431 const y1 = r.top - inset;
2432 const x2 = r.right + inset;
2433 const y2 = r.bottom + inset;
2434 const vw = window.innerWidth;
2435 const vh = window.innerHeight;
2436 // Outer rect + rounded inner rect (evenodd creates a hole)
2437 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`;
2438 spotlightBackdrop.style.clipPath = `path(evenodd, "${path}")`;
2439 }
2440
2441 function showSpotlight(target) {
2442 if (!target || !target.getBoundingClientRect) return;
2443 // Respect the spotlightBlur setting: if disabled, don't show the backdrop
2444 if (window.__IMPECCABLE_CONFIG__?.spotlightBlur === false) {
2445 spotlightTarget = target;
2446 return;
2447 }
2448 spotlightTarget = target;
2449 const bd = getSpotlightBackdrop();
2450 updateSpotlightClipPath();
2451 bd.classList.add('impeccable-visible');
2452 }
2453
2454 function hideSpotlight() {
2455 spotlightTarget = null;
2456 if (spotlightBackdrop) spotlightBackdrop.classList.remove('impeccable-visible');
2457 }
2458
2459 function isInViewport(el) {
2460 const r = el.getBoundingClientRect();
2461 return r.top >= 0 && r.left >= 0 && r.bottom <= window.innerHeight && r.right <= window.innerWidth;
2462 }
2463
2464 // Reposition spotlight on scroll/resize
2465 window.addEventListener('scroll', () => {
2466 if (spotlightTarget) updateSpotlightClipPath();
2467 }, { passive: true });
2468 window.addEventListener('resize', () => {
2469 if (spotlightTarget) updateSpotlightClipPath();
2470 });
2471
2472 const overlays = [];
2473 const TYPE_LABELS = {};
2474 const RULE_CATEGORY = {};
2475 for (const ap of ANTIPATTERNS) {
2476 TYPE_LABELS[ap.id] = ap.name.toLowerCase();
2477 RULE_CATEGORY[ap.id] = ap.category || 'quality';
2478 }
2479
2480 function isInFixedContext(el) {
2481 let p = el;
2482 while (p && p !== document.body) {
2483 if (getComputedStyle(p).position === 'fixed') return true;
2484 p = p.parentElement;
2485 }
2486 return false;
2487 }
2488
2489 function positionOverlay(overlay) {
2490 const el = overlay._targetEl;
2491 if (!el) return;
2492 const rect = el.getBoundingClientRect();
2493 if (overlay._isFixed) {
2494 // Viewport-relative coords for fixed targets
2495 overlay.style.top = `${rect.top - 2}px`;
2496 overlay.style.left = `${rect.left - 2}px`;
2497 } else {
2498 // Document-relative coords for normal targets
2499 overlay.style.top = `${rect.top + scrollY - 2}px`;
2500 overlay.style.left = `${rect.left + scrollX - 2}px`;
2501 }
2502 overlay.style.width = `${rect.width + 4}px`;
2503 overlay.style.height = `${rect.height + 4}px`;
2504 }
2505
2506 function repositionOverlays() {
2507 for (const o of overlays) {
2508 if (!o._targetEl || o.classList.contains('impeccable-banner')) continue;
2509 // Skip overlays whose target is currently hidden (display: none on the overlay)
2510 if (o.style.display === 'none') continue;
2511 positionOverlay(o);
2512 }
2513 }
2514
2515 let resizeRAF;
2516 const onResize = () => {
2517 cancelAnimationFrame(resizeRAF);
2518 resizeRAF = requestAnimationFrame(repositionOverlays);
2519 };
2520 window.addEventListener('resize', onResize);
2521 // Reposition on scroll too -- catches sticky/parallax shifts
2522 window.addEventListener('scroll', onResize, { passive: true });
2523 // Reposition when body resizes (lazy-loaded images, dynamic content, fonts loading)
2524 if (typeof ResizeObserver !== 'undefined') {
2525 const bodyResizeObserver = new ResizeObserver(onResize);
2526 bodyResizeObserver.observe(document.body);
2527 }
2528
2529 // Track target element visibility via IntersectionObserver.
2530 // Uses a huge rootMargin so all *rendered* elements count as intersecting,
2531 // while display:none / closed <details> / hidden modals etc. do not.
2532 // This is event-driven -- no polling needed.
2533 let overlayIndex = 0;
2534 const visibilityObserver = new IntersectionObserver((entries) => {
2535 for (const entry of entries) {
2536 const overlay = entry.target._impeccableOverlay;
2537 if (!overlay) continue;
2538 if (entry.isIntersecting) {
2539 overlay.style.display = '';
2540 positionOverlay(overlay);
2541 if (!overlay._revealed) {
2542 overlay._revealed = true;
2543 if (firstScanDone) {
2544 // Subsequent reveals (re-scans, scroll-into-view): instant, no animation
2545 overlay.style.animation = 'none';
2546 } else {
2547 // Initial scan: staggered cascade reveal
2548 overlay.style.animationDelay = `${Math.min((overlay._staggerIndex || 0) * 60, 600)}ms`;
2549 }
2550 requestAnimationFrame(() => {
2551 overlay.classList.add('impeccable-visible');
2552 if (overlay._checkLabel) overlay._checkLabel();
2553 });
2554 }
2555 } else {
2556 overlay.style.display = 'none';
2557 }
2558 }
2559 }, { rootMargin: '99999px' });
2560
2561 function detachOverlay(overlay) {
2562 if (!overlay) return;
2563 if (typeof overlay._cleanup === 'function') {
2564 try { overlay._cleanup(); } catch { /* best effort overlay teardown */ }
2565 }
2566 if (overlay._targetEl && overlay._targetEl._impeccableOverlay === overlay) {
2567 visibilityObserver.unobserve(overlay._targetEl);
2568 delete overlay._targetEl._impeccableOverlay;
2569 }
2570 const idx = overlays.indexOf(overlay);
2571 if (idx >= 0) overlays.splice(idx, 1);
2572 overlay.remove();
2573 }
2574
2575 // Reposition overlays after CSS transitions end (e.g. reveal animations).
2576 // Listens at document level so it catches transitions on ancestor elements
2577 // (the transform may be on a parent, not the flagged element itself).
2578 document.addEventListener('transitionend', (e) => {
2579 if (e.propertyName !== 'transform') return;
2580 for (const o of overlays) {
2581 if (!o._targetEl || o.classList.contains('impeccable-banner') || o.style.display === 'none') continue;
2582 if (e.target === o._targetEl || e.target.contains(o._targetEl)) {
2583 positionOverlay(o);
2584 }
2585 }
2586 });
2587
2588 const highlight = function(el, findings) {
2589 if (el._impeccableOverlay) detachOverlay(el._impeccableOverlay);
2590 const hasSlop = findings.some(f => RULE_CATEGORY[f.type || f.id] === 'slop');
2591
2592 const fixed = isInFixedContext(el);
2593 const rect = el.getBoundingClientRect();
2594 const outline = document.createElement('div');
2595 outline.className = 'impeccable-overlay';
2596 outline._targetEl = el;
2597 outline._isFixed = fixed;
2598 Object.assign(outline.style, {
2599 position: fixed ? 'fixed' : 'absolute',
2600 top: fixed ? `${rect.top - 2}px` : `${rect.top + scrollY - 2}px`,
2601 left: fixed ? `${rect.left - 2}px` : `${rect.left + scrollX - 2}px`,
2602 width: `${rect.width + 4}px`, height: `${rect.height + 4}px`,
2603 zIndex: '99999', boxSizing: 'border-box',
2604 });
2605
2606 // Build per-finding label entries: ✦ prefix for slop
2607 const entries = findings.map(f => {
2608 const name = TYPE_LABELS[f.type || f.id] || f.type || f.id;
2609 const prefix = RULE_CATEGORY[f.type || f.id] === 'slop' ? '\u2726 ' : '';
2610 return { name: prefix + name, detail: f.detail || f.snippet };
2611 });
2612 const allText = entries.map(e => e.name).join(', ');
2613
2614 const label = document.createElement('div');
2615 label.className = 'impeccable-label';
2616 Object.assign(label.style, {
2617 position: 'absolute', bottom: '100%', left: '-2px',
2618 display: 'flex', alignItems: 'center',
2619 whiteSpace: 'nowrap',
2620 fontSize: '11px', fontWeight: '600', letterSpacing: '0.02em',
2621 color: 'white', lineHeight: '14px',
2622 background: LABEL_BG,
2623 fontFamily: 'system-ui, sans-serif',
2624 borderRadius: '4px 4px 0 0',
2625 });
2626
2627 const textSpan = document.createElement('span');
2628 textSpan.style.padding = '3px 8px';
2629 textSpan.textContent = allText;
2630 label.appendChild(textSpan);
2631
2632 // State for cycling mode
2633 let cycleMode = false;
2634 let cycleIndex = 0;
2635 let isHovered = false;
2636 let prevBtn, nextBtn;
2637
2638 function updateCycleText() {
2639 const e = entries[cycleIndex];
2640 textSpan.textContent = isHovered ? e.detail : e.name;
2641 }
2642
2643 function enableCycleMode() {
2644 if (cycleMode || entries.length < 2) return;
2645 cycleMode = true;
2646
2647 const btnStyle = {
2648 background: 'none', border: 'none', color: 'rgba(255,255,255,0.7)',
2649 fontSize: '11px', cursor: 'pointer', padding: '3px 4px',
2650 fontFamily: 'system-ui, sans-serif', lineHeight: '14px',
2651 pointerEvents: 'auto',
2652 };
2653
2654 const navGroup = document.createElement('span');
2655 Object.assign(navGroup.style, {
2656 display: 'inline-flex', alignItems: 'center', flexShrink: '0',
2657 });
2658
2659 prevBtn = document.createElement('button');
2660 prevBtn.textContent = '\u2039';
2661 Object.assign(prevBtn.style, btnStyle);
2662 prevBtn.style.paddingLeft = '6px';
2663 prevBtn.addEventListener('click', (e) => {
2664 e.stopPropagation();
2665 cycleIndex = (cycleIndex - 1 + entries.length) % entries.length;
2666 updateCycleText();
2667 });
2668
2669 nextBtn = document.createElement('button');
2670 nextBtn.textContent = '\u203A';
2671 Object.assign(nextBtn.style, btnStyle);
2672 nextBtn.style.paddingRight = '2px';
2673 nextBtn.addEventListener('click', (e) => {
2674 e.stopPropagation();
2675 cycleIndex = (cycleIndex + 1) % entries.length;
2676 updateCycleText();
2677 });
2678
2679 navGroup.appendChild(prevBtn);
2680 navGroup.appendChild(nextBtn);
2681 label.insertBefore(navGroup, textSpan);
2682 textSpan.style.padding = '3px 8px 3px 4px';
2683 updateCycleText();
2684 }
2685
2686 outline.appendChild(label);
2687
2688 // Start hidden; the IntersectionObserver will show it once the target is rendered
2689 outline.style.display = 'none';
2690 outline._staggerIndex = overlayIndex++;
2691 el._impeccableOverlay = outline;
2692 visibilityObserver.observe(el);
2693
2694 // After first paint, check label width vs outline
2695 outline._checkLabel = () => {
2696 if (entries.length > 1 && label.offsetWidth > outline.offsetWidth) {
2697 enableCycleMode();
2698 }
2699 };
2700
2701 // Hover: show detail text, darken
2702 const onMouseEnter = () => {
2703 isHovered = true;
2704 outline.classList.add('impeccable-hover');
2705 outline.style.outlineColor = BRAND_COLOR_HOVER;
2706 label.style.background = BRAND_COLOR_HOVER;
2707 if (cycleMode) {
2708 updateCycleText();
2709 } else {
2710 textSpan.textContent = entries.map(e => e.detail).join(' | ');
2711 }
2712 };
2713 const onMouseLeave = () => {
2714 isHovered = false;
2715 outline.classList.remove('impeccable-hover');
2716 outline.style.outlineColor = '';
2717 label.style.background = LABEL_BG;
2718 if (cycleMode) {
2719 updateCycleText();
2720 } else {
2721 textSpan.textContent = allText;
2722 }
2723 };
2724 el.addEventListener('mouseenter', onMouseEnter);
2725 el.addEventListener('mouseleave', onMouseLeave);
2726 outline._cleanup = () => {
2727 el.removeEventListener('mouseenter', onMouseEnter);
2728 el.removeEventListener('mouseleave', onMouseLeave);
2729 };
2730
2731 document.body.appendChild(outline);
2732 overlays.push(outline);
2733 };
2734
2735 const showPageBanner = function(findings) {
2736 if (!findings.length) return;
2737 const banner = document.createElement('div');
2738 banner.className = 'impeccable-overlay impeccable-banner';
2739 Object.assign(banner.style, {
2740 position: 'fixed', top: '0', left: '0', right: '0', zIndex: '100000',
2741 background: LABEL_BG, color: 'white',
2742 fontFamily: 'system-ui, sans-serif', fontSize: '13px',
2743 display: 'flex', alignItems: 'center', pointerEvents: 'auto',
2744 height: '36px', overflow: 'hidden', maxWidth: '100vw',
2745 transform: 'translateY(-100%)',
2746 transition: 'transform 0.4s cubic-bezier(0.16, 1, 0.3, 1)',
2747 });
2748 requestAnimationFrame(() => requestAnimationFrame(() => {
2749 banner.style.transform = 'translateY(0)';
2750 }));
2751
2752 // Scrollable findings area
2753 const scrollArea = document.createElement('div');
2754 Object.assign(scrollArea.style, {
2755 flex: '1', minWidth: '0', overflowX: 'auto', overflowY: 'hidden',
2756 display: 'flex', gap: '8px', alignItems: 'center',
2757 padding: '0 12px', scrollSnapType: 'x mandatory',
2758 scrollbarWidth: 'none',
2759 });
2760 for (const f of findings) {
2761 const prefix = RULE_CATEGORY[f.type] === 'slop' ? '\u2726 ' : '';
2762 const tag = document.createElement('span');
2763 tag.textContent = `${prefix}${TYPE_LABELS[f.type] || f.type}: ${f.detail}`;
2764 Object.assign(tag.style, {
2765 background: 'rgba(255,255,255,0.15)', padding: '2px 8px',
2766 borderRadius: '3px', fontSize: '12px', fontFamily: 'ui-monospace, monospace',
2767 whiteSpace: 'nowrap', flexShrink: '0', scrollSnapAlign: 'start',
2768 });
2769 scrollArea.appendChild(tag);
2770 }
2771 banner.appendChild(scrollArea);
2772
2773 // Controls area (only in standalone mode, not extension)
2774 if (!EXTENSION_MODE) {
2775 const controls = document.createElement('div');
2776 Object.assign(controls.style, {
2777 display: 'flex', alignItems: 'center', gap: '2px',
2778 padding: '0 8px', flexShrink: '0',
2779 });
2780
2781 // Toggle visibility button
2782 const toggle = document.createElement('button');
2783 toggle.textContent = '\u25C9'; // circle with dot (visible state)
2784 toggle.title = 'Toggle overlay visibility';
2785 Object.assign(toggle.style, {
2786 background: 'none', border: 'none',
2787 color: 'white', fontSize: '16px', cursor: 'pointer', padding: '0 4px',
2788 opacity: '0.85', transition: 'opacity 0.15s',
2789 });
2790 let overlaysVisible = true;
2791 toggle.addEventListener('click', () => {
2792 overlaysVisible = !overlaysVisible;
2793 document.body.classList.toggle('impeccable-hidden', !overlaysVisible);
2794 toggle.textContent = overlaysVisible ? '\u25C9' : '\u25CB'; // filled vs empty circle
2795 toggle.style.opacity = overlaysVisible ? '0.85' : '0.5';
2796 });
2797 controls.appendChild(toggle);
2798
2799 // Close button
2800 const close = document.createElement('button');
2801 close.textContent = '\u00d7';
2802 close.title = 'Dismiss banner';
2803 Object.assign(close.style, {
2804 background: 'none', border: 'none',
2805 color: 'white', fontSize: '18px', cursor: 'pointer', padding: '0 4px',
2806 });
2807 close.addEventListener('click', () => banner.remove());
2808 controls.appendChild(close);
2809
2810 banner.appendChild(controls);
2811 }
2812 document.body.appendChild(banner);
2813 overlays.push(banner);
2814 };
2815
2816 // Heuristic for skipping CSS-in-JS hashed class names like "css-1a2b3c" or "_2x4hG_".
2817 // These change between builds and produce brittle, ugly selectors.
2818 function isLikelyHashedClass(c) {
2819 if (!c) return true;
2820 if (/^(css|sc|emotion|jsx|module)-[\w-]{4,}$/i.test(c)) return true;
2821 if (/^_[\w-]{5,}$/.test(c)) return true;
2822 if (/^[a-z0-9]{6,}$/i.test(c) && /\d/.test(c)) return true;
2823 return false;
2824 }
2825
2826 function buildSelectorSegment(el) {
2827 const tag = el.tagName.toLowerCase();
2828 let sel = tag;
2829
2830 if (el.classList && el.classList.length > 0) {
2831 const classes = [...el.classList]
2832 .filter(c => !c.startsWith('impeccable-') && !isLikelyHashedClass(c))
2833 .slice(0, 2);
2834 if (classes.length > 0) {
2835 sel += '.' + classes.map(c => CSS.escape(c)).join('.');
2836 }
2837 }
2838
2839 // Disambiguate among siblings only if the parent has multiple matches
2840 const parent = el.parentElement;
2841 if (parent) {
2842 try {
2843 const matching = parent.querySelectorAll(':scope > ' + sel);
2844 if (matching.length > 1) {
2845 const sameType = [...parent.children].filter(c => c.tagName === el.tagName);
2846 const idx = sameType.indexOf(el) + 1;
2847 sel += `:nth-of-type(${idx})`;
2848 }
2849 } catch {
2850 const idx = [...parent.children].indexOf(el) + 1;
2851 sel = `${tag}:nth-child(${idx})`;
2852 }
2853 }
2854 return sel;
2855 }
2856
2857 function generateSelector(el) {
2858 if (el === document.body) return 'body';
2859 if (el === document.documentElement) return 'html';
2860 if (el.id) return '#' + CSS.escape(el.id);
2861
2862 const parts = [];
2863 let current = el;
2864 let depth = 0;
2865 const MAX_DEPTH = 10;
2866
2867 while (current && current !== document.body && current !== document.documentElement && depth < MAX_DEPTH) {
2868 parts.unshift(buildSelectorSegment(current));
2869
2870 // Anchor on an ancestor's ID and stop walking up
2871 if (current.id) {
2872 parts[0] = '#' + CSS.escape(current.id);
2873 break;
2874 }
2875
2876 // Stop as soon as the partial selector uniquely identifies the target
2877 const trySelector = parts.join(' > ');
2878 try {
2879 const matches = document.querySelectorAll(trySelector);
2880 if (matches.length === 1 && matches[0] === el) {
2881 return trySelector;
2882 }
2883 } catch { /* invalid selector — keep walking */ }
2884
2885 current = current.parentElement;
2886 depth++;
2887 }
2888
2889 return parts.join(' > ');
2890 }
2891
2892 function getDirectText(el) {
2893 return [...el.childNodes]
2894 .filter(n => n.nodeType === 3)
2895 .map(n => n.textContent || '')
2896 .join('');
2897 }
2898
2899 function getDirectTextRect(el) {
2900 const rects = [];
2901 for (const node of el.childNodes) {
2902 if (node.nodeType !== 3 || !(node.textContent || '').trim()) continue;
2903 const range = document.createRange();
2904 range.selectNodeContents(node);
2905 for (const rect of range.getClientRects()) {
2906 if (rect.width >= 1 && rect.height >= 1) rects.push(rect);
2907 }
2908 range.detach?.();
2909 }
2910 if (rects.length === 0) return null;
2911 const left = Math.min(...rects.map(r => r.left));
2912 const top = Math.min(...rects.map(r => r.top));
2913 const right = Math.max(...rects.map(r => r.right));
2914 const bottom = Math.max(...rects.map(r => r.bottom));
2915 return {
2916 left,
2917 top,
2918 right,
2919 bottom,
2920 width: right - left,
2921 height: bottom - top,
2922 x: left,
2923 y: top,
2924 };
2925 }
2926
2927 function collectVisualContrastReasons(el, style) {
2928 const reasons = new Set();
2929 const bgClip = style.webkitBackgroundClip || style.backgroundClip || '';
2930 const ownBgImage = style.backgroundImage || '';
2931 if (bgClip === 'text' && ownBgImage && ownBgImage !== 'none') {
2932 reasons.add('background-clip text');
2933 }
2934 if (style.textShadow && style.textShadow !== 'none') reasons.add('text shadow');
2935
2936 let current = el;
2937 while (current && current.nodeType === 1) {
2938 const tag = current.tagName?.toLowerCase();
2939 const currentStyle = getComputedStyle(current);
2940 const bgImage = currentStyle.backgroundImage || '';
2941 const isDocumentSurface = tag === 'body' || tag === 'html';
2942
2943 if (!isDocumentSurface && bgImage && bgImage !== 'none') {
2944 if (/url\s*\(/i.test(bgImage)) reasons.add('image background');
2945 if (/gradient/i.test(bgImage)) reasons.add('gradient background');
2946 }
2947 if (parseFloat(currentStyle.opacity) < 0.99) reasons.add('opacity stack');
2948 if (currentStyle.mixBlendMode && currentStyle.mixBlendMode !== 'normal') reasons.add('blend mode');
2949 if (currentStyle.filter && currentStyle.filter !== 'none') reasons.add('filter');
2950 if (currentStyle.backdropFilter && currentStyle.backdropFilter !== 'none') reasons.add('backdrop filter');
2951
2952 const solidBg = parseRgb(currentStyle.backgroundColor);
2953 if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break;
2954 current = current.parentElement;
2955 }
2956
2957 const sampleRect = getDirectTextRect(el) || el.getBoundingClientRect();
2958 if (sampleRect && document.elementsFromPoint) {
2959 const points = [
2960 [sampleRect.left + sampleRect.width / 2, sampleRect.top + sampleRect.height / 2],
2961 [sampleRect.left + Math.min(sampleRect.width - 1, Math.max(1, sampleRect.width * 0.25)), sampleRect.top + sampleRect.height / 2],
2962 [sampleRect.left + Math.min(sampleRect.width - 1, Math.max(1, sampleRect.width * 0.75)), sampleRect.top + sampleRect.height / 2],
2963 ];
2964 for (const [x, y] of points) {
2965 if (x < 0 || y < 0 || x > window.innerWidth || y > window.innerHeight) continue;
2966 const stack = document.elementsFromPoint(x, y);
2967 const selfIndex = stack.findIndex(node => node === el || el.contains(node) || node.contains?.(el));
2968 if (selfIndex < 0) continue;
2969 for (const node of stack.slice(selfIndex + 1)) {
2970 const nodeTag = node.tagName?.toLowerCase();
2971 if (nodeTag === 'img' || nodeTag === 'picture' || nodeTag === 'video' || nodeTag === 'canvas' || nodeTag === 'svg') {
2972 reasons.add(`${nodeTag} underlay`);
2973 break;
2974 }
2975 }
2976 }
2977 }
2978
2979 return [...reasons];
2980 }
2981
2982 function collectVisualContrastCandidates(options = {}) {
2983 const maxCandidates = Number.isFinite(options.maxCandidates) ? options.maxCandidates : 12;
2984 const candidates = [];
2985 for (const el of document.querySelectorAll('*')) {
2986 if (candidates.length >= maxCandidates) break;
2987 if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue;
2988 if (el.closest('[id^="impeccable-live-"]')) continue;
2989 if (el === document.body || el === document.documentElement) continue;
2990
2991 const tag = el.tagName.toLowerCase();
2992 const style = getComputedStyle(el);
2993 if (style.display === 'none' || style.visibility === 'hidden') continue;
2994 const directText = getDirectText(el);
2995 const hasDirectText = directText.trim().length > 0;
2996 if (!hasDirectText || isEmojiOnlyText(directText)) continue;
2997
2998 const bgColor = readOwnBackgroundColor(el, style);
2999 const isStyledButton = (tag === 'a' || tag === 'button')
3000 && bgColor && bgColor.a > 0.5;
3001 if (SAFE_TAGS.has(tag) && !isStyledButton) continue;
3002
3003 const rect = getDirectTextRect(el) || el.getBoundingClientRect();
3004 if (!rect || rect.width < 4 || rect.height < 4) continue;
3005
3006 const reasons = collectVisualContrastReasons(el, style);
3007 if (reasons.length === 0) continue;
3008
3009 const textColor = parseRgb(style.color);
3010 const fontSize = parseFloat(style.fontSize) || 16;
3011 const fontWeight = parseInt(style.fontWeight) || 400;
3012 const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700);
3013 const threshold = isLargeText ? 3.0 : 4.5;
3014 const clip = {
3015 x: Math.max(0, Math.floor(rect.left + window.scrollX - 2)),
3016 y: Math.max(0, Math.floor(rect.top + window.scrollY - 2)),
3017 width: Math.max(1, Math.ceil(rect.width + 4)),
3018 height: Math.max(1, Math.ceil(rect.height + 4)),
3019 };
3020
3021 candidates.push({
3022 selector: generateSelector(el),
3023 tagName: tag,
3024 text: directText.trim().replace(/\s+/g, ' ').slice(0, 80),
3025 threshold,
3026 reasons,
3027 clip,
3028 textColor,
3029 preferRenderedForeground: !textColor || textColor.a < 0.99 || reasons.some(reason =>
3030 reason === 'opacity stack' ||
3031 reason === 'blend mode' ||
3032 reason === 'filter' ||
3033 reason === 'backdrop filter' ||
3034 reason === 'background-clip text'
3035 ),
3036 backgroundClipText: reasons.includes('background-clip text'),
3037 });
3038 }
3039 return candidates;
3040 }
3041
3042 const visualContrastImageCache = new Map();
3043 const visualContrastRasterCache = new WeakMap();
3044
3045 function clampByte(value) {
3046 return Math.max(0, Math.min(255, Math.round(value)));
3047 }
3048
3049 function blendRgba(fg, bg) {
3050 if (!fg) return bg || null;
3051 if (!bg || fg.a == null || fg.a >= 0.999) {
3052 return { r: clampByte(fg.r), g: clampByte(fg.g), b: clampByte(fg.b), a: fg.a == null ? 1 : fg.a };
3053 }
3054 const alpha = Math.max(0, Math.min(1, fg.a));
3055 return {
3056 r: clampByte(fg.r * alpha + bg.r * (1 - alpha)),
3057 g: clampByte(fg.g * alpha + bg.g * (1 - alpha)),
3058 b: clampByte(fg.b * alpha + bg.b * (1 - alpha)),
3059 a: 1,
3060 };
3061 }
3062
3063 function pickWorstContrastColor(textColor, colors) {
3064 const usable = (colors || []).filter(Boolean);
3065 if (!usable.length) return null;
3066 let worst = usable[0];
3067 let worstRatio = contrastRatio(textColor, worst);
3068 for (const color of usable.slice(1)) {
3069 const ratio = contrastRatio(textColor, color);
3070 if (ratio < worstRatio) {
3071 worst = color;
3072 worstRatio = ratio;
3073 }
3074 }
3075 return worst;
3076 }
3077
3078 function firstCssUrl(value) {
3079 const match = String(value || '').match(/url\((?:"([^"]+)"|'([^']+)'|([^)]*))\)/i);
3080 if (!match) return '';
3081 return (match[1] || match[2] || match[3] || '').trim();
3082 }
3083
3084 function getLayerValue(value, index = 0) {
3085 return String(value || '').split(',')[index]?.trim() || '';
3086 }
3087
3088 function parsePositionToken(token, container, painted) {
3089 if (!token || token === 'center') return (container - painted) / 2;
3090 if (token === 'left' || token === 'top') return 0;
3091 if (token === 'right' || token === 'bottom') return container - painted;
3092 if (/%$/.test(token)) {
3093 const pct = parseFloat(token) / 100;
3094 return (container - painted) * pct;
3095 }
3096 if (/px$/.test(token)) return parseFloat(token) || 0;
3097 return (container - painted) / 2;
3098 }
3099
3100 function parsePositionPair(positionValue) {
3101 const tokens = String(positionValue || '50% 50%').trim().split(/\s+/).filter(Boolean);
3102 const first = tokens[0] || '50%';
3103 if (tokens.length < 2) {
3104 if (first === 'top' || first === 'bottom') return ['50%', first];
3105 return [first, '50%'];
3106 }
3107 return [first, tokens[1] || '50%'];
3108 }
3109
3110 function resolvePaintedImageRect(containerRect, image, sizeValue, positionValue) {
3111 const intrinsicWidth = image.naturalWidth || image.videoWidth || image.width || 1;
3112 const intrinsicHeight = image.naturalHeight || image.videoHeight || image.height || 1;
3113 let paintedWidth = intrinsicWidth;
3114 let paintedHeight = intrinsicHeight;
3115 const size = String(sizeValue || 'auto').trim();
3116
3117 if (size === 'cover' || size === 'contain') {
3118 const scale = size === 'cover'
3119 ? Math.max(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight)
3120 : Math.min(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight);
3121 paintedWidth = intrinsicWidth * scale;
3122 paintedHeight = intrinsicHeight * scale;
3123 } else if (size && size !== 'auto') {
3124 const parts = size.split(/\s+/);
3125 const widthToken = parts[0];
3126 const heightToken = parts[1] || 'auto';
3127 if (/%$/.test(widthToken)) paintedWidth = containerRect.width * (parseFloat(widthToken) / 100);
3128 else if (/px$/.test(widthToken)) paintedWidth = parseFloat(widthToken) || paintedWidth;
3129 if (heightToken === 'auto') paintedHeight = paintedWidth * (intrinsicHeight / intrinsicWidth);
3130 else if (/%$/.test(heightToken)) paintedHeight = containerRect.height * (parseFloat(heightToken) / 100);
3131 else if (/px$/.test(heightToken)) paintedHeight = parseFloat(heightToken) || paintedHeight;
3132 }
3133
3134 const [xToken, yToken] = parsePositionPair(positionValue);
3135 const positionX = parsePositionToken(xToken, containerRect.width, paintedWidth);
3136 const positionY = parsePositionToken(yToken, containerRect.height, paintedHeight);
3137 return {
3138 left: containerRect.left + positionX,
3139 top: containerRect.top + positionY,
3140 width: paintedWidth,
3141 height: paintedHeight,
3142 intrinsicWidth,
3143 intrinsicHeight,
3144 };
3145 }
3146
3147 function parseObjectPosition(positionValue) {
3148 return parsePositionPair(positionValue);
3149 }
3150
3151 function resolveObjectImageRect(containerRect, image, style) {
3152 const intrinsicWidth = image.naturalWidth || image.videoWidth || image.width || 1;
3153 const intrinsicHeight = image.naturalHeight || image.videoHeight || image.height || 1;
3154 const fit = style.objectFit || 'fill';
3155 let paintedWidth = containerRect.width;
3156 let paintedHeight = containerRect.height;
3157 if (fit === 'contain' || fit === 'cover') {
3158 const scale = fit === 'cover'
3159 ? Math.max(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight)
3160 : Math.min(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight);
3161 paintedWidth = intrinsicWidth * scale;
3162 paintedHeight = intrinsicHeight * scale;
3163 } else if (fit === 'none') {
3164 paintedWidth = intrinsicWidth;
3165 paintedHeight = intrinsicHeight;
3166 } else if (fit === 'scale-down') {
3167 const containScale = Math.min(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight, 1);
3168 paintedWidth = intrinsicWidth * containScale;
3169 paintedHeight = intrinsicHeight * containScale;
3170 }
3171 const [xToken, yToken] = parseObjectPosition(style.objectPosition);
3172 return {
3173 left: containerRect.left + parsePositionToken(xToken, containerRect.width, paintedWidth),
3174 top: containerRect.top + parsePositionToken(yToken, containerRect.height, paintedHeight),
3175 width: paintedWidth,
3176 height: paintedHeight,
3177 intrinsicWidth,
3178 intrinsicHeight,
3179 };
3180 }
3181
3182 function pointToImageSource(point, paintedRect) {
3183 if (
3184 point.x < paintedRect.left ||
3185 point.y < paintedRect.top ||
3186 point.x > paintedRect.left + paintedRect.width ||
3187 point.y > paintedRect.top + paintedRect.height
3188 ) {
3189 return null;
3190 }
3191 return {
3192 x: Math.max(0, Math.min(paintedRect.intrinsicWidth - 1, ((point.x - paintedRect.left) / paintedRect.width) * paintedRect.intrinsicWidth)),
3193 y: Math.max(0, Math.min(paintedRect.intrinsicHeight - 1, ((point.y - paintedRect.top) / paintedRect.height) * paintedRect.intrinsicHeight)),
3194 };
3195 }
3196
3197 async function loadVisualContrastImage(src) {
3198 if (!src) return null;
3199 if (visualContrastImageCache.has(src)) return visualContrastImageCache.get(src);
3200 const promise = new Promise(resolve => {
3201 const img = new Image();
3202 let settled = false;
3203 const finish = value => {
3204 if (settled) return;
3205 settled = true;
3206 clearTimeout(timer);
3207 resolve(value);
3208 };
3209 const timer = setTimeout(() => finish(null), 800);
3210 try {
3211 const absolute = new URL(src, location.href);
3212 if (absolute.origin !== location.origin && absolute.protocol !== 'data:' && absolute.protocol !== 'blob:') {
3213 img.crossOrigin = 'anonymous';
3214 }
3215 } catch {
3216 // Let the browser resolve unusual URLs itself.
3217 }
3218 img.onload = () => finish(img);
3219 img.onerror = () => finish(null);
3220 img.src = src;
3221 });
3222 visualContrastImageCache.set(src, promise);
3223 return promise;
3224 }
3225
3226 function sampleDrawablePixel(drawable, sourcePoint) {
3227 if (visualContrastRasterCache.has(drawable)) {
3228 const cached = visualContrastRasterCache.get(drawable);
3229 if (!cached || !cached.ctx) return { status: 'unresolved', reason: cached?.reason || 'image sample failed' };
3230 try {
3231 const x = Math.max(0, Math.min(cached.width - 1, Math.floor(sourcePoint.x * cached.scaleX)));
3232 const y = Math.max(0, Math.min(cached.height - 1, Math.floor(sourcePoint.y * cached.scaleY)));
3233 const data = cached.ctx.getImageData(x, y, 1, 1).data;
3234 return {
3235 status: 'sampled',
3236 color: { r: data[0], g: data[1], b: data[2], a: data[3] / 255 },
3237 };
3238 } catch (err) {
3239 return {
3240 status: 'unresolved',
3241 reason: /taint|cross-origin|Security/i.test(err?.message || '') ? 'tainted image' : 'image sample failed',
3242 };
3243 }
3244 }
3245
3246 const canvas = document.createElement('canvas');
3247 const intrinsicWidth = drawable.naturalWidth || drawable.videoWidth || drawable.width || 1;
3248 const intrinsicHeight = drawable.naturalHeight || drawable.videoHeight || drawable.height || 1;
3249 const maxRasterSide = 640;
3250 const scale = Math.min(1, maxRasterSide / Math.max(intrinsicWidth, intrinsicHeight));
3251 canvas.width = Math.max(1, Math.round(intrinsicWidth * scale));
3252 canvas.height = Math.max(1, Math.round(intrinsicHeight * scale));
3253 const ctx = canvas.getContext('2d', { willReadFrequently: true });
3254 if (!ctx) return { status: 'unresolved', reason: 'canvas unavailable' };
3255 try {
3256 ctx.drawImage(drawable, 0, 0, canvas.width, canvas.height);
3257 const cached = {
3258 ctx,
3259 width: canvas.width,
3260 height: canvas.height,
3261 scaleX: canvas.width / intrinsicWidth,
3262 scaleY: canvas.height / intrinsicHeight,
3263 };
3264 visualContrastRasterCache.set(drawable, cached);
3265 const x = Math.max(0, Math.min(cached.width - 1, Math.floor(sourcePoint.x * cached.scaleX)));
3266 const y = Math.max(0, Math.min(cached.height - 1, Math.floor(sourcePoint.y * cached.scaleY)));
3267 const data = ctx.getImageData(x, y, 1, 1).data;
3268 return {
3269 status: 'sampled',
3270 color: { r: data[0], g: data[1], b: data[2], a: data[3] / 255 },
3271 };
3272 } catch (err) {
3273 const reason = /taint|cross-origin|Security/i.test(err?.message || '') ? 'tainted image' : 'image sample failed';
3274 visualContrastRasterCache.set(drawable, { ctx: null, reason });
3275 return {
3276 status: 'unresolved',
3277 reason,
3278 };
3279 }
3280 }
3281
3282 async function sampleCssBackground(el, style, point, textColor) {
3283 const rect = el.getBoundingClientRect();
3284 const bgImage = style.backgroundImage || '';
3285 if (bgImage && bgImage !== 'none') {
3286 if (/gradient/i.test(bgImage)) {
3287 const color = pickWorstContrastColor(textColor, parseGradientColors(bgImage));
3288 if (color) return { status: 'sampled', color, method: 'analytic-gradient' };
3289 }
3290 if (/url\s*\(/i.test(bgImage)) {
3291 const img = await loadVisualContrastImage(firstCssUrl(bgImage));
3292 if (!img) return { status: 'unresolved', reason: 'image unavailable' };
3293 const paintedRect = resolvePaintedImageRect(
3294 rect,
3295 img,
3296 getLayerValue(style.backgroundSize) || 'auto',
3297 getLayerValue(style.backgroundPosition) || '50% 50%',
3298 );
3299 const sourcePoint = pointToImageSource(point, paintedRect);
3300 if (!sourcePoint) return { status: 'unresolved', reason: 'point outside background image' };
3301 const sample = sampleDrawablePixel(img, sourcePoint);
3302 if (sample.status === 'sampled') return { ...sample, method: 'canvas-background-image' };
3303 return sample;
3304 }
3305 }
3306 const bg = parseRgb(style.backgroundColor);
3307 if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' };
3308 return { status: 'unresolved', reason: 'no readable background' };
3309 }
3310
3311 async function sampleImageElement(img, point) {
3312 const rect = img.getBoundingClientRect();
3313 const style = getComputedStyle(img);
3314 const paintedRect = resolveObjectImageRect(rect, img, style);
3315 const sourcePoint = pointToImageSource(point, paintedRect);
3316 if (!sourcePoint) return { status: 'unresolved', reason: 'point outside image' };
3317 const sample = sampleDrawablePixel(img, sourcePoint);
3318 if (sample.status === 'sampled') return { ...sample, method: 'canvas-img-underlay' };
3319
3320 if (img.currentSrc || img.src) {
3321 const loaded = await loadVisualContrastImage(img.currentSrc || img.src);
3322 if (loaded) {
3323 const loadedRect = { ...paintedRect, intrinsicWidth: loaded.naturalWidth || loaded.width || paintedRect.intrinsicWidth, intrinsicHeight: loaded.naturalHeight || loaded.height || paintedRect.intrinsicHeight };
3324 const loadedPoint = pointToImageSource(point, loadedRect);
3325 if (loadedPoint) {
3326 const loadedSample = sampleDrawablePixel(loaded, loadedPoint);
3327 if (loadedSample.status === 'sampled') return { ...loadedSample, method: 'canvas-img-underlay' };
3328 }
3329 }
3330 }
3331 return sample;
3332 }
3333
3334 function textSamplePoints(rect) {
3335 const insetX = Math.min(12, Math.max(1, rect.width * 0.12));
3336 const insetY = Math.min(8, Math.max(1, rect.height * 0.22));
3337 const xs = rect.width < 28
3338 ? [rect.left + rect.width / 2]
3339 : [rect.left + insetX, rect.left + rect.width / 2, rect.right - insetX];
3340 const ys = rect.height < 22
3341 ? [rect.top + rect.height / 2]
3342 : [rect.top + insetY, rect.top + rect.height / 2, rect.bottom - insetY];
3343 const points = [];
3344 for (const y of ys) {
3345 for (const x of xs) {
3346 if (x >= 0 && y >= 0 && x <= window.innerWidth && y <= window.innerHeight) points.push({ x, y });
3347 }
3348 }
3349 return points;
3350 }
3351
3352 async function sampleVisualBackgroundAtPoint(el, point, textColor, depth = 0) {
3353 if (depth > 8) {
3354 return { status: 'unresolved', reason: 'background stack too deep' };
3355 }
3356 const stack = typeof document.elementsFromPoint === 'function'
3357 ? document.elementsFromPoint(point.x, point.y)
3358 : [];
3359 const selfIndex = stack.findIndex(node => node === el || el.contains(node));
3360 const nodes = selfIndex >= 0 ? stack.slice(selfIndex) : [el, ...stack];
3361 const unresolved = [];
3362
3363 for (const node of nodes) {
3364 if (!node || node.nodeType !== 1) continue;
3365 if (node.closest?.('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue;
3366 const tag = node.tagName?.toLowerCase();
3367 if (tag === 'img') {
3368 const sample = await sampleImageElement(node, point);
3369 if (sample.status === 'sampled') return sample;
3370 unresolved.push(sample.reason);
3371 continue;
3372 }
3373 if (tag === 'canvas' || tag === 'video') {
3374 const rect = node.getBoundingClientRect();
3375 const sourcePoint = pointToImageSource(point, {
3376 left: rect.left,
3377 top: rect.top,
3378 width: rect.width,
3379 height: rect.height,
3380 intrinsicWidth: node.width || node.videoWidth || rect.width,
3381 intrinsicHeight: node.height || node.videoHeight || rect.height,
3382 });
3383 if (sourcePoint) {
3384 const sample = sampleDrawablePixel(node, sourcePoint);
3385 if (sample.status === 'sampled') return { ...sample, method: `canvas-${tag}-underlay` };
3386 unresolved.push(sample.reason);
3387 }
3388 continue;
3389 }
3390 const style = getComputedStyle(node);
3391 const sample = await sampleCssBackground(node, style, point, textColor);
3392 if (sample.status === 'sampled') {
3393 if (!sample.color || sample.color.a == null || sample.color.a >= 0.95) return sample;
3394 const under = await sampleVisualBackgroundAtPoint(node.parentElement || document.body, point, textColor, depth + 1);
3395 if (under.status === 'sampled') {
3396 return {
3397 status: 'sampled',
3398 color: blendRgba(sample.color, under.color),
3399 method: `${sample.method}+alpha`,
3400 };
3401 }
3402 return sample;
3403 }
3404 unresolved.push(sample.reason);
3405 }
3406
3407 return {
3408 status: 'unresolved',
3409 reason: [...new Set(unresolved.filter(Boolean))].slice(0, 3).join(', ') || 'no readable visual background',
3410 };
3411 }
3412
3413 async function analyzeVisualContrastCandidate(candidate) {
3414 let el;
3415 try {
3416 el = document.querySelector(candidate.selector);
3417 } catch {
3418 return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'stale selector' };
3419 }
3420 if (!el) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing element' };
3421
3422 const blockingReason = (candidate.reasons || []).find(reason =>
3423 reason === 'background-clip text' ||
3424 reason === 'blend mode' ||
3425 reason === 'filter' ||
3426 reason === 'backdrop filter' ||
3427 reason === 'opacity stack' ||
3428 reason === 'text shadow'
3429 );
3430 if (blockingReason) {
3431 return { ...candidate, status: 'unresolved', confidence: 'none', reason: `${blockingReason} needs screenshot pixels` };
3432 }
3433
3434 const style = getComputedStyle(el);
3435 const textColor = parseRgb(style.color) || candidate.textColor;
3436 if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' };
3437
3438 const rect = getDirectTextRect(el) || el.getBoundingClientRect();
3439 if (!rect || rect.width < 4 || rect.height < 4) {
3440 return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing text rect' };
3441 }
3442
3443 const points = textSamplePoints(rect);
3444 if (points.length === 0) {
3445 return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'text outside viewport' };
3446 }
3447
3448 const ratios = [];
3449 const methods = new Set();
3450 const unresolved = [];
3451 for (const point of points) {
3452 const sample = await sampleVisualBackgroundAtPoint(el, point, textColor);
3453 if (sample.status !== 'sampled' || !sample.color) {
3454 unresolved.push(sample.reason);
3455 continue;
3456 }
3457 const fg = blendRgba(textColor, sample.color);
3458 ratios.push(contrastRatio(fg, sample.color));
3459 if (sample.method) methods.add(sample.method);
3460 }
3461
3462 if (ratios.length < Math.min(3, points.length)) {
3463 return {
3464 ...candidate,
3465 status: 'unresolved',
3466 confidence: 'none',
3467 samples: ratios.length,
3468 reason: [...new Set(unresolved.filter(Boolean))].slice(0, 3).join(', ') || 'not enough readable samples',
3469 };
3470 }
3471
3472 ratios.sort((a, b) => a - b);
3473 const pick = pct => ratios[Math.min(ratios.length - 1, Math.max(0, Math.floor((pct / 100) * ratios.length)))];
3474 const measuredRatio = pick(10);
3475 const medianRatio = pick(50);
3476 const status = measuredRatio < candidate.threshold ? 'fail' : 'pass';
3477 const method = [...methods].sort().join(', ') || 'browser-visual';
3478 const textLabel = candidate.text ? ` "${candidate.text}"` : '';
3479 const detail = `browser contrast ${measuredRatio.toFixed(1)}:1 median ${medianRatio.toFixed(1)}:1 (need ${candidate.threshold}:1) via ${method}${textLabel}`;
3480 return {
3481 ...candidate,
3482 status,
3483 confidence: method.includes('canvas-') ? 'high' : 'medium',
3484 method,
3485 ratio: measuredRatio,
3486 medianRatio,
3487 samples: ratios.length,
3488 finding: status === 'fail' ? { id: 'low-contrast', snippet: detail } : null,
3489 };
3490 }
3491
3492 function waitForVisualPaint() {
3493 return new Promise(resolve => {
3494 requestAnimationFrame(() => requestAnimationFrame(resolve));
3495 });
3496 }
3497
3498 async function analyzeVisualContrast(options = {}) {
3499 const candidates = collectVisualContrastCandidates(options);
3500 const results = [];
3501 const shouldScrollOffscreen = options.scrollOffscreen === true;
3502 const restoreScroll = { x: window.scrollX, y: window.scrollY };
3503 for (const candidate of candidates) {
3504 if (shouldScrollOffscreen && (window.scrollX !== restoreScroll.x || window.scrollY !== restoreScroll.y)) {
3505 window.scrollTo(restoreScroll.x, restoreScroll.y);
3506 await waitForVisualPaint();
3507 }
3508 let result = await analyzeVisualContrastCandidate(candidate);
3509 if (shouldScrollOffscreen && result.status === 'unresolved' && result.reason === 'text outside viewport') {
3510 let el = null;
3511 try {
3512 el = document.querySelector(candidate.selector);
3513 } catch {
3514 el = null;
3515 }
3516 if (el && typeof el.scrollIntoView === 'function') {
3517 el.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
3518 await waitForVisualPaint();
3519 result = await analyzeVisualContrastCandidate(candidate);
3520 }
3521 }
3522 results.push(result);
3523 }
3524 if (shouldScrollOffscreen && (window.scrollX !== restoreScroll.x || window.scrollY !== restoreScroll.y)) {
3525 window.scrollTo(restoreScroll.x, restoreScroll.y);
3526 }
3527 return results;
3528 }
3529
3530 function isElementHidden(el) {
3531 if (!el || el === document.body || el === document.documentElement) return false;
3532 if (typeof el.checkVisibility === 'function') return !el.checkVisibility({ checkOpacity: false, checkVisibilityCSS: true });
3533 // Fallback: zero size or no offsetParent (covers display:none and detached subtrees)
3534 return el.offsetWidth === 0 && el.offsetHeight === 0;
3535 }
3536
3537 function serializeFindings(allFindings) {
3538 return allFindings.map(({ el, findings }) => ({
3539 selector: generateSelector(el),
3540 tagName: el.tagName?.toLowerCase() || 'unknown',
3541 rect: (el !== document.body && el !== document.documentElement && el.getBoundingClientRect)
3542 ? el.getBoundingClientRect().toJSON() : null,
3543 isPageLevel: el === document.body || el === document.documentElement,
3544 isHidden: isElementHidden(el),
3545 findings: findings.map(f => {
3546 const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id));
3547 return {
3548 type: f.type || f.id,
3549 category: ap ? ap.category : 'quality',
3550 severity: ap?.severity || 'warning',
3551 detail: f.detail || f.snippet,
3552 name: ap ? ap.name : (f.type || f.id),
3553 description: ap ? ap.description : '',
3554 };
3555 }),
3556 }));
3557 }
3558
3559 const printSummary = function(allFindings) {
3560 if (allFindings.length === 0) {
3561 console.log('%c[impeccable] No anti-patterns found.', 'color: #22c55e; font-weight: bold');
3562 return;
3563 }
3564 console.group(
3565 `%c[impeccable] ${allFindings.length} anti-pattern${allFindings.length === 1 ? '' : 's'} found`,
3566 'color: oklch(60% 0.25 350); font-weight: bold'
3567 );
3568 for (const { el, findings } of allFindings) {
3569 for (const f of findings) {
3570 console.log(`%c${f.type || f.id}%c ${f.detail || f.snippet}`,
3571 'color: oklch(55% 0.25 350); font-weight: bold', 'color: inherit', el);
3572 }
3573 }
3574 console.groupEnd();
3575 };
3576
3577 function addBrowserFindings(groupMap, el, findings) {
3578 if (!findings || findings.length === 0) return;
3579 const existing = groupMap.get(el);
3580 if (existing) existing.push(...findings);
3581 else groupMap.set(el, [...findings]);
3582 }
3583
3584 function browserFindingsFromMap(groupMap) {
3585 return [...groupMap.entries()].map(([el, findings]) => ({ el, findings }));
3586 }
3587
3588 function collectBrowserFindings() {
3589 const groupMap = new Map();
3590 const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : [];
3591 const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id);
3592
3593 for (const el of document.querySelectorAll('*')) {
3594 // Skip impeccable's own elements and any descendants (overlays, labels, banner, nav buttons)
3595 if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue;
3596 // Skip browser extension elements (Claude, etc.)
3597 const elId = el.id || '';
3598 if (elId.startsWith('claude-') || elId.startsWith('cic-')) continue;
3599 // Skip the impeccable live-mode overlay (highlight, tooltip, bar, picker, toast).
3600 // These are inspector chrome, not part of the user's design.
3601 if (el.closest('[id^="impeccable-live-"]')) continue;
3602 // Skip html/body -- page-level findings go in the banner, not a full-page overlay
3603 if (el === document.body || el === document.documentElement) continue;
3604
3605 const findings = [
3606 ...checkElementBordersDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
3607 ...checkElementColorsDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
3608 ...checkElementMotionDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
3609 ...checkElementGlowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
3610 ...checkElementAIPaletteDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
3611 ...checkElementIconTileDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
3612 ...checkElementItalicSerifDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
3613 ...checkElementHeroEyebrowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
3614 ...checkElementQualityDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
3615 ].filter(f => _ruleOk(f.type));
3616
3617 addBrowserFindings(groupMap, el, findings);
3618 }
3619
3620 const pageLevelFindings = [];
3621
3622 const typoFindings = checkTypography().filter(f => _ruleOk(f.type));
3623 if (typoFindings.length > 0) {
3624 pageLevelFindings.push(...typoFindings);
3625 addBrowserFindings(groupMap, document.body, typoFindings);
3626 }
3627
3628 const sectionKickerFindings = checkRepeatedSectionKickersDOM()
3629 .map(f => ({ type: f.id, detail: f.snippet }))
3630 .filter(f => _ruleOk(f.type));
3631 if (sectionKickerFindings.length > 0) {
3632 pageLevelFindings.push(...sectionKickerFindings);
3633 addBrowserFindings(groupMap, document.body, sectionKickerFindings);
3634 }
3635
3636 const layoutFindings = checkLayout().filter(f => _ruleOk(f.type));
3637 for (const f of layoutFindings) {
3638 const el = f.el || document.body;
3639 addBrowserFindings(groupMap, el, [{ type: f.type, detail: f.detail || f.snippet }]);
3640 }
3641
3642 // Page-level quality checks (headings, etc.)
3643 const qualityFindings = checkPageQualityDOM().filter(f => _ruleOk(f.type));
3644 if (qualityFindings.length > 0) {
3645 pageLevelFindings.push(...qualityFindings);
3646 addBrowserFindings(groupMap, document.body, qualityFindings);
3647 }
3648
3649 // Regex-on-HTML checks (shared with Node)
3650 // Clone the document and strip impeccable-live overlay nodes before the
3651 // regex scan, so the inspector's own inline styles (transitions on top/
3652 // left/width/height, etc.) don't register as page anti-patterns.
3653 const docClone = document.documentElement.cloneNode(true);
3654 for (const node of docClone.querySelectorAll('[id^="impeccable-live-"]')) {
3655 node.remove();
3656 }
3657 const htmlPatternFindings = checkHtmlPatterns(docClone.outerHTML);
3658 if (htmlPatternFindings.length > 0) {
3659 const mapped = htmlPatternFindings.map(f => ({ type: f.id, detail: f.snippet })).filter(f => _ruleOk(f.type));
3660 pageLevelFindings.push(...mapped);
3661 addBrowserFindings(groupMap, document.body, mapped);
3662 }
3663
3664 return {
3665 groupMap,
3666 allFindings: browserFindingsFromMap(groupMap),
3667 pageLevelFindings,
3668 };
3669 }
3670
3671 function shouldRunVisualContrast(options = {}) {
3672 return options.visualContrast === true || window.__IMPECCABLE_CONFIG__?.visualContrast === true;
3673 }
3674
3675 function visualContrastOptions(options = {}) {
3676 const config = window.__IMPECCABLE_CONFIG__ || {};
3677 const scrollOffscreen = typeof options.scrollOffscreen === 'boolean'
3678 ? options.scrollOffscreen
3679 : typeof options.visualContrastScrollOffscreen === 'boolean'
3680 ? options.visualContrastScrollOffscreen
3681 : typeof config.visualContrastScrollOffscreen === 'boolean'
3682 ? config.visualContrastScrollOffscreen
3683 : false;
3684 return {
3685 ...options,
3686 maxCandidates: Number.isFinite(options.visualContrastMaxCandidates)
3687 ? options.visualContrastMaxCandidates
3688 : Number.isFinite(options.maxCandidates)
3689 ? options.maxCandidates
3690 : Number.isFinite(config.visualContrastMaxCandidates)
3691 ? config.visualContrastMaxCandidates
3692 : undefined,
3693 scrollOffscreen,
3694 };
3695 }
3696
3697 let lastVisualContrastAnalyses = [];
3698 let lazyVisualContrastObserver = null;
3699 let lazyVisualContrastPending = new WeakMap();
3700 const lazyVisualContrastResolving = new WeakSet();
3701 let scanGeneration = 0;
3702
3703 function rememberVisualContrastAnalysis(result) {
3704 if (!result?.selector) {
3705 lastVisualContrastAnalyses.push(result);
3706 return;
3707 }
3708 const idx = lastVisualContrastAnalyses.findIndex(item => item.selector === result.selector);
3709 if (idx >= 0) lastVisualContrastAnalyses[idx] = result;
3710 else lastVisualContrastAnalyses.push(result);
3711 }
3712
3713 function disconnectLazyVisualContrastObserver() {
3714 if (lazyVisualContrastObserver) {
3715 lazyVisualContrastObserver.disconnect();
3716 lazyVisualContrastObserver = null;
3717 }
3718 lazyVisualContrastPending = new WeakMap();
3719 }
3720
3721 function addVisualContrastResult(groupMap, result, options = {}) {
3722 if (result.status !== 'fail' || !result.finding || !result.selector) return false;
3723 let el = null;
3724 try {
3725 el = document.querySelector(result.selector);
3726 } catch {
3727 el = null;
3728 }
3729 if (!el) return false;
3730 const findingType = result.finding.type || result.finding.id || 'low-contrast';
3731 const existing = groupMap.get(el) || [];
3732 if (existing.some(f => (f.type || f.id) === findingType)) return false;
3733 addBrowserFindings(groupMap, el, [{
3734 type: findingType,
3735 detail: result.finding.detail || result.finding.snippet,
3736 }]);
3737 if (options.decorate && el !== document.body && el !== document.documentElement) {
3738 highlight(el, groupMap.get(el) || []);
3739 }
3740 return true;
3741 }
3742
3743 function postSerializedFindings(groupMap) {
3744 if (!EXTENSION_MODE) return;
3745 const allFindings = browserFindingsFromMap(groupMap);
3746 window.postMessage({
3747 source: 'impeccable-results',
3748 findings: serializeFindings(allFindings),
3749 count: allFindings.length,
3750 }, '*');
3751 }
3752
3753 function postExtensionError(err) {
3754 if (!EXTENSION_MODE) return;
3755 window.postMessage({
3756 source: 'impeccable-error',
3757 message: err?.message || String(err),
3758 }, '*');
3759 }
3760
3761 function reportVisualContrastError(err, detail = {}) {
3762 window.dispatchEvent(new CustomEvent('impeccable-visual-contrast-error', {
3763 detail: {
3764 ...detail,
3765 message: err?.message || String(err),
3766 },
3767 }));
3768 if (EXTENSION_MODE) {
3769 postExtensionError(err);
3770 } else {
3771 console.warn('[impeccable] visual contrast scan failed', err);
3772 }
3773 }
3774
3775 function scheduleLazyVisualContrast(groupMap, analyses, options = {}, runtime = {}) {
3776 disconnectLazyVisualContrastObserver();
3777 if (options.visualContrastLazy === false || options.scrollOffscreen !== false) return;
3778 if (typeof IntersectionObserver === 'undefined') return;
3779 const unresolved = (analyses || []).filter(result =>
3780 result?.status === 'unresolved' &&
3781 result.reason === 'text outside viewport' &&
3782 result.selector
3783 );
3784 if (unresolved.length === 0) return;
3785 const generation = runtime.generation || scanGeneration;
3786
3787 lazyVisualContrastObserver = new IntersectionObserver((entries) => {
3788 for (const entry of entries) {
3789 if (!entry.isIntersecting) continue;
3790 const el = entry.target;
3791 const candidate = lazyVisualContrastPending.get(el);
3792 if (!candidate || lazyVisualContrastResolving.has(el)) continue;
3793 lazyVisualContrastObserver?.unobserve(el);
3794 lazyVisualContrastPending.delete(el);
3795 lazyVisualContrastResolving.add(el);
3796 waitForVisualPaint()
3797 .then(() => analyzeVisualContrastCandidate(candidate))
3798 .then(result => {
3799 if (generation !== scanGeneration) return;
3800 rememberVisualContrastAnalysis(result);
3801 const added = addVisualContrastResult(groupMap, result, { decorate: true });
3802 if (added) {
3803 postSerializedFindings(groupMap);
3804 window.dispatchEvent(new CustomEvent('impeccable-visual-contrast-resolved', {
3805 detail: {
3806 selector: result.selector,
3807 status: result.status,
3808 finding: result.finding || null,
3809 },
3810 }));
3811 }
3812 })
3813 .catch(err => {
3814 reportVisualContrastError(err, { selector: candidate.selector });
3815 })
3816 .finally(() => {
3817 lazyVisualContrastResolving.delete(el);
3818 });
3819 }
3820 }, { threshold: 0.5 });
3821
3822 for (const candidate of unresolved) {
3823 let el = null;
3824 try {
3825 el = document.querySelector(candidate.selector);
3826 } catch {
3827 el = null;
3828 }
3829 if (!el) continue;
3830 lazyVisualContrastPending.set(el, candidate);
3831 lazyVisualContrastObserver.observe(el);
3832 }
3833 }
3834
3835 async function addVisualContrastFindings(groupMap, options = {}, runtime = {}) {
3836 if (!shouldRunVisualContrast(options)) {
3837 lastVisualContrastAnalyses = [];
3838 disconnectLazyVisualContrastObserver();
3839 return [];
3840 }
3841 const resolvedOptions = visualContrastOptions(options);
3842 const analyses = await analyzeVisualContrast(resolvedOptions);
3843 if (runtime.generation && runtime.generation !== scanGeneration) return analyses;
3844 lastVisualContrastAnalyses = analyses;
3845 for (const result of analyses) {
3846 addVisualContrastResult(groupMap, result, { decorate: runtime.decorate });
3847 }
3848 if (runtime.decorate || runtime.scheduleLazy) scheduleLazyVisualContrast(groupMap, analyses, resolvedOptions, runtime);
3849 return analyses;
3850 }
3851
3852 async function collectBrowserFindingsAsync(options = {}, runtime = {}) {
3853 const collected = collectBrowserFindings();
3854 await addVisualContrastFindings(collected.groupMap, options, runtime);
3855 return {
3856 ...collected,
3857 allFindings: browserFindingsFromMap(collected.groupMap),
3858 visualContrastAnalyses: lastVisualContrastAnalyses,
3859 };
3860 }
3861
3862 function clearOverlays() {
3863 scanGeneration += 1;
3864 disconnectLazyVisualContrastObserver();
3865 for (const o of [...overlays]) detachOverlay(o);
3866 overlays.length = 0;
3867 visibilityObserver.disconnect();
3868 overlayIndex = 0;
3869 }
3870
3871 function renderBrowserFindings(collected) {
3872 const { allFindings, pageLevelFindings } = collected;
3873
3874 for (const { el, findings } of allFindings) {
3875 if (el === document.body || el === document.documentElement) continue;
3876 highlight(el, findings);
3877 }
3878
3879 if (pageLevelFindings.length > 0) {
3880 showPageBanner(pageLevelFindings);
3881 }
3882
3883 if (!EXTENSION_MODE) printSummary(allFindings);
3884
3885 // In extension mode, post serialized results for the DevTools panel
3886 if (EXTENSION_MODE) {
3887 window.postMessage({
3888 source: 'impeccable-results',
3889 findings: serializeFindings(allFindings),
3890 count: allFindings.length,
3891 }, '*');
3892 }
3893
3894 // After this scan completes, all subsequent reveals are instant (no stagger, no animation)
3895 setTimeout(() => { firstScanDone = true; }, 1000);
3896
3897 return allFindings;
3898 }
3899
3900 let firstScanDone = false;
3901 const scan = function(options = {}) {
3902 clearOverlays();
3903 const generation = scanGeneration;
3904 const collected = collectBrowserFindings();
3905 const allFindings = renderBrowserFindings(collected);
3906 if (shouldRunVisualContrast(options)) {
3907 addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation })
3908 .then(() => {
3909 if (generation === scanGeneration) postSerializedFindings(collected.groupMap);
3910 })
3911 .catch(err => {
3912 reportVisualContrastError(err);
3913 });
3914 }
3915 return allFindings;
3916 };
3917
3918 const scanAsync = async function(options = {}) {
3919 clearOverlays();
3920 const generation = scanGeneration;
3921 if (shouldRunVisualContrast(options)) {
3922 const collected = await collectBrowserFindingsAsync(options, { generation, scheduleLazy: true });
3923 if (generation !== scanGeneration) return [];
3924 return renderBrowserFindings(collected);
3925 }
3926 lastVisualContrastAnalyses = [];
3927 return renderBrowserFindings(collectBrowserFindings());
3928 };
3929
3930 const detect = function(options = {}) {
3931 lastVisualContrastAnalyses = [];
3932 const { allFindings } = collectBrowserFindings();
3933 return options.serialize === false ? allFindings : serializeFindings(allFindings);
3934 };
3935
3936 const detectAsync = async function(options = {}) {
3937 if (shouldRunVisualContrast(options)) {
3938 const { allFindings } = await collectBrowserFindingsAsync(options);
3939 return options.serialize === false ? allFindings : serializeFindings(allFindings);
3940 }
3941 lastVisualContrastAnalyses = [];
3942 const { allFindings } = collectBrowserFindings();
3943 return options.serialize === false ? allFindings : serializeFindings(allFindings);
3944 };
3945
3946 if (EXTENSION_MODE) {
3947 // Extension mode: listen for commands, don't auto-scan
3948 window.addEventListener('message', (e) => {
3949 if (e.source !== window || !e.data || e.data.source !== 'impeccable-command') return;
3950 if (e.data.action === 'scan') {
3951 if (e.data.config) window.__IMPECCABLE_CONFIG__ = e.data.config;
3952 try {
3953 scan(e.data.config || {});
3954 } catch (err) {
3955 postExtensionError(err);
3956 }
3957 }
3958 if (e.data.action === 'toggle-overlays') {
3959 const visible = !document.body.classList.contains('impeccable-hidden');
3960 document.body.classList.toggle('impeccable-hidden', visible);
3961 window.postMessage({ source: 'impeccable-overlays-toggled', visible: !visible }, '*');
3962 }
3963 if (e.data.action === 'remove') {
3964 clearOverlays();
3965 styleEl.remove();
3966 if (spotlightBackdrop) { spotlightBackdrop.remove(); spotlightBackdrop = null; }
3967 document.body.classList.remove('impeccable-hidden');
3968 }
3969 if (e.data.action === 'highlight') {
3970 try {
3971 const target = e.data.selector ? document.querySelector(e.data.selector) : null;
3972 if (target) {
3973 // Scroll first so positionOverlay reads the post-scroll rect
3974 if (!isInViewport(target) && target.scrollIntoView) {
3975 target.scrollIntoView({ behavior: 'instant', block: 'center' });
3976 }
3977 for (const o of overlays) {
3978 if (o.classList.contains('impeccable-banner')) continue;
3979 const isMatch = o._targetEl === target;
3980 o.classList.toggle('impeccable-spotlight', isMatch);
3981 o.classList.toggle('impeccable-spotlight-dimmed', !isMatch);
3982 if (isMatch) {
3983 // Force the matching overlay visible immediately, don't wait for IntersectionObserver
3984 o.style.display = '';
3985 o.style.animation = 'none';
3986 o.classList.add('impeccable-visible');
3987 o._revealed = true;
3988 positionOverlay(o);
3989 }
3990 }
3991 showSpotlight(target);
3992 }
3993 } catch { /* invalid selector */ }
3994 }
3995 if (e.data.action === 'unhighlight') {
3996 hideSpotlight();
3997 for (const o of overlays) {
3998 o.classList.remove('impeccable-spotlight');
3999 o.classList.remove('impeccable-spotlight-dimmed');
4000 }
4001 }
4002 });
4003 window.postMessage({ source: 'impeccable-ready' }, '*');
4004 } else {
4005 if (window.__IMPECCABLE_CONFIG__?.autoScan !== false) {
4006 const runAutoScan = () => {
4007 try {
4008 scan();
4009 } catch (err) {
4010 console.warn('[impeccable] scan failed', err);
4011 }
4012 };
4013 if (document.readyState === 'loading') {
4014 document.addEventListener('DOMContentLoaded', () => setTimeout(runAutoScan, 100));
4015 } else {
4016 setTimeout(runAutoScan, 100);
4017 }
4018 }
4019 }
4020
4021 window.impeccableDetect = detect;
4022 window.impeccableDetectAsync = detectAsync;
4023 window.impeccableScan = scan;
4024 window.impeccableScanAsync = scanAsync;
4025 window.impeccableCollectVisualContrastCandidates = collectVisualContrastCandidates;
4026 window.impeccableAnalyzeVisualContrast = analyzeVisualContrast;
4027 window.impeccableGetLastVisualContrastAnalyses = () => lastVisualContrastAnalyses.slice();
4028}
4029
4030})();