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