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