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