detect-antipatterns.mjs

   1#!/usr/bin/env node
   2
   3/**
   4 * Anti-Pattern Detector for Impeccable
   5 * Copyright (c) 2026 Paul Bakaus
   6 * SPDX-License-Identifier: Apache-2.0
   7 *
   8 * Universal file — auto-detects environment (browser vs Node) and adapts.
   9 *
  10 * Node usage:
  11 *   node detect-antipatterns.mjs [file-or-dir...]   # jsdom for HTML, regex for rest
  12 *   node detect-antipatterns.mjs https://...         # Puppeteer (auto)
  13 *   node detect-antipatterns.mjs --fast [files...]   # regex-only (skip jsdom)
  14 *   node detect-antipatterns.mjs --json              # JSON output
  15 *
  16 * Browser usage:
  17 *   <script src="detect-antipatterns-browser.js"></script>
  18 *   Re-scan: window.impeccableScan()
  19 *
  20 * Exit codes: 0 = clean, 2 = findings
  21 */
  22
  23// ─── Environment ────────────────────────────────────────────────────────────
  24
  25const IS_BROWSER = typeof window !== 'undefined';
  26const IS_NODE = !IS_BROWSER;
  27
  28// @browser-strip-start
  29let fs, path;
  30if (!IS_BROWSER) {
  31  fs = (await import('node:fs')).default;
  32  path = (await import('node:path')).default;
  33}
  34// @browser-strip-end
  35
  36// ─── Section 1: Constants ───────────────────────────────────────────────────
  37
  38const SAFE_TAGS = new Set([
  39  'blockquote', 'nav', 'a', 'input', 'textarea', 'select',
  40  'pre', 'code', 'span', 'th', 'td', 'tr', 'li', 'label',
  41  'button', 'hr', 'html', 'head', 'body', 'script', 'style',
  42  'link', 'meta', 'title', 'br', 'img', 'svg', 'path', 'circle',
  43  'rect', 'line', 'polyline', 'polygon', 'g', 'defs', 'use',
  44]);
  45
  46// Per-check safe-tags override for the border (side-tab / border-accent)
  47// rule. We intentionally re-allow <label> here because card-shaped clickable
  48// labels (e.g. .checklist-item wrapping a checkbox + content) are one of the
  49// canonical side-tab anti-pattern shapes and must be detected. The rule's
  50// other preconditions (non-neutral color, width >= 2px on a single side,
  51// radius > 0 or width >= 3, element size >= 20x20 in the browser path)
  52// already filter out plain inline form labels so this does not introduce
  53// false positives. See modern-color-borders.html for the test matrix.
  54const BORDER_SAFE_TAGS = new Set(
  55  [...SAFE_TAGS].filter(t => t !== 'label')
  56);
  57
  58const OVERUSED_FONTS = new Set([
  59  'inter', 'roboto', 'open sans', 'lato', 'montserrat', 'arial', 'helvetica',
  60]);
  61
  62// Brand-associated fonts: don't flag these as "overused" on the brand's own domains.
  63// Keys are font names, values are arrays of hostname suffixes where the font is allowed.
  64const GOOGLE_DOMAINS = [
  65  'google.com', 'youtube.com', 'android.com', 'chromium.org',
  66  'chrome.com', 'web.dev', 'gstatic.com', 'firebase.google.com',
  67];
  68const BRAND_FONT_DOMAINS = {
  69  'roboto': GOOGLE_DOMAINS,
  70  'google sans': GOOGLE_DOMAINS,
  71  'product sans': GOOGLE_DOMAINS,
  72};
  73
  74function isBrandFontOnOwnDomain(font) {
  75  if (typeof location === 'undefined') return false;
  76  const allowed = BRAND_FONT_DOMAINS[font];
  77  if (!allowed) return false;
  78  const host = location.hostname.toLowerCase();
  79  return allowed.some(suffix => host === suffix || host.endsWith('.' + suffix));
  80}
  81
  82const GENERIC_FONTS = new Set([
  83  'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy',
  84  'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
  85  '-apple-system', 'blinkmacsystemfont', 'segoe ui',
  86  'inherit', 'initial', 'unset', 'revert',
  87]);
  88
  89const ANTIPATTERNS = [
  90  // ── AI slop: tells that something was AI-generated ──
  91  {
  92    id: 'side-tab',
  93    category: 'slop',
  94    name: 'Side-tab accent border',
  95    description:
  96      '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.',
  97    skillSection: 'Visual Details',
  98    skillGuideline: 'colored accent stripe',
  99  },
 100  {
 101    id: 'border-accent-on-rounded',
 102    category: 'slop',
 103    name: 'Border accent on rounded element',
 104    description:
 105      'Thick accent border on a rounded card — the border clashes with the rounded corners. Remove the border or the border-radius.',
 106    skillSection: 'Visual Details',
 107    skillGuideline: 'colored accent stripe',
 108  },
 109  {
 110    id: 'overused-font',
 111    category: 'slop',
 112    name: 'Overused font',
 113    description:
 114      'Inter, Roboto, Open Sans, Lato, Montserrat, and Arial are used on millions of sites. Choose a distinctive font that gives your interface personality.',
 115    skillSection: 'Typography',
 116    skillGuideline: 'overused fonts like Inter',
 117  },
 118  {
 119    id: 'single-font',
 120    category: 'slop',
 121    name: 'Single font for everything',
 122    description:
 123      'Only one font family is used for the entire page. Pair a distinctive display font with a refined body font to create typographic hierarchy.',
 124    skillSection: 'Typography',
 125    skillGuideline: 'only one font family for the entire page',
 126  },
 127  {
 128    id: 'flat-type-hierarchy',
 129    category: 'slop',
 130    name: 'Flat type hierarchy',
 131    description:
 132      '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).',
 133    skillSection: 'Typography',
 134    skillGuideline: 'flat type hierarchy',
 135  },
 136  {
 137    id: 'gradient-text',
 138    category: 'slop',
 139    name: 'Gradient text',
 140    description:
 141      'Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.',
 142    skillSection: 'Color & Contrast',
 143    skillGuideline: 'gradient text for',
 144  },
 145  {
 146    id: 'ai-color-palette',
 147    category: 'slop',
 148    name: 'AI color palette',
 149    description:
 150      'Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.',
 151    skillSection: 'Color & Contrast',
 152    skillGuideline: 'AI color palette',
 153  },
 154  {
 155    id: 'nested-cards',
 156    category: 'slop',
 157    name: 'Nested cards',
 158    description:
 159      'Cards inside cards create visual noise and excessive depth. Flatten the hierarchy — use spacing, typography, and dividers instead of nesting containers.',
 160    skillSection: 'Layout & Space',
 161    skillGuideline: 'Nest cards inside cards',
 162  },
 163  {
 164    id: 'monotonous-spacing',
 165    category: 'slop',
 166    name: 'Monotonous spacing',
 167    description:
 168      'The same spacing value used everywhere — no rhythm, no variation. Use tight groupings for related items and generous separations between sections.',
 169    skillSection: 'Layout & Space',
 170    skillGuideline: 'same spacing everywhere',
 171  },
 172  {
 173    id: 'everything-centered',
 174    category: 'slop',
 175    name: 'Everything centered',
 176    description:
 177      'Every text element is center-aligned. Left-aligned text with asymmetric layouts feels more designed. Center only hero sections and CTAs.',
 178    skillSection: 'Layout & Space',
 179    skillGuideline: 'Center everything',
 180  },
 181  {
 182    id: 'bounce-easing',
 183    category: 'slop',
 184    name: 'Bounce or elastic easing',
 185    description:
 186      'Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.',
 187    skillSection: 'Motion',
 188    skillGuideline: 'bounce or elastic easing',
 189  },
 190  {
 191    id: 'dark-glow',
 192    category: 'slop',
 193    name: 'Dark mode with glowing accents',
 194    description:
 195      '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.',
 196    skillSection: 'Color & Contrast',
 197    skillGuideline: 'dark mode with glowing accents',
 198  },
 199  {
 200    id: 'icon-tile-stack',
 201    category: 'slop',
 202    name: 'Icon tile stacked above heading',
 203    description:
 204      '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.',
 205    skillSection: 'Typography',
 206    skillGuideline: 'large icons with rounded corners above every heading',
 207  },
 208
 209  // ── Quality: general design and accessibility issues ──
 210  {
 211    id: 'pure-black-white',
 212    category: 'quality',
 213    name: 'Pure black background',
 214    description:
 215      '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.',
 216    skillSection: 'Color & Contrast',
 217    skillGuideline: 'pure black (#000)',
 218  },
 219  {
 220    id: 'gray-on-color',
 221    category: 'quality',
 222    name: 'Gray text on colored background',
 223    description:
 224      'Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.',
 225    skillSection: 'Color & Contrast',
 226    skillGuideline: 'gray text on colored backgrounds',
 227  },
 228  {
 229    id: 'low-contrast',
 230    category: 'quality',
 231    name: 'Low contrast text',
 232    description:
 233      '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.',
 234  },
 235  {
 236    id: 'layout-transition',
 237    category: 'quality',
 238    name: 'Layout property animation',
 239    description:
 240      'Animating width, height, padding, or margin causes layout thrash and janky performance. Use transform and opacity instead, or grid-template-rows for height animations.',
 241    skillSection: 'Motion',
 242    skillGuideline: 'Animate layout properties',
 243  },
 244  {
 245    id: 'line-length',
 246    category: 'quality',
 247    name: 'Line length too long',
 248    description:
 249      '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.',
 250    skillSection: 'Layout & Space',
 251    skillGuideline: 'wrap beyond ~80 characters',
 252  },
 253  {
 254    id: 'cramped-padding',
 255    category: 'quality',
 256    name: 'Cramped padding',
 257    description:
 258      'Text is too close to the edge of its container. Add at least 8px (ideally 12-16px) of padding inside bordered or colored containers.',
 259  },
 260  {
 261    id: 'tight-leading',
 262    category: 'quality',
 263    name: 'Tight line height',
 264    description:
 265      '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.',
 266  },
 267  {
 268    id: 'skipped-heading',
 269    category: 'quality',
 270    name: 'Skipped heading level',
 271    description:
 272      '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.',
 273  },
 274  {
 275    id: 'justified-text',
 276    category: 'quality',
 277    name: 'Justified text',
 278    description:
 279      '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.',
 280  },
 281  {
 282    id: 'tiny-text',
 283    category: 'quality',
 284    name: 'Tiny body text',
 285    description:
 286      'Body text below 12px is hard to read, especially on high-DPI screens. Use at least 14px for body content, 16px is ideal.',
 287  },
 288  {
 289    id: 'all-caps-body',
 290    category: 'quality',
 291    name: 'All-caps body text',
 292    description:
 293      '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.',
 294    skillSection: 'Typography',
 295    skillGuideline: 'long body passages in uppercase',
 296  },
 297  {
 298    id: 'wide-tracking',
 299    category: 'quality',
 300    name: 'Wide letter spacing on body text',
 301    description:
 302      'Letter spacing above 0.05em on body text disrupts natural character groupings and slows reading. Reserve wide tracking for short uppercase labels only.',
 303  },
 304];
 305
 306// ─── Section 2: Color Utilities ─────────────────────────────────────────────
 307
 308function isNeutralColor(color) {
 309  if (!color || color === 'transparent') return true;
 310
 311  // rgb/rgba — use channel spread. Threshold 30 ≈ 11.7% of the 0–255 range.
 312  const rgb = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
 313  if (rgb) {
 314    return (Math.max(+rgb[1], +rgb[2], +rgb[3]) - Math.min(+rgb[1], +rgb[2], +rgb[3])) < 30;
 315  }
 316
 317  // oklch()/lch() — chroma is the second numeric component.
 318  // oklch chroma is ~0–0.4 in sRGB gamut; >= 0.02 reads as tinted, not gray.
 319  // lch chroma is ~0–150; >= 3 reads as tinted. jsdom emits both formats
 320  // literally (it does NOT convert them to rgb).
 321  const oklch = color.match(/oklch\(\s*[\d.%-]+\s+([\d.-]+)/i);
 322  if (oklch) return parseFloat(oklch[1]) < 0.02;
 323  const lch = color.match(/lch\(\s*[\d.%-]+\s+([\d.-]+)/i);
 324  if (lch) return parseFloat(lch[1]) < 3;
 325
 326  // oklab()/lab() — a and b are signed axes; chroma = sqrt(a² + b²).
 327  // oklab a/b are ~-0.4..0.4, threshold 0.02. lab a/b are ~-128..127, threshold 3.
 328  const oklab = color.match(/oklab\(\s*[\d.%-]+\s+([\d.-]+)\s+([\d.-]+)/i);
 329  if (oklab) {
 330    const a = parseFloat(oklab[1]), b = parseFloat(oklab[2]);
 331    return Math.hypot(a, b) < 0.02;
 332  }
 333  const lab = color.match(/lab\(\s*[\d.%-]+\s+([\d.-]+)\s+([\d.-]+)/i);
 334  if (lab) {
 335    const a = parseFloat(lab[1]), b = parseFloat(lab[2]);
 336    return Math.hypot(a, b) < 3;
 337  }
 338
 339  // hsl/hsla — saturation is the second numeric component (percent).
 340  // Modern jsdom usually converts hsl() to rgb, but handle it directly for
 341  // safety across versions and for any engine that preserves the format.
 342  const hsl = color.match(/hsla?\(\s*[\d.-]+\s*,?\s*([\d.]+)%/i);
 343  if (hsl) return parseFloat(hsl[1]) < 10;
 344
 345  // hwb(hue whiteness% blackness%) — a pixel is fully gray when
 346  // whiteness + blackness >= 100; chroma-like saturation = 1 - (w+b)/100.
 347  const hwb = color.match(/hwb\(\s*[\d.-]+\s+([\d.]+)%\s+([\d.]+)%/i);
 348  if (hwb) {
 349    const w = parseFloat(hwb[1]), b = parseFloat(hwb[2]);
 350    return (1 - Math.min(100, w + b) / 100) < 0.1;
 351  }
 352
 353  // Unknown / unrecognized format — err on the side of DETECTING rather
 354  // than silently skipping. This is the opposite of the previous default,
 355  // which was the root cause of the oklch bug.
 356  return false;
 357}
 358
 359function parseRgb(color) {
 360  if (!color || color === 'transparent') return null;
 361  const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
 362  if (!m) return null;
 363  return { r: +m[1], g: +m[2], b: +m[3], a: m[4] !== undefined ? +m[4] : 1 };
 364}
 365
 366function relativeLuminance({ r, g, b }) {
 367  const [rs, gs, bs] = [r / 255, g / 255, b / 255].map(c =>
 368    c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
 369  );
 370  return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
 371}
 372
 373function contrastRatio(c1, c2) {
 374  const l1 = relativeLuminance(c1);
 375  const l2 = relativeLuminance(c2);
 376  return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
 377}
 378
 379function parseGradientColors(bgImage) {
 380  if (!bgImage || !bgImage.includes('gradient')) return [];
 381  const colors = [];
 382  for (const m of bgImage.matchAll(/rgba?\([^)]+\)/g)) {
 383    const c = parseRgb(m[0]);
 384    if (c) colors.push(c);
 385  }
 386  for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
 387    const h = m[1];
 388    if (h.length === 6) {
 389      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 });
 390    } else {
 391      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 });
 392    }
 393  }
 394  return colors;
 395}
 396
 397function hasChroma(c, threshold = 30) {
 398  if (!c) return false;
 399  return (Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b)) >= threshold;
 400}
 401
 402function getHue(c) {
 403  if (!c) return 0;
 404  const r = c.r / 255, g = c.g / 255, b = c.b / 255;
 405  const max = Math.max(r, g, b), min = Math.min(r, g, b);
 406  if (max === min) return 0;
 407  const d = max - min;
 408  let h;
 409  if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
 410  else if (max === g) h = ((b - r) / d + 2) / 6;
 411  else h = ((r - g) / d + 4) / 6;
 412  return Math.round(h * 360);
 413}
 414
 415function colorToHex(c) {
 416  if (!c) return '?';
 417  return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');
 418}
 419
 420// ─── Section 3: Pure Detection ──────────────────────────────────────────────
 421
 422function checkBorders(tag, widths, colors, radius) {
 423  if (BORDER_SAFE_TAGS.has(tag)) return [];
 424  const findings = [];
 425  const sides = ['Top', 'Right', 'Bottom', 'Left'];
 426
 427  for (const side of sides) {
 428    const w = widths[side];
 429    if (w < 1 || isNeutralColor(colors[side])) continue;
 430
 431    const otherSides = sides.filter(s => s !== side);
 432    const maxOther = Math.max(...otherSides.map(s => widths[s]));
 433    if (!(w >= 2 && (maxOther <= 1 || w >= maxOther * 2))) continue;
 434
 435    const sn = side.toLowerCase();
 436    const isSide = side === 'Left' || side === 'Right';
 437
 438    if (isSide) {
 439      if (radius > 0) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` });
 440      else if (w >= 3) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px` });
 441    } else {
 442      if (radius > 0 && w >= 2) findings.push({ id: 'border-accent-on-rounded', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` });
 443    }
 444  }
 445
 446  return findings;
 447}
 448
 449// Returns true if the given text is composed entirely of emoji characters
 450// (plus whitespace / variation selectors). Emojis render as multicolor glyphs
 451// regardless of CSS `color`, so contrast checks against the element's text
 452// color are meaningless for these nodes.
 453const 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;
 454const 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;
 455function isEmojiOnlyText(text) {
 456  if (!text) return false;
 457  if (!EMOJI_CHAR_RE.test(text)) return false;
 458  return text.replace(EMOJI_CHARS_GLOBAL, '').trim() === '';
 459}
 460
 461function checkColors(opts) {
 462  const { tag, textColor, bgColor, effectiveBg, effectiveBgStops, fontSize, fontWeight, hasDirectText, isEmojiOnly, bgClip, bgImage, classList } = opts;
 463  if (SAFE_TAGS.has(tag)) return [];
 464  const findings = [];
 465
 466  // Pure black background (only solid or near-solid, not semi-transparent overlays)
 467  if (bgColor && bgColor.a >= 0.9 && bgColor.r === 0 && bgColor.g === 0 && bgColor.b === 0) {
 468    findings.push({ id: 'pure-black-white', snippet: '#000000 background' });
 469  }
 470
 471  if (hasDirectText && textColor && !isEmojiOnly) {
 472    // Run background-dependent checks against either a solid bg or, if the
 473    // ancestor is a gradient, against every gradient stop (use the worst case).
 474    const bgs = effectiveBg ? [effectiveBg] : (effectiveBgStops && effectiveBgStops.length ? effectiveBgStops : null);
 475    if (bgs) {
 476      // Gray on colored background — flag if every stop is chromatic
 477      const textLum = relativeLuminance(textColor);
 478      const isGray = !hasChroma(textColor, 20) && textLum > 0.05 && textLum < 0.85;
 479      if (isGray && bgs.every(b => hasChroma(b, 40))) {
 480        const bgLabel = effectiveBg ? colorToHex(effectiveBg) : `gradient(${bgs.map(colorToHex).join(', ')})`;
 481        findings.push({ id: 'gray-on-color', snippet: `text ${colorToHex(textColor)} on bg ${bgLabel}` });
 482      }
 483
 484      // Low contrast (WCAG AA) — worst case across all bg stops
 485      const ratios = bgs.map(b => contrastRatio(textColor, b));
 486      let worstIdx = 0;
 487      for (let i = 1; i < ratios.length; i++) if (ratios[i] < ratios[worstIdx]) worstIdx = i;
 488      const ratio = ratios[worstIdx];
 489      const isHeading = ['h1', 'h2', 'h3'].includes(tag);
 490      const isLargeText = fontSize >= 18 || (fontSize >= 14 && fontWeight >= 700) || isHeading;
 491      const threshold = isLargeText ? 3.0 : 4.5;
 492      if (ratio < threshold) {
 493        findings.push({ id: 'low-contrast', snippet: `${ratio.toFixed(1)}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(bgs[worstIdx])}` });
 494      }
 495    }
 496
 497    // AI palette: purple/violet on headings
 498    if (hasChroma(textColor, 50)) {
 499      const hue = getHue(textColor);
 500      if (hue >= 260 && hue <= 310 && (['h1', 'h2', 'h3'].includes(tag) || fontSize >= 20)) {
 501        findings.push({ id: 'ai-color-palette', snippet: `Purple/violet text (${colorToHex(textColor)}) on heading` });
 502      }
 503    }
 504  }
 505
 506  // Gradient text
 507  if (bgClip === 'text' && bgImage && bgImage.includes('gradient')) {
 508    findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
 509  }
 510
 511  // Tailwind class checks
 512  if (classList) {
 513    const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
 514    if (/\bbg-black\b(?!\/)/.test(classStr)) {
 515      findings.push({ id: 'pure-black-white', snippet: 'bg-black' });
 516    }
 517
 518    const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
 519    const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
 520    if (grayMatch && colorBgMatch) {
 521      findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
 522    }
 523
 524    if (/\bbg-clip-text\b/.test(classStr) && /\bbg-gradient-to-/.test(classStr)) {
 525      findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' });
 526    }
 527
 528    const purpleText = classStr.match(/\btext-(?:purple|violet|indigo)-\d+\b/);
 529    if (purpleText && (['h1', 'h2', 'h3'].includes(tag) || /\btext-(?:[2-9]xl)\b/.test(classStr))) {
 530      findings.push({ id: 'ai-color-palette', snippet: `${purpleText[0]} on heading` });
 531    }
 532
 533    if (/\bfrom-(?:purple|violet|indigo)-\d+\b/.test(classStr) && /\bto-(?:purple|violet|indigo|blue|cyan|pink|fuchsia)-\d+\b/.test(classStr)) {
 534      findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet gradient (Tailwind)' });
 535    }
 536  }
 537
 538  return findings;
 539}
 540
 541function isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg) {
 542  if (!hasShadow && !hasBorder) return false;
 543  return hasRadius || hasBg;
 544}
 545
 546const HEADING_TAGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
 547
 548// Pure check: given a heading and metrics about its previousElementSibling,
 549// decide if the sibling is the canonical "icon-tile-stacked-above-heading" shape.
 550//
 551// Triggers when ALL of the following hold for the sibling:
 552//   • size 32–128px on both axes (not too small, not a hero image)
 553//   • aspect ratio 0.7–1.4 (squarish — excludes wide thumbnails / pill badges)
 554//   • has a non-transparent background-color, background-image, OR a visible border
 555//     (covers solid colors, white-with-border, gradients — anything that visually
 556//      defines a tile)
 557//   • border-radius < width/2 (excludes round avatars; rounded squares pass)
 558//   • contains an <svg> or icon-class <i> element that's smaller than the tile
 559//   • the tile sits above the heading (its bottom is above the heading's top)
 560function checkIconTile(opts) {
 561  const { headingTag, headingText, headingTop,
 562          siblingTag, siblingWidth, siblingHeight, siblingBottom,
 563          siblingBgColor, siblingBgImage, siblingBorderWidth, siblingBorderRadius,
 564          hasIconChild, iconChildWidth } = opts;
 565  if (!HEADING_TAGS.has(headingTag)) return [];
 566  if (!siblingTag) return [];
 567  // Don't recurse into nested headings (e.g. h2 above h3 in a section header)
 568  if (HEADING_TAGS.has(siblingTag)) return [];
 569
 570  // Size window: 32–128px on each axis
 571  if (!(siblingWidth >= 32 && siblingWidth <= 128)) return [];
 572  if (!(siblingHeight >= 32 && siblingHeight <= 128)) return [];
 573
 574  // Squarish aspect ratio
 575  const ratio = siblingWidth / siblingHeight;
 576  if (ratio < 0.7 || ratio > 1.4) return [];
 577
 578  // Must have something that visually defines the tile
 579  const bgVisible = (siblingBgColor && siblingBgColor.a > 0.1)
 580    || (siblingBgImage && siblingBgImage !== 'none' && siblingBgImage !== '');
 581  const borderVisible = siblingBorderWidth > 0;
 582  if (!bgVisible && !borderVisible) return [];
 583
 584  // Exclude circles (avatars). Rounded squares pass.
 585  if (siblingBorderRadius >= siblingWidth / 2) return [];
 586
 587  // Must contain an icon element smaller than the tile
 588  if (!hasIconChild) return [];
 589  if (iconChildWidth && iconChildWidth >= siblingWidth * 0.95) return [];
 590
 591  // Vertical stacking: tile must end above where the heading starts.
 592  // (Allow the check to skip when both top/bottom are 0 — jsdom layout case.)
 593  if (headingTop && siblingBottom && siblingBottom > headingTop + 4) return [];
 594
 595  const text = (headingText || '').trim().slice(0, 60);
 596  return [{
 597    id: 'icon-tile-stack',
 598    snippet: `${Math.round(siblingWidth)}x${Math.round(siblingHeight)}px icon tile above ${headingTag} "${text}"`,
 599  }];
 600}
 601
 602const LAYOUT_TRANSITION_PROPS = new Set([
 603  'width', 'height', 'padding', 'margin',
 604  'max-height', 'max-width', 'min-height', 'min-width',
 605  'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
 606  'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
 607]);
 608
 609function checkMotion(opts) {
 610  const { tag, transitionProperty, animationName, timingFunctions, classList } = opts;
 611  if (SAFE_TAGS.has(tag)) return [];
 612  const findings = [];
 613
 614  // --- Bounce/elastic easing ---
 615  if (animationName && animationName !== 'none' && /bounce|elastic|wobble|jiggle|spring/i.test(animationName)) {
 616    findings.push({ id: 'bounce-easing', snippet: `animation: ${animationName}` });
 617  }
 618  if (classList && /\banimate-bounce\b/.test(classList)) {
 619    findings.push({ id: 'bounce-easing', snippet: 'animate-bounce (Tailwind)' });
 620  }
 621
 622  // Check timing functions for overshoot cubic-bezier (y values outside [0, 1])
 623  if (timingFunctions) {
 624    const bezierRe = /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g;
 625    let m;
 626    while ((m = bezierRe.exec(timingFunctions)) !== null) {
 627      const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]);
 628      if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) {
 629        findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${m[1]}, ${m[2]}, ${m[3]}, ${m[4]})` });
 630        break;
 631      }
 632    }
 633  }
 634
 635  // --- Layout property transition ---
 636  if (transitionProperty && transitionProperty !== 'all' && transitionProperty !== 'none') {
 637    const props = transitionProperty.split(',').map(p => p.trim().toLowerCase());
 638    const layoutFound = props.filter(p => LAYOUT_TRANSITION_PROPS.has(p));
 639    if (layoutFound.length > 0) {
 640      findings.push({ id: 'layout-transition', snippet: `transition: ${layoutFound.join(', ')}` });
 641    }
 642  }
 643
 644  return findings;
 645}
 646
 647function checkGlow(opts) {
 648  const { boxShadow, effectiveBg } = opts;
 649  if (!boxShadow || boxShadow === 'none') return [];
 650  if (!effectiveBg) return [];
 651
 652  // Only flag on dark backgrounds (luminance < 0.1)
 653  const bgLum = relativeLuminance(effectiveBg);
 654  if (bgLum >= 0.1) return [];
 655
 656  // Split multiple shadows (commas not inside parentheses)
 657  const parts = boxShadow.split(/,(?![^(]*\))/);
 658  for (const shadow of parts) {
 659    const colorMatch = shadow.match(/rgba?\([^)]+\)/);
 660    if (!colorMatch) continue;
 661    const color = parseRgb(colorMatch[0]);
 662    if (!color || !hasChroma(color, 30)) continue;
 663
 664    // Extract px values — in computed style: "color Xpx Ypx BLURpx [SPREADpx]"
 665    const afterColor = shadow.substring(shadow.indexOf(colorMatch[0]) + colorMatch[0].length);
 666    const beforeColor = shadow.substring(0, shadow.indexOf(colorMatch[0]));
 667    const pxVals = [...beforeColor.matchAll(/([\d.]+)px/g), ...afterColor.matchAll(/([\d.]+)px/g)]
 668      .map(m => parseFloat(m[1]));
 669
 670    // Third value is blur (offset-x, offset-y, blur, [spread])
 671    if (pxVals.length >= 3 && pxVals[2] > 4) {
 672      return [{ id: 'dark-glow', snippet: `Colored glow (${colorToHex(color)}) on dark background` }];
 673    }
 674  }
 675
 676  return [];
 677}
 678
 679/**
 680 * Regex-on-HTML checks shared between browser and Node page-level detection.
 681 * These don't need DOM access, just the raw HTML string.
 682 */
 683function checkHtmlPatterns(html) {
 684  const findings = [];
 685
 686  // --- Color ---
 687
 688  // Pure black background
 689  const pureBlackBgRe = /background(?:-color)?\s*:\s*(?:#000000|#000|rgb\(\s*0,\s*0,\s*0\s*\))\b/gi;
 690  if (pureBlackBgRe.test(html)) {
 691    findings.push({ id: 'pure-black-white', snippet: 'Pure #000 background' });
 692  }
 693
 694  // AI color palette: purple/violet
 695  const purpleHexRe = /#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9|6366f1|764ba2|667eea)\b/gi;
 696  if (purpleHexRe.test(html)) {
 697    const purpleTextRe = /(?:(?:^|;)\s*color\s*:\s*(?:.*?)(?:#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9))|gradient.*?#(?:7c3aed|8b5cf6|a855f7|764ba2|667eea))/gi;
 698    if (purpleTextRe.test(html)) {
 699      findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected' });
 700    }
 701  }
 702
 703  // Gradient text (background-clip: text + gradient)
 704  const gradientRe = /(?:-webkit-)?background-clip\s*:\s*text/gi;
 705  let gm;
 706  while ((gm = gradientRe.exec(html)) !== null) {
 707    const start = Math.max(0, gm.index - 200);
 708    const context = html.substring(start, gm.index + gm[0].length + 200);
 709    if (/gradient/i.test(context)) {
 710      findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
 711      break;
 712    }
 713  }
 714  if (/\bbg-clip-text\b/.test(html) && /\bbg-gradient-to-/.test(html)) {
 715    findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' });
 716  }
 717
 718  // --- Layout ---
 719
 720  // Monotonous spacing
 721  const spacingValues = [];
 722  const spacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*(\d+)px/gi;
 723  let sm;
 724  while ((sm = spacingRe.exec(html)) !== null) {
 725    const v = parseInt(sm[1], 10);
 726    if (v > 0 && v < 200) spacingValues.push(v);
 727  }
 728  const gapRe = /gap\s*:\s*(\d+)px/gi;
 729  while ((sm = gapRe.exec(html)) !== null) {
 730    spacingValues.push(parseInt(sm[1], 10));
 731  }
 732  const twSpaceRe = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+)\b/g;
 733  while ((sm = twSpaceRe.exec(html)) !== null) {
 734    spacingValues.push(parseInt(sm[1], 10) * 4);
 735  }
 736  const remSpacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*([\d.]+)rem/gi;
 737  while ((sm = remSpacingRe.exec(html)) !== null) {
 738    const v = Math.round(parseFloat(sm[1]) * 16);
 739    if (v > 0 && v < 200) spacingValues.push(v);
 740  }
 741  const roundedSpacing = spacingValues.map(v => Math.round(v / 4) * 4);
 742  if (roundedSpacing.length >= 10) {
 743    const counts = {};
 744    for (const v of roundedSpacing) counts[v] = (counts[v] || 0) + 1;
 745    const maxCount = Math.max(...Object.values(counts));
 746    const dominantPct = maxCount / roundedSpacing.length;
 747    const unique = [...new Set(roundedSpacing)].filter(v => v > 0);
 748    if (dominantPct > 0.6 && unique.length <= 3) {
 749      const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
 750      findings.push({
 751        id: 'monotonous-spacing',
 752        snippet: `~${dominant}px used ${maxCount}/${roundedSpacing.length} times (${Math.round(dominantPct * 100)}%)`,
 753      });
 754    }
 755  }
 756
 757  // --- Motion ---
 758
 759  // Bounce/elastic animation names
 760  const bounceRe = /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi;
 761  if (bounceRe.test(html)) {
 762    findings.push({ id: 'bounce-easing', snippet: 'Bounce/elastic animation in CSS' });
 763  }
 764
 765  // Overshoot cubic-bezier
 766  const bezierRe = /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g;
 767  let bm;
 768  while ((bm = bezierRe.exec(html)) !== null) {
 769    const y1 = parseFloat(bm[2]), y2 = parseFloat(bm[4]);
 770    if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) {
 771      findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})` });
 772      break;
 773    }
 774  }
 775
 776  // Layout property transitions
 777  const transRe = /transition(?:-property)?\s*:\s*([^;{}]+)/gi;
 778  let tm;
 779  while ((tm = transRe.exec(html)) !== null) {
 780    const val = tm[1].toLowerCase();
 781    if (/\ball\b/.test(val)) continue;
 782    const found = val.match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
 783    if (found) {
 784      findings.push({ id: 'layout-transition', snippet: `transition: ${found.join(', ')}` });
 785      break;
 786    }
 787  }
 788
 789  // --- Dark glow ---
 790
 791  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;
 792  const twDarkBg = /\bbg-(?:gray|slate|zinc|neutral|stone)-(?:9\d{2}|800)\b/;
 793  if (darkBgRe.test(html) || twDarkBg.test(html)) {
 794    const shadowRe = /box-shadow\s*:\s*([^;{}]+)/gi;
 795    let shm;
 796    while ((shm = shadowRe.exec(html)) !== null) {
 797      const val = shm[1];
 798      const colorMatch = val.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
 799      if (!colorMatch) continue;
 800      const [r, g, b] = [+colorMatch[1], +colorMatch[2], +colorMatch[3]];
 801      if ((Math.max(r, g, b) - Math.min(r, g, b)) < 30) continue;
 802      const pxVals = [...val.matchAll(/(\d+)px|(?<![.\d])\b(0)\b(?![.\d])/g)].map(p => +(p[1] || p[2]));
 803      if (pxVals.length >= 3 && pxVals[2] > 4) {
 804        findings.push({ id: 'dark-glow', snippet: `Colored glow (rgb(${r},${g},${b})) on dark page` });
 805        break;
 806      }
 807    }
 808  }
 809
 810  return findings;
 811}
 812
 813// ─── Section 4: resolveBackground (unified) ─────────────────────────────────
 814
 815function resolveBackground(el, win) {
 816  let current = el;
 817  while (current && current.nodeType === 1) {
 818    const style = IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
 819
 820    // If this element has a background-image (gradient or url), it's visually
 821    // opaque but we can't determine the effective color — bail out so callers
 822    // don't get a false solid-color answer.
 823    const bgImage = style.backgroundImage || '';
 824    if (bgImage && bgImage !== 'none' && (/gradient/i.test(bgImage) || /url\s*\(/i.test(bgImage))) {
 825      return null;
 826    }
 827
 828    let bg = parseRgb(style.backgroundColor);
 829    if (!IS_BROWSER && (!bg || bg.a < 0.1)) {
 830      // jsdom doesn't decompose background shorthand — parse raw style attr
 831      const rawStyle = current.getAttribute?.('style') || '';
 832      const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
 833      const inlineBg = bgMatch ? bgMatch[1].trim() : '';
 834      // Check for gradient or url() image in inline style too
 835      if (/gradient/i.test(inlineBg) || /url\s*\(/i.test(inlineBg)) return null;
 836      bg = parseRgb(inlineBg);
 837      if (!bg && inlineBg) {
 838        const hexMatch = inlineBg.match(/#([0-9a-f]{6}|[0-9a-f]{3})\b/i);
 839        if (hexMatch) {
 840          const h = hexMatch[1];
 841          if (h.length === 6) {
 842            bg = { r: parseInt(h.slice(0,2), 16), g: parseInt(h.slice(2,4), 16), b: parseInt(h.slice(4,6), 16), a: 1 };
 843          } else {
 844            bg = { r: parseInt(h[0]+h[0], 16), g: parseInt(h[1]+h[1], 16), b: parseInt(h[2]+h[2], 16), a: 1 };
 845          }
 846        }
 847      }
 848    }
 849    if (bg && bg.a > 0.1) {
 850      if (IS_BROWSER || bg.a >= 0.5) return bg;
 851    }
 852    current = current.parentElement;
 853  }
 854  return { r: 255, g: 255, b: 255 };
 855}
 856
 857// Walk parents looking for a gradient background and return its color stops.
 858// Used as a fallback when resolveBackground() returns null because the
 859// effective background is a gradient (no single solid color to compare against).
 860function resolveGradientStops(el, win) {
 861  let current = el;
 862  while (current && current.nodeType === 1) {
 863    const style = IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
 864    const bgImage = style.backgroundImage || '';
 865    if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
 866      const stops = parseGradientColors(bgImage);
 867      if (stops.length > 0) return stops;
 868    }
 869    if (!IS_BROWSER) {
 870      // jsdom doesn't decompose `background:` shorthand — peek at the raw inline style
 871      const rawStyle = current.getAttribute?.('style') || '';
 872      const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
 873      if (bgMatch && /gradient/i.test(bgMatch[1])) {
 874        const stops = parseGradientColors(bgMatch[1]);
 875        if (stops.length > 0) return stops;
 876      }
 877    }
 878    current = current.parentElement;
 879  }
 880  return null;
 881}
 882
 883// ─── Section 5: Element Adapters ────────────────────────────────────────────
 884
 885// Browser adapters — call getComputedStyle/getBoundingClientRect on live DOM
 886
 887function checkElementBordersDOM(el) {
 888  const tag = el.tagName.toLowerCase();
 889  if (BORDER_SAFE_TAGS.has(tag)) return [];
 890  const rect = el.getBoundingClientRect();
 891  if (rect.width < 20 || rect.height < 20) return [];
 892  const style = getComputedStyle(el);
 893  const sides = ['Top', 'Right', 'Bottom', 'Left'];
 894  const widths = {}, colors = {};
 895  for (const s of sides) {
 896    widths[s] = parseFloat(style[`border${s}Width`]) || 0;
 897    colors[s] = style[`border${s}Color`] || '';
 898  }
 899  return checkBorders(tag, widths, colors, parseFloat(style.borderRadius) || 0);
 900}
 901
 902function checkElementColorsDOM(el) {
 903  const tag = el.tagName.toLowerCase();
 904  if (SAFE_TAGS.has(tag)) return [];
 905  const rect = el.getBoundingClientRect();
 906  if (rect.width < 10 || rect.height < 10) return [];
 907  const style = getComputedStyle(el);
 908  const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
 909  const hasDirectText = directText.trim().length > 0;
 910  const effectiveBg = resolveBackground(el);
 911  return checkColors({
 912    tag,
 913    textColor: parseRgb(style.color),
 914    bgColor: parseRgb(style.backgroundColor),
 915    effectiveBg,
 916    effectiveBgStops: effectiveBg ? null : resolveGradientStops(el),
 917    fontSize: parseFloat(style.fontSize) || 16,
 918    fontWeight: parseInt(style.fontWeight) || 400,
 919    hasDirectText,
 920    isEmojiOnly: isEmojiOnlyText(directText),
 921    bgClip: style.webkitBackgroundClip || style.backgroundClip || '',
 922    bgImage: style.backgroundImage || '',
 923    classList: el.getAttribute('class') || '',
 924  });
 925}
 926
 927function checkElementIconTileDOM(el) {
 928  const tag = el.tagName.toLowerCase();
 929  if (!HEADING_TAGS.has(tag)) return [];
 930  const sibling = el.previousElementSibling;
 931  if (!sibling) return [];
 932
 933  const sibRect = sibling.getBoundingClientRect();
 934  const headRect = el.getBoundingClientRect();
 935  const sibStyle = getComputedStyle(sibling);
 936
 937  // The tile may either contain an <svg>/<i> icon child, OR the tile itself
 938  // may contain an emoji/symbol character directly as its only text content
 939  // (the "card-icon" pattern from many AI-generated demos).
 940  const iconChild = sibling.querySelector('svg, i[data-lucide], i[class*="fa-"], i[class*="icon"]');
 941  const iconRect = iconChild?.getBoundingClientRect();
 942  const sibDirectText = [...sibling.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
 943  const hasInlineEmojiIcon = sibling.children.length === 0 && isEmojiOnlyText(sibDirectText);
 944
 945  return checkIconTile({
 946    headingTag: tag,
 947    headingText: el.textContent || '',
 948    headingTop: headRect.top,
 949    siblingTag: sibling.tagName.toLowerCase(),
 950    siblingWidth: sibRect.width,
 951    siblingHeight: sibRect.height,
 952    siblingBottom: sibRect.bottom,
 953    siblingBgColor: parseRgb(sibStyle.backgroundColor),
 954    siblingBgImage: sibStyle.backgroundImage || '',
 955    siblingBorderWidth: parseFloat(sibStyle.borderTopWidth) || 0,
 956    siblingBorderRadius: parseFloat(sibStyle.borderRadius) || 0,
 957    hasIconChild: !!iconChild || hasInlineEmojiIcon,
 958    iconChildWidth: iconRect?.width || 0,
 959  });
 960}
 961
 962function checkElementMotionDOM(el) {
 963  const tag = el.tagName.toLowerCase();
 964  if (SAFE_TAGS.has(tag)) return [];
 965  const style = getComputedStyle(el);
 966  return checkMotion({
 967    tag,
 968    transitionProperty: style.transitionProperty || '',
 969    animationName: style.animationName || '',
 970    timingFunctions: [style.animationTimingFunction, style.transitionTimingFunction].filter(Boolean).join(' '),
 971    classList: el.getAttribute('class') || '',
 972  });
 973}
 974
 975function checkElementGlowDOM(el) {
 976  const tag = el.tagName.toLowerCase();
 977  const style = getComputedStyle(el);
 978  if (!style.boxShadow || style.boxShadow === 'none') return [];
 979  // Use parent's background — glow radiates outward, so the surrounding context matters
 980  // If resolveBackground returns null (gradient), try to infer from the gradient colors
 981  let parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el);
 982  if (!parentBg) {
 983    // Gradient background — sample its colors to determine if it's dark
 984    let cur = el.parentElement;
 985    while (cur && cur.nodeType === 1) {
 986      const bgImage = getComputedStyle(cur).backgroundImage || '';
 987      const gradColors = parseGradientColors(bgImage);
 988      if (gradColors.length > 0) {
 989        // Average the gradient colors
 990        const avg = { r: 0, g: 0, b: 0 };
 991        for (const c of gradColors) { avg.r += c.r; avg.g += c.g; avg.b += c.b; }
 992        avg.r = Math.round(avg.r / gradColors.length);
 993        avg.g = Math.round(avg.g / gradColors.length);
 994        avg.b = Math.round(avg.b / gradColors.length);
 995        parentBg = avg;
 996        break;
 997      }
 998      cur = cur.parentElement;
 999    }
1000  }
1001  return checkGlow({ tag, boxShadow: style.boxShadow, effectiveBg: parentBg });
1002}
1003
1004function checkElementAIPaletteDOM(el) {
1005  const style = getComputedStyle(el);
1006  const findings = [];
1007
1008  // Check gradient backgrounds for purple/violet or cyan
1009  const bgImage = style.backgroundImage || '';
1010  const gradColors = parseGradientColors(bgImage);
1011  for (const c of gradColors) {
1012    if (hasChroma(c, 50)) {
1013      const hue = getHue(c);
1014      if (hue >= 260 && hue <= 310) {
1015        findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet gradient background' });
1016        break;
1017      }
1018      if (hue >= 160 && hue <= 200) {
1019        findings.push({ id: 'ai-color-palette', snippet: 'Cyan gradient background' });
1020        break;
1021      }
1022    }
1023  }
1024
1025  // Check for neon text (vivid cyan/purple color on dark background)
1026  const textColor = parseRgb(style.color);
1027  if (textColor && hasChroma(textColor, 80)) {
1028    const hue = getHue(textColor);
1029    const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
1030    if (isAIPalette) {
1031      const parentBg = el.parentElement ? resolveBackground(el.parentElement) : null;
1032      // Also check gradient parents
1033      let effectiveBg = parentBg;
1034      if (!effectiveBg) {
1035        let cur = el.parentElement;
1036        while (cur && cur.nodeType === 1) {
1037          const gi = getComputedStyle(cur).backgroundImage || '';
1038          const gc = parseGradientColors(gi);
1039          if (gc.length > 0) {
1040            const avg = { r: 0, g: 0, b: 0 };
1041            for (const c of gc) { avg.r += c.r; avg.g += c.g; avg.b += c.b; }
1042            avg.r = Math.round(avg.r / gc.length);
1043            avg.g = Math.round(avg.g / gc.length);
1044            avg.b = Math.round(avg.b / gc.length);
1045            effectiveBg = avg;
1046            break;
1047          }
1048          cur = cur.parentElement;
1049        }
1050      }
1051      if (effectiveBg && relativeLuminance(effectiveBg) < 0.1) {
1052        const label = hue >= 260 ? 'Purple/violet' : 'Cyan';
1053        findings.push({ id: 'ai-color-palette', snippet: `${label} neon text on dark background` });
1054      }
1055    }
1056  }
1057
1058  return findings;
1059}
1060
1061const QUALITY_TEXT_TAGS = new Set(['p', 'li', 'td', 'th', 'dd', 'blockquote', 'figcaption']);
1062
1063// Resolve a CSS font-size value to pixels by walking up the parent chain.
1064// Browsers resolve em/rem/% to px in getComputedStyle, but jsdom returns the
1065// specified value verbatim — so for the Node path we walk parents ourselves.
1066function resolveFontSizePx(el, win) {
1067  const chain = []; // raw font-size strings, leaf → root
1068  let cur = el;
1069  while (cur && cur.nodeType === 1) {
1070    const fs = (win ? win.getComputedStyle(cur) : getComputedStyle(cur)).fontSize;
1071    chain.push(fs || '');
1072    cur = cur.parentElement;
1073  }
1074  // Walk root → leaf, resolving each value relative to its parent context.
1075  let px = 16; // root default
1076  for (let i = chain.length - 1; i >= 0; i--) {
1077    const v = chain[i];
1078    if (!v || v === 'inherit') continue;
1079    const num = parseFloat(v);
1080    if (isNaN(num)) continue;
1081    if (v.endsWith('px')) px = num;
1082    else if (v.endsWith('rem')) px = num * 16;
1083    else if (v.endsWith('em')) px = num * px;
1084    else if (v.endsWith('%')) px = (num / 100) * px;
1085    else px = num; // unitless — already resolved
1086  }
1087  return px;
1088}
1089
1090// Resolve a CSS length value (line-height, letter-spacing, etc.) given a
1091// known font-size context. Returns null for "normal" / unparseable values.
1092function resolveLengthPx(value, fontSizePx) {
1093  if (!value || value === 'normal' || value === 'auto' || value === 'inherit') return null;
1094  const num = parseFloat(value);
1095  if (isNaN(num)) return null;
1096  if (value.endsWith('px')) return num;
1097  if (value.endsWith('rem')) return num * 16;
1098  if (value.endsWith('em')) return num * fontSizePx;
1099  if (value.endsWith('%')) return (num / 100) * fontSizePx;
1100  // Unitless line-height = multiplier, return px equivalent
1101  return num * fontSizePx;
1102}
1103
1104// Pure quality checks. Most run on computed CSS and DOM-only inputs (work in
1105// jsdom and the browser). Two checks (line-length, cramped-padding) gate on
1106// element rect dimensions, which jsdom can't compute — pass `rect: null` from
1107// the Node adapter to skip those.
1108//
1109// Both adapters resolve font-size, line-height and letter-spacing to pixels
1110// before calling this so the pure function only deals with numbers.
1111function checkQuality(opts) {
1112  const { el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax = 80 } = opts;
1113  const findings = [];
1114  // Skip browser extension injected elements
1115  const elId = el.id || '';
1116  if (elId.startsWith('claude-') || elId.startsWith('cic-')) return findings;
1117
1118  // --- Line length too long --- (browser-only: needs rect.width)
1119  if (rect && hasDirectText && QUALITY_TEXT_TAGS.has(tag) && rect.width > 0 && textLen > lineMax) {
1120    const charsPerLine = rect.width / (fontSize * 0.5);
1121    if (charsPerLine > lineMax + 5) {
1122      findings.push({ id: 'line-length', snippet: `~${Math.round(charsPerLine)} chars/line (aim for <${lineMax})` });
1123    }
1124  }
1125
1126  // --- Cramped padding --- (browser-only: needs rect to skip small badges/labels)
1127  // Vertical and horizontal thresholds are independent because line-height
1128  // already provides built-in vertical breathing room (the line box is taller
1129  // than the cap height), but horizontal has no equivalent. Both scale with
1130  // font-size — bigger text demands proportionally more padding.
1131  //   vertical:   max(4px, fontSize × 0.3)
1132  //   horizontal: max(8px, fontSize × 0.5)
1133  if (rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) {
1134    const borders = {
1135      top: parseFloat(style.borderTopWidth) || 0,
1136      right: parseFloat(style.borderRightWidth) || 0,
1137      bottom: parseFloat(style.borderBottomWidth) || 0,
1138      left: parseFloat(style.borderLeftWidth) || 0,
1139    };
1140    const borderCount = Object.values(borders).filter(w => w > 0).length;
1141    const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)';
1142    if (borderCount >= 2 || hasBg) {
1143      const vPads = [], hPads = [];
1144      if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0);
1145      if (hasBg || borders.bottom > 0) vPads.push(parseFloat(style.paddingBottom) || 0);
1146      if (hasBg || borders.left > 0) hPads.push(parseFloat(style.paddingLeft) || 0);
1147      if (hasBg || borders.right > 0) hPads.push(parseFloat(style.paddingRight) || 0);
1148
1149      const vMin = vPads.length ? Math.min(...vPads) : Infinity;
1150      const hMin = hPads.length ? Math.min(...hPads) : Infinity;
1151      const vThresh = Math.max(4, fontSize * 0.3);
1152      const hThresh = Math.max(8, fontSize * 0.5);
1153
1154      // Emit at most one finding per element — pick whichever axis is worse.
1155      if (vMin < vThresh) {
1156        findings.push({ id: 'cramped-padding', snippet: `${vMin}px vertical padding (need ≥${vThresh.toFixed(1)}px for ${fontSize}px text)` });
1157      } else if (hMin < hThresh) {
1158        findings.push({ id: 'cramped-padding', snippet: `${hMin}px horizontal padding (need ≥${hThresh.toFixed(1)}px for ${fontSize}px text)` });
1159      }
1160    }
1161  }
1162
1163  // --- Tight line height ---
1164  if (hasDirectText && textLen > 50 && !['h1','h2','h3','h4','h5','h6'].includes(tag)) {
1165    if (lineHeightPx != null && fontSize > 0) {
1166      const ratio = lineHeightPx / fontSize;
1167      if (ratio > 0 && ratio < 1.3) {
1168        findings.push({ id: 'tight-leading', snippet: `line-height ${ratio.toFixed(2)}x (need >=1.3)` });
1169      }
1170    }
1171  }
1172
1173  // --- Justified text (without hyphens) ---
1174  if (hasDirectText && style.textAlign === 'justify') {
1175    const hyphens = style.hyphens || style.webkitHyphens || '';
1176    if (hyphens !== 'auto') {
1177      findings.push({ id: 'justified-text', snippet: 'text-align: justify without hyphens: auto' });
1178    }
1179  }
1180
1181  // --- Tiny body text ---
1182  // Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.)
1183  if (hasDirectText && textLen > 20 && fontSize < 12) {
1184    const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption'];
1185    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]');
1186    const isUppercase = style.textTransform === 'uppercase';
1187    if (!skipTags.includes(tag) && !inUIContext && !isUppercase) {
1188      findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` });
1189    }
1190  }
1191
1192  // --- All-caps body text ---
1193  if (hasDirectText && textLen > 30 && style.textTransform === 'uppercase') {
1194    if (!['h1','h2','h3','h4','h5','h6'].includes(tag)) {
1195      findings.push({ id: 'all-caps-body', snippet: `text-transform: uppercase on ${textLen} chars of body text` });
1196    }
1197  }
1198
1199  // --- Wide letter spacing on body text ---
1200  if (hasDirectText && textLen > 20 && style.textTransform !== 'uppercase') {
1201    if (letterSpacingPx != null && letterSpacingPx > 0 && fontSize > 0) {
1202      const trackingEm = letterSpacingPx / fontSize;
1203      if (trackingEm > 0.05) {
1204        findings.push({ id: 'wide-tracking', snippet: `letter-spacing: ${trackingEm.toFixed(2)}em on body text` });
1205      }
1206    }
1207  }
1208
1209  return findings;
1210}
1211
1212function checkElementQualityDOM(el) {
1213  const tag = el.tagName.toLowerCase();
1214  const style = getComputedStyle(el);
1215  const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 10);
1216  const textLen = el.textContent?.trim().length || 0;
1217  // Browser getComputedStyle resolves everything to px — direct parseFloat
1218  // works.
1219  const fontSize = parseFloat(style.fontSize) || 16;
1220  const lineHeightPx = resolveLengthPx(style.lineHeight, fontSize);
1221  const letterSpacingPx = resolveLengthPx(style.letterSpacing, fontSize);
1222  const rect = el.getBoundingClientRect();
1223  const lineMax = (typeof window !== 'undefined' && window.__IMPECCABLE_CONFIG__?.lineLengthMax) || 80;
1224  return checkQuality({ el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax });
1225}
1226
1227// Pure page-level skipped-heading walk. Takes a Document so it works in both
1228// the browser and jsdom.
1229function checkPageQualityFromDoc(doc) {
1230  const findings = [];
1231  const headings = doc.querySelectorAll('h1, h2, h3, h4, h5, h6');
1232  let prevLevel = 0;
1233  let prevText = '';
1234  for (const h of headings) {
1235    const level = parseInt(h.tagName[1]);
1236    const text = (h.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 60);
1237    if (prevLevel > 0 && level > prevLevel + 1) {
1238      findings.push({
1239        id: 'skipped-heading',
1240        snippet: `<h${prevLevel}> "${prevText}" followed by <h${level}> "${text}" (missing h${prevLevel + 1})`,
1241      });
1242    }
1243    prevLevel = level;
1244    prevText = text;
1245  }
1246  return findings;
1247}
1248
1249// Browser adapter (returns the legacy { type, detail } shape used by the overlay loop)
1250function checkPageQualityDOM() {
1251  return checkPageQualityFromDoc(document).map(f => ({ type: f.id, detail: f.snippet }));
1252}
1253
1254// Node adapters — take pre-extracted jsdom computed style
1255
1256// jsdom doesn't lay out OR resolve em/rem/% to px — so we pre-resolve every
1257// CSS length the rule needs ourselves (walking the parent chain for
1258// font-size inheritance), and pass `rect: null` to skip the two rules that
1259// genuinely need element rects (line-length, cramped-padding).
1260function checkElementQuality(el, style, tag, window) {
1261  const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 10);
1262  const textLen = el.textContent?.trim().length || 0;
1263  const fontSize = resolveFontSizePx(el, window);
1264  const lineHeightPx = resolveLengthPx(style.lineHeight, fontSize);
1265  const letterSpacingPx = resolveLengthPx(style.letterSpacing, fontSize);
1266  return checkQuality({ el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect: null });
1267}
1268
1269function checkElementBorders(tag, style, overrides) {
1270  const sides = ['Top', 'Right', 'Bottom', 'Left'];
1271  const widths = {}, colors = {};
1272  for (const s of sides) {
1273    widths[s] = parseFloat(style[`border${s}Width`]) || 0;
1274    colors[s] = style[`border${s}Color`] || '';
1275    // jsdom silently drops any border shorthand containing var(), leaving
1276    // both width and color empty on the computed style. When the detectHtml
1277    // pre-pass pulled a resolved value off the rule, use it to fill in the
1278    // missing side so the side-tab check can run. Real browsers resolve
1279    // var() natively, so this fallback is a no-op in the browser path.
1280    if (widths[s] === 0 && overrides && overrides[s]) {
1281      widths[s] = overrides[s].width;
1282      colors[s] = overrides[s].color;
1283    } else if (colors[s] && colors[s].startsWith('var(') && overrides && overrides[s]) {
1284      // Longhand case: jsdom kept the width but left the color as the
1285      // literal `var(...)` string. Substitute the resolved color.
1286      colors[s] = overrides[s].color;
1287    }
1288  }
1289  return checkBorders(tag, widths, colors, parseFloat(style.borderRadius) || 0);
1290}
1291
1292function checkElementColors(el, style, tag, window) {
1293  const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
1294  const hasDirectText = directText.trim().length > 0;
1295
1296  const effectiveBg = resolveBackground(el, window);
1297  return checkColors({
1298    tag,
1299    textColor: parseRgb(style.color),
1300    bgColor: parseRgb(style.backgroundColor),
1301    effectiveBg,
1302    effectiveBgStops: effectiveBg ? null : resolveGradientStops(el, window),
1303    fontSize: parseFloat(style.fontSize) || 16,
1304    fontWeight: parseInt(style.fontWeight) || 400,
1305    hasDirectText,
1306    isEmojiOnly: isEmojiOnlyText(directText),
1307    bgClip: style.webkitBackgroundClip || style.backgroundClip || '',
1308    bgImage: style.backgroundImage || '',
1309    classList: el.getAttribute?.('class') || el.className || '',
1310  });
1311}
1312
1313function checkElementIconTile(el, tag, window) {
1314  if (!HEADING_TAGS.has(tag)) return [];
1315  const sibling = el.previousElementSibling;
1316  if (!sibling) return [];
1317
1318  const sibStyle = window.getComputedStyle(sibling);
1319  // jsdom doesn't lay out — read explicit pixel dimensions from CSS instead.
1320  const sibWidth = parseFloat(sibStyle.width) || 0;
1321  const sibHeight = parseFloat(sibStyle.height) || 0;
1322
1323  const iconChild = sibling.querySelector('svg, i[data-lucide], i[class*="fa-"], i[class*="icon"]');
1324  let iconWidth = 0;
1325  if (iconChild) {
1326    const iconStyle = window.getComputedStyle(iconChild);
1327    iconWidth = parseFloat(iconStyle.width) || parseFloat(iconChild.getAttribute('width')) || 0;
1328  }
1329  // Or: tile contains an emoji/symbol character directly as its only content
1330  const sibDirectText = [...sibling.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
1331  const hasInlineEmojiIcon = sibling.children.length === 0 && isEmojiOnlyText(sibDirectText);
1332
1333  return checkIconTile({
1334    headingTag: tag,
1335    headingText: el.textContent || '',
1336    headingTop: 0, // jsdom: no layout, skip vertical-stacking gate
1337    siblingTag: sibling.tagName.toLowerCase(),
1338    siblingWidth: sibWidth,
1339    siblingHeight: sibHeight,
1340    siblingBottom: 0,
1341    siblingBgColor: parseRgb(sibStyle.backgroundColor),
1342    siblingBgImage: sibStyle.backgroundImage || '',
1343    siblingBorderWidth: parseFloat(sibStyle.borderTopWidth) || 0,
1344    siblingBorderRadius: parseFloat(sibStyle.borderRadius) || 0,
1345    hasIconChild: !!iconChild || hasInlineEmojiIcon,
1346    iconChildWidth: iconWidth,
1347  });
1348}
1349
1350function checkElementMotion(tag, style) {
1351  return checkMotion({
1352    tag,
1353    transitionProperty: style.transitionProperty || '',
1354    animationName: style.animationName || '',
1355    timingFunctions: [style.animationTimingFunction, style.transitionTimingFunction].filter(Boolean).join(' '),
1356    classList: '',
1357  });
1358}
1359
1360function checkElementGlow(tag, style, effectiveBg) {
1361  if (!style.boxShadow || style.boxShadow === 'none') return [];
1362  return checkGlow({ tag, boxShadow: style.boxShadow, effectiveBg });
1363}
1364
1365// ─── Section 6: Page-Level Checks ───────────────────────────────────────────
1366
1367// Browser page-level checks — use document/getComputedStyle globals
1368
1369function checkTypography() {
1370  const findings = [];
1371
1372  // Walk actual text-bearing elements and tally font usage by *computed style*.
1373  // This is much more accurate than scanning CSS rules — it ignores rules that
1374  // exist in the stylesheet but apply to nothing (e.g. demo classes showing
1375  // anti-patterns), and counts what the user actually sees.
1376  const fontUsage = new Map(); // primary font name → count of elements
1377  let totalTextElements = 0;
1378  for (const el of document.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, td, th, dd, blockquote, figcaption, a, button, label, span')) {
1379    // Skip impeccable's own elements
1380    if (el.closest && el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue;
1381    // Only count elements that actually have visible direct text
1382    const hasText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
1383    if (!hasText) continue;
1384    const style = getComputedStyle(el);
1385    const ff = style.fontFamily;
1386    if (!ff) continue;
1387    const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
1388    const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
1389    if (!primary) continue;
1390    fontUsage.set(primary, (fontUsage.get(primary) || 0) + 1);
1391    totalTextElements++;
1392  }
1393
1394  if (totalTextElements >= 20) {
1395    // A font is "primary" if it's used by at least 15% of text elements
1396    const PRIMARY_THRESHOLD = 0.15;
1397    for (const [font, count] of fontUsage) {
1398      const share = count / totalTextElements;
1399      if (share < PRIMARY_THRESHOLD) continue;
1400      if (!OVERUSED_FONTS.has(font)) continue;
1401      if (isBrandFontOnOwnDomain(font)) continue;
1402      findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
1403    }
1404
1405    // Single-font check: only one distinct primary font across all text
1406    if (fontUsage.size === 1) {
1407      const only = [...fontUsage.keys()][0];
1408      findings.push({ type: 'single-font', detail: `only font used is ${only}` });
1409    }
1410  }
1411
1412  const sizes = new Set();
1413  for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) {
1414    const fs = parseFloat(getComputedStyle(el).fontSize);
1415    if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10);
1416  }
1417  if (sizes.size >= 3) {
1418    const sorted = [...sizes].sort((a, b) => a - b);
1419    const ratio = sorted[sorted.length - 1] / sorted[0];
1420    if (ratio < 2.0) {
1421      findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` });
1422    }
1423  }
1424
1425  return findings;
1426}
1427
1428function isCardLikeDOM(el) {
1429  const tag = el.tagName.toLowerCase();
1430  if (SAFE_TAGS.has(tag) || ['input','select','textarea','img','video','canvas','picture'].includes(tag)) return false;
1431  const style = getComputedStyle(el);
1432  const cls = el.getAttribute('class') || '';
1433  const hasShadow = (style.boxShadow && style.boxShadow !== 'none') || /\bshadow(?:-sm|-md|-lg|-xl|-2xl)?\b/.test(cls);
1434  const hasBorder = /\bborder\b/.test(cls);
1435  const hasRadius = parseFloat(style.borderRadius) > 0 || /\brounded(?:-sm|-md|-lg|-xl|-2xl|-full)?\b/.test(cls);
1436  const hasBg = (style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)') || /\bbg-(?:white|gray-\d+|slate-\d+)\b/.test(cls);
1437  return isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg);
1438}
1439
1440function checkLayout() {
1441  const findings = [];
1442  const flaggedEls = new Set();
1443
1444  for (const el of document.querySelectorAll('*')) {
1445    if (!isCardLikeDOM(el) || flaggedEls.has(el)) continue;
1446    const cls = el.getAttribute('class') || '';
1447    const style = getComputedStyle(el);
1448    if (style.position === 'absolute' || style.position === 'fixed') continue;
1449    if (/\b(?:dropdown|popover|tooltip|menu|modal|dialog)\b/i.test(cls)) continue;
1450    if ((el.textContent?.trim().length || 0) < 10) continue;
1451    const rect = el.getBoundingClientRect();
1452    if (rect.width < 50 || rect.height < 30) continue;
1453
1454    let parent = el.parentElement;
1455    while (parent) {
1456      if (isCardLikeDOM(parent)) { flaggedEls.add(el); break; }
1457      parent = parent.parentElement;
1458    }
1459  }
1460
1461  for (const el of flaggedEls) {
1462    let isAncestor = false;
1463    for (const other of flaggedEls) {
1464      if (other !== el && el.contains(other)) { isAncestor = true; break; }
1465    }
1466    if (!isAncestor) findings.push({ type: 'nested-cards', detail: 'Card inside card', el });
1467  }
1468
1469  return findings;
1470}
1471
1472// Node page-level checks — take document/window as parameters
1473
1474function checkPageTypography(doc, win) {
1475  const findings = [];
1476
1477  const fonts = new Set();
1478  const overusedFound = new Set();
1479
1480  for (const sheet of doc.styleSheets) {
1481    let rules;
1482    try { rules = sheet.cssRules || sheet.rules; } catch { continue; }
1483    if (!rules) continue;
1484    for (const rule of rules) {
1485      if (rule.type !== 1) continue;
1486      const ff = rule.style?.fontFamily;
1487      if (!ff) continue;
1488      const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
1489      const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
1490      if (primary) {
1491        fonts.add(primary);
1492        if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
1493      }
1494    }
1495  }
1496
1497  // Check Google Fonts links in HTML
1498  const html = doc.documentElement?.outerHTML || '';
1499  const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
1500  let m;
1501  while ((m = gfRe.exec(html)) !== null) {
1502    const families = m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase());
1503    for (const f of families) {
1504      fonts.add(f);
1505      if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
1506    }
1507  }
1508
1509  // Also parse raw HTML/style content for font-family (jsdom may not expose all via CSSOM)
1510  const ffRe = /font-family\s*:\s*([^;}]+)/gi;
1511  let fm;
1512  while ((fm = ffRe.exec(html)) !== null) {
1513    for (const f of fm[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) {
1514      if (f && !GENERIC_FONTS.has(f)) {
1515        fonts.add(f);
1516        if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
1517      }
1518    }
1519  }
1520
1521  for (const font of overusedFound) {
1522    findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` });
1523  }
1524
1525  // Single font
1526  if (fonts.size === 1) {
1527    const els = doc.querySelectorAll('*');
1528    if (els.length >= 20) {
1529      findings.push({ id: 'single-font', snippet: `only font used is ${[...fonts][0]}` });
1530    }
1531  }
1532
1533  // Flat type hierarchy
1534  const sizes = new Set();
1535  const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div');
1536  for (const el of textEls) {
1537    const fontSize = parseFloat(win.getComputedStyle(el).fontSize);
1538    // Filter out sub-8px values (jsdom doesn't resolve relative units properly)
1539    if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10);
1540  }
1541  if (sizes.size >= 3) {
1542    const sorted = [...sizes].sort((a, b) => a - b);
1543    const ratio = sorted[sorted.length - 1] / sorted[0];
1544    if (ratio < 2.0) {
1545      findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` });
1546    }
1547  }
1548
1549  return findings;
1550}
1551
1552function isCardLike(el, win) {
1553  const tag = el.tagName.toLowerCase();
1554  if (SAFE_TAGS.has(tag) || ['input', 'select', 'textarea', 'img', 'video', 'canvas', 'picture'].includes(tag)) return false;
1555
1556  const style = win.getComputedStyle(el);
1557  const rawStyle = el.getAttribute?.('style') || '';
1558  const cls = el.getAttribute?.('class') || '';
1559
1560  const hasShadow = (style.boxShadow && style.boxShadow !== 'none') ||
1561    /\bshadow(?:-sm|-md|-lg|-xl|-2xl)?\b/.test(cls) || /box-shadow/i.test(rawStyle);
1562  const hasBorder = /\bborder\b/.test(cls);
1563  const hasRadius = (parseFloat(style.borderRadius) || 0) > 0 ||
1564    /\brounded(?:-sm|-md|-lg|-xl|-2xl|-full)?\b/.test(cls) || /border-radius/i.test(rawStyle);
1565  const hasBg = /\bbg-(?:white|gray-\d+|slate-\d+)\b/.test(cls) ||
1566    /background(?:-color)?\s*:\s*(?!transparent)/i.test(rawStyle);
1567
1568  return isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg);
1569}
1570
1571function checkPageLayout(doc, win) {
1572  const findings = [];
1573
1574  // Nested cards
1575  const allEls = doc.querySelectorAll('*');
1576  const flaggedEls = new Set();
1577  for (const el of allEls) {
1578    if (!isCardLike(el, win)) continue;
1579    if (flaggedEls.has(el)) continue;
1580
1581    const tag = el.tagName.toLowerCase();
1582    const cls = el.getAttribute?.('class') || '';
1583    const rawStyle = el.getAttribute?.('style') || '';
1584
1585    if (['pre', 'code'].includes(tag)) continue;
1586    if (/\b(?:absolute|fixed)\b/.test(cls) || /position\s*:\s*(?:absolute|fixed)/i.test(rawStyle)) continue;
1587    if ((el.textContent?.trim().length || 0) < 10) continue;
1588    if (/\b(?:dropdown|popover|tooltip|menu|modal|dialog)\b/i.test(cls)) continue;
1589
1590    // Walk up to find card-like ancestor
1591    let parent = el.parentElement;
1592    while (parent) {
1593      if (isCardLike(parent, win)) {
1594        flaggedEls.add(el);
1595        break;
1596      }
1597      parent = parent.parentElement;
1598    }
1599  }
1600
1601  // Only report innermost nested cards
1602  for (const el of flaggedEls) {
1603    let isAncestorOfFlagged = false;
1604    for (const other of flaggedEls) {
1605      if (other !== el && el.contains(other)) {
1606        isAncestorOfFlagged = true;
1607        break;
1608      }
1609    }
1610    if (!isAncestorOfFlagged) {
1611      findings.push({ id: 'nested-cards', snippet: `Card inside card (${el.tagName.toLowerCase()})` });
1612    }
1613  }
1614
1615  // Everything centered
1616  const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, li, div, button');
1617  let centeredCount = 0;
1618  let totalText = 0;
1619  for (const el of textEls) {
1620    const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length >= 3);
1621    if (!hasDirectText) continue;
1622    totalText++;
1623
1624    let cur = el;
1625    let isCentered = false;
1626    while (cur && cur.nodeType === 1) {
1627      const rawStyle = cur.getAttribute?.('style') || '';
1628      const cls = cur.getAttribute?.('class') || '';
1629      if (/text-align\s*:\s*center/i.test(rawStyle) || /\btext-center\b/.test(cls)) {
1630        isCentered = true;
1631        break;
1632      }
1633      if (cur.tagName === 'BODY') break;
1634      cur = cur.parentElement;
1635    }
1636    if (isCentered) centeredCount++;
1637  }
1638
1639  if (totalText >= 5 && centeredCount / totalText > 0.7) {
1640    findings.push({
1641      id: 'everything-centered',
1642      snippet: `${centeredCount}/${totalText} text elements centered (${Math.round(centeredCount / totalText * 100)}%)`,
1643    });
1644  }
1645
1646  return findings;
1647}
1648
1649// ─── Section 7: Browser UI (IS_BROWSER only) ────────────────────────────────
1650
1651if (IS_BROWSER) {
1652  // Detect extension mode via the script tag's data attribute or the document element fallback.
1653  // currentScript is reliable for synchronously-executing scripts (which our IIFE is).
1654  const _myScript = document.currentScript;
1655  const EXTENSION_MODE = (_myScript && _myScript.dataset.impeccableExtension === 'true')
1656    || document.documentElement.dataset.impeccableExtension === 'true';
1657
1658  const BRAND_COLOR = 'oklch(55% 0.25 350)';
1659  const BRAND_COLOR_HOVER = 'oklch(45% 0.25 350)';
1660  const LABEL_BG = BRAND_COLOR;
1661  const OUTLINE_COLOR = BRAND_COLOR;
1662
1663  // Inject hover styles via CSS (more reliable than JS event listeners)
1664  const styleEl = document.createElement('style');
1665  styleEl.textContent = `
1666    @keyframes impeccable-reveal {
1667      from { opacity: 0; }
1668      to { opacity: 1; }
1669    }
1670    .impeccable-overlay:not(.impeccable-banner) {
1671      pointer-events: none;
1672      outline: 2px solid ${OUTLINE_COLOR};
1673      border-radius: 4px;
1674      transition: outline-color 0.15s ease;
1675      animation: impeccable-reveal 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
1676      animation-play-state: paused;
1677      border-top-left-radius: 0;
1678    }
1679    .impeccable-overlay.impeccable-visible {
1680      animation-play-state: running;
1681    }
1682    .impeccable-overlay.impeccable-hover {
1683      outline-color: ${BRAND_COLOR_HOVER};
1684      z-index: 100001 !important;
1685    }
1686    .impeccable-overlay.impeccable-hover .impeccable-label {
1687      background: ${BRAND_COLOR_HOVER};
1688    }
1689    .impeccable-overlay.impeccable-spotlight {
1690      z-index: 100002 !important;
1691    }
1692    .impeccable-overlay.impeccable-spotlight-dimmed {
1693      opacity: 0.15 !important;
1694      animation: none !important;
1695      filter: blur(3px);
1696    }
1697    .impeccable-spotlight-backdrop {
1698      position: fixed;
1699      top: 0; left: 0; right: 0; bottom: 0;
1700      backdrop-filter: blur(3px) brightness(0.6);
1701      -webkit-backdrop-filter: blur(3px) brightness(0.6);
1702      pointer-events: none;
1703      z-index: 99998;
1704      opacity: 0;
1705      outline: none !important;
1706      animation: none !important;
1707    }
1708    .impeccable-spotlight-backdrop.impeccable-visible {
1709      opacity: 1;
1710    }
1711    .impeccable-hidden .impeccable-overlay${EXTENSION_MODE ? '' : ':not(.impeccable-banner)'} {
1712      display: none !important;
1713    }
1714    .impeccable-hidden .impeccable-overlay${EXTENSION_MODE ? '' : ':not(.impeccable-banner)'} {
1715      display: none !important;
1716    }
1717  `;
1718  (document.head || document.documentElement).appendChild(styleEl);
1719
1720  // Spotlight backdrop element (created lazily on first use)
1721  let spotlightBackdrop = null;
1722  let spotlightTarget = null;
1723  let spotlightTimer = null;
1724
1725  function getSpotlightBackdrop() {
1726    if (!spotlightBackdrop) {
1727      spotlightBackdrop = document.createElement('div');
1728      spotlightBackdrop.className = 'impeccable-spotlight-backdrop';
1729      document.body.appendChild(spotlightBackdrop);
1730    }
1731    return spotlightBackdrop;
1732  }
1733
1734  function updateSpotlightClipPath() {
1735    if (!spotlightBackdrop || !spotlightTarget) return;
1736    const r = spotlightTarget.getBoundingClientRect();
1737    // Match the overlay's outer edge: element rect + 4px (2px overlay offset + 2px outline width)
1738    const inset = 4;
1739    const radius = 6; // outline border-radius (4) + outline width (2)
1740    const x1 = r.left - inset;
1741    const y1 = r.top - inset;
1742    const x2 = r.right + inset;
1743    const y2 = r.bottom + inset;
1744    const vw = window.innerWidth;
1745    const vh = window.innerHeight;
1746    // Outer rect + rounded inner rect (evenodd creates a hole)
1747    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`;
1748    spotlightBackdrop.style.clipPath = `path(evenodd, "${path}")`;
1749  }
1750
1751  function showSpotlight(target) {
1752    if (!target || !target.getBoundingClientRect) return;
1753    // Respect the spotlightBlur setting: if disabled, don't show the backdrop
1754    if (window.__IMPECCABLE_CONFIG__?.spotlightBlur === false) {
1755      spotlightTarget = target;
1756      return;
1757    }
1758    spotlightTarget = target;
1759    const bd = getSpotlightBackdrop();
1760    updateSpotlightClipPath();
1761    bd.classList.add('impeccable-visible');
1762  }
1763
1764  function hideSpotlight() {
1765    spotlightTarget = null;
1766    if (spotlightTimer) { clearTimeout(spotlightTimer); spotlightTimer = null; }
1767    if (spotlightBackdrop) spotlightBackdrop.classList.remove('impeccable-visible');
1768  }
1769
1770  function isInViewport(el) {
1771    const r = el.getBoundingClientRect();
1772    return r.top >= 0 && r.left >= 0 && r.bottom <= window.innerHeight && r.right <= window.innerWidth;
1773  }
1774
1775  // Reposition spotlight on scroll/resize
1776  window.addEventListener('scroll', () => {
1777    if (spotlightTarget) updateSpotlightClipPath();
1778  }, { passive: true });
1779  window.addEventListener('resize', () => {
1780    if (spotlightTarget) updateSpotlightClipPath();
1781  });
1782
1783  const overlays = [];
1784  const TYPE_LABELS = {};
1785  const RULE_CATEGORY = {};
1786  for (const ap of ANTIPATTERNS) {
1787    TYPE_LABELS[ap.id] = ap.name.toLowerCase();
1788    RULE_CATEGORY[ap.id] = ap.category || 'quality';
1789  }
1790
1791  function isInFixedContext(el) {
1792    let p = el;
1793    while (p && p !== document.body) {
1794      if (getComputedStyle(p).position === 'fixed') return true;
1795      p = p.parentElement;
1796    }
1797    return false;
1798  }
1799
1800  function positionOverlay(overlay) {
1801    const el = overlay._targetEl;
1802    if (!el) return;
1803    const rect = el.getBoundingClientRect();
1804    if (overlay._isFixed) {
1805      // Viewport-relative coords for fixed targets
1806      overlay.style.top = `${rect.top - 2}px`;
1807      overlay.style.left = `${rect.left - 2}px`;
1808    } else {
1809      // Document-relative coords for normal targets
1810      overlay.style.top = `${rect.top + scrollY - 2}px`;
1811      overlay.style.left = `${rect.left + scrollX - 2}px`;
1812    }
1813    overlay.style.width = `${rect.width + 4}px`;
1814    overlay.style.height = `${rect.height + 4}px`;
1815  }
1816
1817  function repositionOverlays() {
1818    for (const o of overlays) {
1819      if (!o._targetEl || o.classList.contains('impeccable-banner')) continue;
1820      // Skip overlays whose target is currently hidden (display: none on the overlay)
1821      if (o.style.display === 'none') continue;
1822      positionOverlay(o);
1823    }
1824  }
1825
1826  let resizeRAF;
1827  const onResize = () => {
1828    cancelAnimationFrame(resizeRAF);
1829    resizeRAF = requestAnimationFrame(repositionOverlays);
1830  };
1831  window.addEventListener('resize', onResize);
1832  // Reposition on scroll too -- catches sticky/parallax shifts
1833  window.addEventListener('scroll', onResize, { passive: true });
1834  // Reposition when body resizes (lazy-loaded images, dynamic content, fonts loading)
1835  if (typeof ResizeObserver !== 'undefined') {
1836    const bodyResizeObserver = new ResizeObserver(onResize);
1837    bodyResizeObserver.observe(document.body);
1838  }
1839
1840  // Track target element visibility via IntersectionObserver.
1841  // Uses a huge rootMargin so all *rendered* elements count as intersecting,
1842  // while display:none / closed <details> / hidden modals etc. do not.
1843  // This is event-driven -- no polling needed.
1844  let overlayIndex = 0;
1845  const visibilityObserver = new IntersectionObserver((entries) => {
1846    for (const entry of entries) {
1847      const overlay = entry.target._impeccableOverlay;
1848      if (!overlay) continue;
1849      if (entry.isIntersecting) {
1850        overlay.style.display = '';
1851        positionOverlay(overlay);
1852        if (!overlay._revealed) {
1853          overlay._revealed = true;
1854          if (firstScanDone) {
1855            // Subsequent reveals (re-scans, scroll-into-view): instant, no animation
1856            overlay.style.animation = 'none';
1857          } else {
1858            // Initial scan: staggered cascade reveal
1859            overlay.style.animationDelay = `${Math.min((overlay._staggerIndex || 0) * 60, 600)}ms`;
1860          }
1861          requestAnimationFrame(() => {
1862            overlay.classList.add('impeccable-visible');
1863            if (overlay._checkLabel) overlay._checkLabel();
1864          });
1865        }
1866      } else {
1867        overlay.style.display = 'none';
1868      }
1869    }
1870  }, { rootMargin: '99999px' });
1871
1872  // Reposition overlays after CSS transitions end (e.g. reveal animations).
1873  // Listens at document level so it catches transitions on ancestor elements
1874  // (the transform may be on a parent, not the flagged element itself).
1875  document.addEventListener('transitionend', (e) => {
1876    if (e.propertyName !== 'transform') return;
1877    for (const o of overlays) {
1878      if (!o._targetEl || o.classList.contains('impeccable-banner') || o.style.display === 'none') continue;
1879      if (e.target === o._targetEl || e.target.contains(o._targetEl)) {
1880        positionOverlay(o);
1881      }
1882    }
1883  });
1884
1885  const highlight = function(el, findings) {
1886    const hasSlop = findings.some(f => RULE_CATEGORY[f.type || f.id] === 'slop');
1887
1888    const fixed = isInFixedContext(el);
1889    const rect = el.getBoundingClientRect();
1890    const outline = document.createElement('div');
1891    outline.className = 'impeccable-overlay';
1892    outline._targetEl = el;
1893    outline._isFixed = fixed;
1894    Object.assign(outline.style, {
1895      position: fixed ? 'fixed' : 'absolute',
1896      top: fixed ? `${rect.top - 2}px` : `${rect.top + scrollY - 2}px`,
1897      left: fixed ? `${rect.left - 2}px` : `${rect.left + scrollX - 2}px`,
1898      width: `${rect.width + 4}px`, height: `${rect.height + 4}px`,
1899      zIndex: '99999', boxSizing: 'border-box',
1900    });
1901
1902    // Build per-finding label entries: ✦ prefix for slop
1903    const entries = findings.map(f => {
1904      const name = TYPE_LABELS[f.type || f.id] || f.type || f.id;
1905      const prefix = RULE_CATEGORY[f.type || f.id] === 'slop' ? '\u2726 ' : '';
1906      return { name: prefix + name, detail: f.detail || f.snippet };
1907    });
1908    const allText = entries.map(e => e.name).join(', ');
1909
1910    const label = document.createElement('div');
1911    label.className = 'impeccable-label';
1912    Object.assign(label.style, {
1913      position: 'absolute', bottom: '100%', left: '-2px',
1914      display: 'flex', alignItems: 'center',
1915      whiteSpace: 'nowrap',
1916      fontSize: '11px', fontWeight: '600', letterSpacing: '0.02em',
1917      color: 'white', lineHeight: '14px',
1918      background: LABEL_BG,
1919      fontFamily: 'system-ui, sans-serif',
1920      borderRadius: '4px 4px 0 0',
1921    });
1922
1923    const textSpan = document.createElement('span');
1924    textSpan.style.padding = '3px 8px';
1925    textSpan.textContent = allText;
1926    label.appendChild(textSpan);
1927
1928    // State for cycling mode
1929    let cycleMode = false;
1930    let cycleIndex = 0;
1931    let isHovered = false;
1932    let prevBtn, nextBtn;
1933
1934    function updateCycleText() {
1935      const e = entries[cycleIndex];
1936      textSpan.textContent = isHovered ? e.detail : e.name;
1937    }
1938
1939    function enableCycleMode() {
1940      if (cycleMode || entries.length < 2) return;
1941      cycleMode = true;
1942
1943      const btnStyle = {
1944        background: 'none', border: 'none', color: 'rgba(255,255,255,0.7)',
1945        fontSize: '11px', cursor: 'pointer', padding: '3px 4px',
1946        fontFamily: 'system-ui, sans-serif', lineHeight: '14px',
1947        pointerEvents: 'auto',
1948      };
1949
1950      const navGroup = document.createElement('span');
1951      Object.assign(navGroup.style, {
1952        display: 'inline-flex', alignItems: 'center', flexShrink: '0',
1953      });
1954
1955      prevBtn = document.createElement('button');
1956      prevBtn.textContent = '\u2039';
1957      Object.assign(prevBtn.style, btnStyle);
1958      prevBtn.style.paddingLeft = '6px';
1959      prevBtn.addEventListener('click', (e) => {
1960        e.stopPropagation();
1961        cycleIndex = (cycleIndex - 1 + entries.length) % entries.length;
1962        updateCycleText();
1963      });
1964
1965      nextBtn = document.createElement('button');
1966      nextBtn.textContent = '\u203A';
1967      Object.assign(nextBtn.style, btnStyle);
1968      nextBtn.style.paddingRight = '2px';
1969      nextBtn.addEventListener('click', (e) => {
1970        e.stopPropagation();
1971        cycleIndex = (cycleIndex + 1) % entries.length;
1972        updateCycleText();
1973      });
1974
1975      navGroup.appendChild(prevBtn);
1976      navGroup.appendChild(nextBtn);
1977      label.insertBefore(navGroup, textSpan);
1978      textSpan.style.padding = '3px 8px 3px 4px';
1979      updateCycleText();
1980    }
1981
1982    outline.appendChild(label);
1983
1984    // Start hidden; the IntersectionObserver will show it once the target is rendered
1985    outline.style.display = 'none';
1986    outline._staggerIndex = overlayIndex++;
1987    el._impeccableOverlay = outline;
1988    visibilityObserver.observe(el);
1989
1990    // After first paint, check label width vs outline
1991    outline._checkLabel = () => {
1992      if (entries.length > 1 && label.offsetWidth > outline.offsetWidth) {
1993        enableCycleMode();
1994      }
1995    };
1996
1997    // Hover: show detail text, darken
1998    el.addEventListener('mouseenter', () => {
1999      isHovered = true;
2000      outline.classList.add('impeccable-hover');
2001      outline.style.outlineColor = BRAND_COLOR_HOVER;
2002      label.style.background = BRAND_COLOR_HOVER;
2003      if (cycleMode) {
2004        updateCycleText();
2005      } else {
2006        textSpan.textContent = entries.map(e => e.detail).join(' | ');
2007      }
2008    });
2009    el.addEventListener('mouseleave', () => {
2010      isHovered = false;
2011      outline.classList.remove('impeccable-hover');
2012      outline.style.outlineColor = '';
2013      label.style.background = LABEL_BG;
2014      if (cycleMode) {
2015        updateCycleText();
2016      } else {
2017        textSpan.textContent = allText;
2018      }
2019    });
2020
2021    document.body.appendChild(outline);
2022    overlays.push(outline);
2023  };
2024
2025  const showPageBanner = function(findings) {
2026    if (!findings.length) return;
2027    const banner = document.createElement('div');
2028    banner.className = 'impeccable-overlay impeccable-banner';
2029    Object.assign(banner.style, {
2030      position: 'fixed', top: '0', left: '0', right: '0', zIndex: '100000',
2031      background: LABEL_BG, color: 'white',
2032      fontFamily: 'system-ui, sans-serif', fontSize: '13px',
2033      display: 'flex', alignItems: 'center', pointerEvents: 'auto',
2034      height: '36px', overflow: 'hidden', maxWidth: '100vw',
2035      transform: 'translateY(-100%)',
2036      transition: 'transform 0.4s cubic-bezier(0.16, 1, 0.3, 1)',
2037    });
2038    requestAnimationFrame(() => requestAnimationFrame(() => {
2039      banner.style.transform = 'translateY(0)';
2040    }));
2041
2042    // Scrollable findings area
2043    const scrollArea = document.createElement('div');
2044    Object.assign(scrollArea.style, {
2045      flex: '1', minWidth: '0', overflowX: 'auto', overflowY: 'hidden',
2046      display: 'flex', gap: '8px', alignItems: 'center',
2047      padding: '0 12px', scrollSnapType: 'x mandatory',
2048      scrollbarWidth: 'none',
2049    });
2050    for (const f of findings) {
2051      const prefix = RULE_CATEGORY[f.type] === 'slop' ? '\u2726 ' : '';
2052      const tag = document.createElement('span');
2053      tag.textContent = `${prefix}${TYPE_LABELS[f.type] || f.type}: ${f.detail}`;
2054      Object.assign(tag.style, {
2055        background: 'rgba(255,255,255,0.15)', padding: '2px 8px',
2056        borderRadius: '3px', fontSize: '12px', fontFamily: 'ui-monospace, monospace',
2057        whiteSpace: 'nowrap', flexShrink: '0', scrollSnapAlign: 'start',
2058      });
2059      scrollArea.appendChild(tag);
2060    }
2061    banner.appendChild(scrollArea);
2062
2063    // Controls area (only in standalone mode, not extension)
2064    if (!EXTENSION_MODE) {
2065      const controls = document.createElement('div');
2066      Object.assign(controls.style, {
2067        display: 'flex', alignItems: 'center', gap: '2px',
2068        padding: '0 8px', flexShrink: '0',
2069      });
2070
2071      // Toggle visibility button
2072      const toggle = document.createElement('button');
2073      toggle.textContent = '\u25C9'; // circle with dot (visible state)
2074      toggle.title = 'Toggle overlay visibility';
2075      Object.assign(toggle.style, {
2076        background: 'none', border: 'none',
2077        color: 'white', fontSize: '16px', cursor: 'pointer', padding: '0 4px',
2078        opacity: '0.85', transition: 'opacity 0.15s',
2079      });
2080      let overlaysVisible = true;
2081      toggle.addEventListener('click', () => {
2082        overlaysVisible = !overlaysVisible;
2083        document.body.classList.toggle('impeccable-hidden', !overlaysVisible);
2084        toggle.textContent = overlaysVisible ? '\u25C9' : '\u25CB'; // filled vs empty circle
2085        toggle.style.opacity = overlaysVisible ? '0.85' : '0.5';
2086      });
2087      controls.appendChild(toggle);
2088
2089      // Close button
2090      const close = document.createElement('button');
2091      close.textContent = '\u00d7';
2092      close.title = 'Dismiss banner';
2093      Object.assign(close.style, {
2094        background: 'none', border: 'none',
2095        color: 'white', fontSize: '18px', cursor: 'pointer', padding: '0 4px',
2096      });
2097      close.addEventListener('click', () => banner.remove());
2098      controls.appendChild(close);
2099
2100      banner.appendChild(controls);
2101    }
2102    document.body.appendChild(banner);
2103    overlays.push(banner);
2104  };
2105
2106  // Heuristic for skipping CSS-in-JS hashed class names like "css-1a2b3c" or "_2x4hG_".
2107  // These change between builds and produce brittle, ugly selectors.
2108  function isLikelyHashedClass(c) {
2109    if (!c) return true;
2110    if (/^(css|sc|emotion|jsx|module)-[\w-]{4,}$/i.test(c)) return true;
2111    if (/^_[\w-]{5,}$/.test(c)) return true;
2112    if (/^[a-z0-9]{6,}$/i.test(c) && /\d/.test(c)) return true;
2113    return false;
2114  }
2115
2116  function buildSelectorSegment(el) {
2117    const tag = el.tagName.toLowerCase();
2118    let sel = tag;
2119
2120    if (el.classList && el.classList.length > 0) {
2121      const classes = [...el.classList]
2122        .filter(c => !c.startsWith('impeccable-') && !isLikelyHashedClass(c))
2123        .slice(0, 2);
2124      if (classes.length > 0) {
2125        sel += '.' + classes.map(c => CSS.escape(c)).join('.');
2126      }
2127    }
2128
2129    // Disambiguate among siblings only if the parent has multiple matches
2130    const parent = el.parentElement;
2131    if (parent) {
2132      try {
2133        const matching = parent.querySelectorAll(':scope > ' + sel);
2134        if (matching.length > 1) {
2135          const sameType = [...parent.children].filter(c => c.tagName === el.tagName);
2136          const idx = sameType.indexOf(el) + 1;
2137          sel += `:nth-of-type(${idx})`;
2138        }
2139      } catch {
2140        const idx = [...parent.children].indexOf(el) + 1;
2141        sel = `${tag}:nth-child(${idx})`;
2142      }
2143    }
2144    return sel;
2145  }
2146
2147  function generateSelector(el) {
2148    if (el === document.body) return 'body';
2149    if (el === document.documentElement) return 'html';
2150    if (el.id) return '#' + CSS.escape(el.id);
2151
2152    const parts = [];
2153    let current = el;
2154    let depth = 0;
2155    const MAX_DEPTH = 10;
2156
2157    while (current && current !== document.body && current !== document.documentElement && depth < MAX_DEPTH) {
2158      parts.unshift(buildSelectorSegment(current));
2159
2160      // Anchor on an ancestor's ID and stop walking up
2161      if (current.id) {
2162        parts[0] = '#' + CSS.escape(current.id);
2163        break;
2164      }
2165
2166      // Stop as soon as the partial selector uniquely identifies the target
2167      const trySelector = parts.join(' > ');
2168      try {
2169        const matches = document.querySelectorAll(trySelector);
2170        if (matches.length === 1 && matches[0] === el) {
2171          return trySelector;
2172        }
2173      } catch { /* invalid selector — keep walking */ }
2174
2175      current = current.parentElement;
2176      depth++;
2177    }
2178
2179    return parts.join(' > ');
2180  }
2181
2182  function isElementHidden(el) {
2183    if (!el || el === document.body || el === document.documentElement) return false;
2184    if (typeof el.checkVisibility === 'function') return !el.checkVisibility({ checkOpacity: false, checkVisibilityCSS: true });
2185    // Fallback: zero size or no offsetParent (covers display:none and detached subtrees)
2186    return el.offsetWidth === 0 && el.offsetHeight === 0;
2187  }
2188
2189  function serializeFindings(allFindings) {
2190    return allFindings.map(({ el, findings }) => ({
2191      selector: generateSelector(el),
2192      tagName: el.tagName?.toLowerCase() || 'unknown',
2193      rect: (el !== document.body && el !== document.documentElement && el.getBoundingClientRect)
2194        ? el.getBoundingClientRect().toJSON() : null,
2195      isPageLevel: el === document.body || el === document.documentElement,
2196      isHidden: isElementHidden(el),
2197      findings: findings.map(f => {
2198        const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id));
2199        return {
2200          type: f.type || f.id,
2201          category: ap ? ap.category : 'quality',
2202          detail: f.detail || f.snippet,
2203          name: ap ? ap.name : (f.type || f.id),
2204          description: ap ? ap.description : '',
2205        };
2206      }),
2207    }));
2208  }
2209
2210  const printSummary = function(allFindings) {
2211    if (allFindings.length === 0) {
2212      console.log('%c[impeccable] No anti-patterns found.', 'color: #22c55e; font-weight: bold');
2213      return;
2214    }
2215    console.group(
2216      `%c[impeccable] ${allFindings.length} anti-pattern${allFindings.length === 1 ? '' : 's'} found`,
2217      'color: oklch(60% 0.25 350); font-weight: bold'
2218    );
2219    for (const { el, findings } of allFindings) {
2220      for (const f of findings) {
2221        console.log(`%c${f.type || f.id}%c ${f.detail || f.snippet}`,
2222          'color: oklch(55% 0.25 350); font-weight: bold', 'color: inherit', el);
2223      }
2224    }
2225    console.groupEnd();
2226  };
2227
2228  let firstScanDone = false;
2229  const scan = function() {
2230    for (const o of overlays) o.remove();
2231    overlays.length = 0;
2232    visibilityObserver.disconnect();
2233    overlayIndex = 0;
2234    const allFindings = [];
2235    const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : [];
2236    const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id);
2237
2238    for (const el of document.querySelectorAll('*')) {
2239      // Skip impeccable's own elements and any descendants (overlays, labels, banner, nav buttons)
2240      if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue;
2241      // Skip browser extension elements (Claude, etc.)
2242      const elId = el.id || '';
2243      if (elId.startsWith('claude-') || elId.startsWith('cic-')) continue;
2244      // Skip html/body -- page-level findings go in the banner, not a full-page overlay
2245      if (el === document.body || el === document.documentElement) continue;
2246
2247      const findings = [
2248        ...checkElementBordersDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
2249        ...checkElementColorsDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
2250        ...checkElementMotionDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
2251        ...checkElementGlowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
2252        ...checkElementAIPaletteDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
2253        ...checkElementIconTileDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
2254        ...checkElementQualityDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
2255      ].filter(f => _ruleOk(f.type));
2256
2257      if (findings.length > 0) {
2258        highlight(el, findings);
2259        allFindings.push({ el, findings });
2260      }
2261    }
2262
2263    const pageLevelFindings = [];
2264
2265    const typoFindings = checkTypography().filter(f => _ruleOk(f.type));
2266    if (typoFindings.length > 0) {
2267      pageLevelFindings.push(...typoFindings);
2268      allFindings.push({ el: document.body, findings: typoFindings });
2269    }
2270
2271    const layoutFindings = checkLayout().filter(f => _ruleOk(f.type));
2272    for (const f of layoutFindings) {
2273      const el = f.el || document.body;
2274      delete f.el;
2275      // Merge into existing overlay if this element already has one
2276      const existing = el._impeccableOverlay;
2277      if (existing) {
2278        const nameRow = existing.querySelector('.impeccable-label-name');
2279        const detailRow = existing.querySelector('.impeccable-label-detail');
2280        const newType = TYPE_LABELS[f.type] || f.type;
2281        if (nameRow) nameRow.textContent += ', ' + newType;
2282        if (detailRow) detailRow.textContent += ' | ' + (f.detail || '');
2283      } else {
2284        highlight(el, [f]);
2285      }
2286      allFindings.push({ el, findings: [f] });
2287    }
2288
2289    // Page-level quality checks (headings, etc.)
2290    const qualityFindings = checkPageQualityDOM().filter(f => _ruleOk(f.type));
2291    if (qualityFindings.length > 0) {
2292      pageLevelFindings.push(...qualityFindings);
2293      allFindings.push({ el: document.body, findings: qualityFindings });
2294    }
2295
2296    // Regex-on-HTML checks (shared with Node)
2297    const htmlPatternFindings = checkHtmlPatterns(document.documentElement.outerHTML);
2298    if (htmlPatternFindings.length > 0) {
2299      const mapped = htmlPatternFindings.map(f => ({ type: f.id, detail: f.snippet })).filter(f => _ruleOk(f.type));
2300      pageLevelFindings.push(...mapped);
2301      allFindings.push({ el: document.body, findings: mapped });
2302    }
2303
2304    if (pageLevelFindings.length > 0) {
2305      showPageBanner(pageLevelFindings);
2306    }
2307
2308    if (!EXTENSION_MODE) printSummary(allFindings);
2309
2310    // In extension mode, post serialized results for the DevTools panel
2311    if (EXTENSION_MODE) {
2312      window.postMessage({
2313        source: 'impeccable-results',
2314        findings: serializeFindings(allFindings),
2315        count: allFindings.length,
2316      }, '*');
2317    }
2318
2319    // After this scan completes, all subsequent reveals are instant (no stagger, no animation)
2320    setTimeout(() => { firstScanDone = true; }, 1000);
2321
2322    return allFindings;
2323  };
2324
2325  if (EXTENSION_MODE) {
2326    // Extension mode: listen for commands, don't auto-scan
2327    window.addEventListener('message', (e) => {
2328      if (e.source !== window || !e.data || e.data.source !== 'impeccable-command') return;
2329      if (e.data.action === 'scan') {
2330        if (e.data.config) window.__IMPECCABLE_CONFIG__ = e.data.config;
2331        scan();
2332      }
2333      if (e.data.action === 'toggle-overlays') {
2334        const visible = !document.body.classList.contains('impeccable-hidden');
2335        document.body.classList.toggle('impeccable-hidden', visible);
2336        window.postMessage({ source: 'impeccable-overlays-toggled', visible: !visible }, '*');
2337      }
2338      if (e.data.action === 'remove') {
2339        for (const o of overlays) o.remove();
2340        overlays.length = 0;
2341        visibilityObserver.disconnect();
2342        styleEl.remove();
2343        if (spotlightBackdrop) { spotlightBackdrop.remove(); spotlightBackdrop = null; }
2344        document.body.classList.remove('impeccable-hidden');
2345      }
2346      if (e.data.action === 'highlight') {
2347        if (spotlightTimer) { clearTimeout(spotlightTimer); spotlightTimer = null; }
2348        try {
2349          const target = e.data.selector ? document.querySelector(e.data.selector) : null;
2350          if (target) {
2351            // Scroll first so positionOverlay reads the post-scroll rect
2352            if (!isInViewport(target) && target.scrollIntoView) {
2353              target.scrollIntoView({ behavior: 'instant', block: 'center' });
2354            }
2355            for (const o of overlays) {
2356              if (o.classList.contains('impeccable-banner')) continue;
2357              const isMatch = o._targetEl === target;
2358              o.classList.toggle('impeccable-spotlight', isMatch);
2359              o.classList.toggle('impeccable-spotlight-dimmed', !isMatch);
2360              if (isMatch) {
2361                // Force the matching overlay visible immediately, don't wait for IntersectionObserver
2362                o.style.display = '';
2363                o.style.animation = 'none';
2364                o.classList.add('impeccable-visible');
2365                o._revealed = true;
2366                positionOverlay(o);
2367              }
2368            }
2369            showSpotlight(target);
2370          }
2371        } catch { /* invalid selector */ }
2372      }
2373      if (e.data.action === 'unhighlight') {
2374        hideSpotlight();
2375        for (const o of overlays) {
2376          o.classList.remove('impeccable-spotlight');
2377          o.classList.remove('impeccable-spotlight-dimmed');
2378        }
2379      }
2380    });
2381    window.postMessage({ source: 'impeccable-ready' }, '*');
2382  } else {
2383    if (document.readyState === 'loading') {
2384      document.addEventListener('DOMContentLoaded', () => setTimeout(scan, 100));
2385    } else {
2386      setTimeout(scan, 100);
2387    }
2388  }
2389
2390  window.impeccableScan = scan;
2391}
2392
2393// ─── Section 8: Node Engine ─────────────────────────────────────────────────
2394// @browser-strip-start
2395
2396function getAP(id) {
2397  return ANTIPATTERNS.find(a => a.id === id);
2398}
2399
2400function finding(id, filePath, snippet, line = 0) {
2401  const ap = getAP(id);
2402  return { antipattern: id, name: ap.name, description: ap.description, file: filePath, line, snippet };
2403}
2404
2405/** Check if content looks like a full page (not a component/partial) */
2406function isFullPage(content) {
2407  const stripped = content.replace(/<!--[\s\S]*?-->/g, '');
2408  return /<!doctype\s|<html[\s>]|<head[\s>]/i.test(stripped);
2409}
2410
2411// ---------------------------------------------------------------------------
2412// jsdom CSS-variable border override map
2413// ---------------------------------------------------------------------------
2414//
2415// jsdom's CSSOM silently drops any border shorthand that contains a var()
2416// reference — the computed style for the element then shows empty width,
2417// empty style, and a default black color. That's enough to hide the most
2418// common real-world side-tab pattern in AI-generated pages:
2419//
2420//   :root { --brand: #87a8ff; }
2421//   .card { border-left: 5px solid var(--brand); border-radius: 4px; }
2422//
2423// Real browsers (and therefore the browser detector path) resolve var()
2424// natively, so this only affects the Node jsdom path.
2425//
2426// This pre-pass walks the stylesheets, finds any rule whose per-side or
2427// all-sides border property contains var(), resolves the var() against
2428// :root-level custom properties (read from the documentElement's computed
2429// style, which jsdom DOES handle correctly), and attaches the resolved
2430// width+color to every element that matches the rule's selector. The
2431// Node-side `checkElementBorders` adapter consumes that map as a fallback
2432// whenever jsdom's computed style came back empty.
2433//
2434// Limitations (intentional, to keep the pass simple):
2435//   * Only :root-level custom properties are resolved. Scoped overrides on
2436//     descendants are not tracked — uncommon in practice and would require
2437//     a per-element cascade walk.
2438//   * @media / @supports wrapped rules are ignored (jsdom often mishandles
2439//     these anyway).
2440//   * The fallback only fills sides that jsdom left empty, so any rule
2441//     whose border parses normally still wins via the computed style.
2442
2443const BORDER_SHORTHAND_RE = /^(\d+(?:\.\d+)?)px\s+(solid|dashed|dotted|double|groove|ridge|inset|outset)\s+(.+)$/i;
2444
2445// isNeutralColor only understands rgba()/oklch()/lch()/lab()/hsl()/hwb().
2446// CSS variables typically hold hex or named colors, so normalize those to
2447// rgb() before handing the value off to the shared check. Anything we don't
2448// recognise is passed through unchanged — isNeutralColor then treats it as
2449// non-neutral, which is the safer default (matches the oklch-era bugfix).
2450const NAMED_COLORS = {
2451  white: [255, 255, 255], black: [0, 0, 0], gray: [128, 128, 128],
2452  grey: [128, 128, 128], silver: [192, 192, 192], red: [255, 0, 0],
2453  green: [0, 128, 0], blue: [0, 0, 255], yellow: [255, 255, 0],
2454};
2455
2456function normalizeColorForCheck(value) {
2457  if (!value) return value;
2458  const v = value.trim();
2459  const hex6 = v.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
2460  if (hex6) {
2461    const [r, g, b] = [parseInt(hex6[1], 16), parseInt(hex6[2], 16), parseInt(hex6[3], 16)];
2462    return `rgb(${r}, ${g}, ${b})`;
2463  }
2464  const hex3 = v.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/i);
2465  if (hex3) {
2466    const [r, g, b] = [
2467      parseInt(hex3[1] + hex3[1], 16),
2468      parseInt(hex3[2] + hex3[2], 16),
2469      parseInt(hex3[3] + hex3[3], 16),
2470    ];
2471    return `rgb(${r}, ${g}, ${b})`;
2472  }
2473  const named = NAMED_COLORS[v.toLowerCase()];
2474  if (named) return `rgb(${named[0]}, ${named[1]}, ${named[2]})`;
2475  return v;
2476}
2477
2478function buildBorderOverrideMap(document, window) {
2479  const map = new Map();
2480  const rootStyle = window.getComputedStyle(document.documentElement);
2481
2482  function resolveVar(value, depth = 0) {
2483    if (!value || depth > 10 || !value.includes('var(')) return value;
2484    return value.replace(
2485      /var\(\s*(--[\w-]+)\s*(?:,\s*([^)]+))?\s*\)/g,
2486      (_, name, fallback) => {
2487        const v = rootStyle.getPropertyValue(name).trim();
2488        if (v) return resolveVar(v, depth + 1);
2489        if (fallback) return resolveVar(fallback.trim(), depth + 1);
2490        return '';
2491      }
2492    );
2493  }
2494
2495  function parseShorthand(text) {
2496    const m = text.trim().match(BORDER_SHORTHAND_RE);
2497    if (!m) return null;
2498    return { width: parseFloat(m[1]), color: normalizeColorForCheck(m[3]) };
2499  }
2500
2501  // Read from the per-property accessors on rule.style. jsdom preserves
2502  // each border-* shorthand it parsed, even when the overall cssText has
2503  // been truncated (e.g. a `border: 1px solid var(...)` followed by a
2504  // `border-left: ...` loses the first declaration but keeps the second).
2505  const SIDE_PROPS = [
2506    ['borderLeft', 'Left'],
2507    ['borderRight', 'Right'],
2508    ['borderTop', 'Top'],
2509    ['borderBottom', 'Bottom'],
2510    ['borderInlineStart', 'Left'],
2511    ['borderInlineEnd', 'Right'],
2512  ];
2513
2514  for (const sheet of document.styleSheets) {
2515    let rules;
2516    try { rules = sheet.cssRules || []; } catch { continue; }
2517    for (const rule of rules) {
2518      // CSSStyleRule only; skip @media / @keyframes / @supports wrappers.
2519      if (rule.type !== 1 || !rule.style || !rule.selectorText) continue;
2520
2521      const perSide = {};
2522
2523      for (const [prop, side] of SIDE_PROPS) {
2524        const val = rule.style[prop];
2525        if (!val || !val.includes('var(')) continue;
2526        const parsed = parseShorthand(resolveVar(val));
2527        if (parsed && parsed.color) perSide[side] = parsed;
2528      }
2529
2530      // Uniform `border: <w> <style> var(...)` applies to every side the
2531      // per-side map didn't already claim.
2532      const borderAll = rule.style.border;
2533      if (borderAll && borderAll.includes('var(')) {
2534        const parsed = parseShorthand(resolveVar(borderAll));
2535        if (parsed && parsed.color) {
2536          for (const s of ['Top', 'Right', 'Bottom', 'Left']) {
2537            if (!perSide[s]) perSide[s] = parsed;
2538          }
2539        }
2540      }
2541
2542      // Longhand `border-*-color: var(...)` with width/style in separate
2543      // declarations. Rare in AI-generated pages, but cheap to cover.
2544      for (const [prop, side] of [
2545        ['borderLeftColor', 'Left'],
2546        ['borderRightColor', 'Right'],
2547        ['borderTopColor', 'Top'],
2548        ['borderBottomColor', 'Bottom'],
2549      ]) {
2550        const val = rule.style[prop];
2551        if (!val || !val.includes('var(')) continue;
2552        const resolved = resolveVar(val).trim();
2553        if (!resolved) continue;
2554        // Width may or may not come from this rule — that's fine; the
2555        // adapter only substitutes the color when jsdom left it as a
2556        // literal var() string.
2557        if (!perSide[side]) perSide[side] = { width: 0, color: normalizeColorForCheck(resolved) };
2558      }
2559
2560      if (Object.keys(perSide).length === 0) continue;
2561
2562      let matched;
2563      try { matched = document.querySelectorAll(rule.selectorText); }
2564      catch { continue; }
2565
2566      for (const el of matched) {
2567        const existing = map.get(el);
2568        if (existing) {
2569          // Later rules overwrite earlier ones — approximates source-order
2570          // cascade for equal-specificity rules and is good enough for the
2571          // uncontested var()-dropped sides we're trying to recover.
2572          Object.assign(existing, perSide);
2573        } else {
2574          map.set(el, { ...perSide });
2575        }
2576      }
2577    }
2578  }
2579
2580  return map;
2581}
2582
2583// ---------------------------------------------------------------------------
2584// jsdom detection (default for HTML files)
2585// ---------------------------------------------------------------------------
2586
2587async function detectHtml(filePath) {
2588  let JSDOM;
2589  try {
2590    ({ JSDOM } = await import('jsdom'));
2591  } catch {
2592    const content = fs.readFileSync(filePath, 'utf-8');
2593    return detectText(content, filePath);
2594  }
2595
2596  const html = fs.readFileSync(filePath, 'utf-8');
2597  const resolvedPath = path.resolve(filePath);
2598  const fileDir = path.dirname(resolvedPath);
2599
2600  // Inline linked local stylesheets so jsdom can see them
2601  let processedHtml = html;
2602  const linkRes = [
2603    /<link[^>]+rel=["']stylesheet["'][^>]*href=["']([^"']+)["'][^>]*>/gi,
2604    /<link[^>]+href=["']([^"']+)["'][^>]*rel=["']stylesheet["'][^>]*>/gi,
2605  ];
2606  for (const re of linkRes) {
2607    let m;
2608    while ((m = re.exec(html)) !== null) {
2609      const href = m[1];
2610      if (/^(https?:)?\/\//.test(href)) continue;
2611      const cssPath = path.resolve(fileDir, href);
2612      try {
2613        const css = fs.readFileSync(cssPath, 'utf-8');
2614        processedHtml = processedHtml.replace(m[0], `<style>/* ${href} */\n${css}\n</style>`);
2615      } catch { /* skip unreadable */ }
2616    }
2617  }
2618
2619  const dom = new JSDOM(processedHtml, {
2620    url: `file://${resolvedPath}`,
2621  });
2622  const { window } = dom;
2623  const { document } = window;
2624
2625  const findings = [];
2626
2627  // Pre-pass: recover border declarations that jsdom dropped because they
2628  // contained a var() reference. The map is keyed by element and consulted
2629  // by the border check adapter as a fallback.
2630  const borderOverrides = buildBorderOverrideMap(document, window);
2631
2632  // Element-level checks (borders + colors + motion)
2633  for (const el of document.querySelectorAll('*')) {
2634    const tag = el.tagName.toLowerCase();
2635    const style = window.getComputedStyle(el);
2636    for (const f of checkElementBorders(tag, style, borderOverrides.get(el))) {
2637      findings.push(finding(f.id, filePath, f.snippet));
2638    }
2639    for (const f of checkElementColors(el, style, tag, window)) {
2640      findings.push(finding(f.id, filePath, f.snippet));
2641    }
2642    for (const f of checkElementGlow(tag, style, resolveBackground(el.parentElement || el, window))) {
2643      findings.push(finding(f.id, filePath, f.snippet));
2644    }
2645    for (const f of checkElementMotion(tag, style)) {
2646      findings.push(finding(f.id, filePath, f.snippet));
2647    }
2648    for (const f of checkElementIconTile(el, tag, window)) {
2649      findings.push(finding(f.id, filePath, f.snippet));
2650    }
2651    for (const f of checkElementQuality(el, style, tag, window)) {
2652      findings.push(finding(f.id, filePath, f.snippet));
2653    }
2654  }
2655
2656  // Page-level checks (only for full pages, not partials)
2657  if (isFullPage(html)) {
2658    for (const f of checkPageTypography(document, window)) {
2659      findings.push(finding(f.id, filePath, f.snippet));
2660    }
2661    for (const f of checkPageLayout(document, window)) {
2662      findings.push(finding(f.id, filePath, f.snippet));
2663    }
2664    for (const f of checkPageQualityFromDoc(document)) {
2665      findings.push(finding(f.id, filePath, f.snippet));
2666    }
2667    for (const f of checkHtmlPatterns(html)) {
2668      findings.push(finding(f.id, filePath, f.snippet));
2669    }
2670  }
2671
2672  window.close();
2673  return findings;
2674}
2675
2676// ---------------------------------------------------------------------------
2677// Puppeteer detection (for URLs)
2678// ---------------------------------------------------------------------------
2679
2680async function detectUrl(url) {
2681  let puppeteer;
2682  try {
2683    puppeteer = await import('puppeteer');
2684  } catch {
2685    throw new Error('puppeteer is required for URL scanning. Install: npm install puppeteer');
2686  }
2687
2688  // Read the browser detection script — reuse it instead of reimplementing
2689  const browserScriptPath = path.resolve(
2690    path.dirname(new URL(import.meta.url).pathname),
2691    'detect-antipatterns-browser.js'
2692  );
2693  let browserScript;
2694  try {
2695    browserScript = fs.readFileSync(browserScriptPath, 'utf-8');
2696  } catch {
2697    throw new Error(`Browser script not found at ${browserScriptPath}`);
2698  }
2699
2700  // CI runners (GitHub Actions Ubuntu) block unprivileged user namespaces, so
2701  // Chrome can't initialize its sandbox there. Disable the sandbox only when
2702  // running in CI; local users keep the default hardened launch.
2703  const launchArgs = process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [];
2704  const browser = await puppeteer.default.launch({ headless: true, args: launchArgs });
2705  const page = await browser.newPage();
2706  await page.setViewport({ width: 1280, height: 800 });
2707  await page.goto(url, { waitUntil: 'networkidle0', timeout: 30000 });
2708
2709  // Inject the browser detection script and collect results
2710  await page.evaluate(browserScript);
2711  const results = await page.evaluate(() => {
2712    if (!window.impeccableScan) return [];
2713    const allFindings = window.impeccableScan();
2714    return allFindings.flatMap(({ findings }) =>
2715      findings.map(f => ({ id: f.type, snippet: f.detail }))
2716    );
2717  });
2718
2719  await browser.close();
2720  return results.map(f => finding(f.id, url, f.snippet));
2721}
2722
2723// ---------------------------------------------------------------------------
2724// Regex fallback (non-HTML files: CSS, JSX, TSX, etc.)
2725// ---------------------------------------------------------------------------
2726
2727const hasRounded = (line) => /\brounded(?:-\w+)?\b/.test(line);
2728const hasBorderRadius = (line) => /border-radius/i.test(line);
2729const isSafeElement = (line) => /<(?:blockquote|nav[\s>]|pre[\s>]|code[\s>]|a\s|input[\s>]|span[\s>])/i.test(line);
2730
2731function isNeutralBorderColor(str) {
2732  const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i);
2733  if (!m) return false;
2734  const c = m[1].toLowerCase();
2735  if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true;
2736  const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/);
2737  if (hex) {
2738    const [r, g, b] = [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)];
2739    return (Math.max(r, g, b) - Math.min(r, g, b)) < 30;
2740  }
2741  const shex = c.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/);
2742  if (shex) {
2743    const [r, g, b] = [parseInt(shex[1] + shex[1], 16), parseInt(shex[2] + shex[2], 16), parseInt(shex[3] + shex[3], 16)];
2744    return (Math.max(r, g, b) - Math.min(r, g, b)) < 30;
2745  }
2746  return false;
2747}
2748
2749const REGEX_MATCHERS = [
2750  // --- Side-tab ---
2751  { id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
2752    test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 1 : n >= 4; },
2753    fmt: (m) => m[0] },
2754  { id: 'side-tab', regex: /border-(?:left|right)\s*:\s*(\d+)px\s+solid[^;]*/gi,
2755    test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 1 : n >= 3; },
2756    fmt: (m) => m[0].replace(/\s*;?\s*$/, '') },
2757  { id: 'side-tab', regex: /border-(?:left|right)-width\s*:\s*(\d+)px/gi,
2758    test: (m, line) => !isSafeElement(line) && +m[1] >= 3,
2759    fmt: (m) => m[0] },
2760  { id: 'side-tab', regex: /border-inline-(?:start|end)\s*:\s*(\d+)px\s+solid/gi,
2761    test: (m, line) => !isSafeElement(line) && +m[1] >= 3,
2762    fmt: (m) => m[0] },
2763  { id: 'side-tab', regex: /border-inline-(?:start|end)-width\s*:\s*(\d+)px/gi,
2764    test: (m, line) => !isSafeElement(line) && +m[1] >= 3,
2765    fmt: (m) => m[0] },
2766  { id: 'side-tab', regex: /border(?:Left|Right)\s*[:=]\s*["'`](\d+)px\s+solid/g,
2767    test: (m) => +m[1] >= 3,
2768    fmt: (m) => m[0] },
2769  // --- Border accent on rounded ---
2770  { id: 'border-accent-on-rounded', regex: /\bborder-[tb]-(\d+)\b/g,
2771    test: (m, line) => hasRounded(line) && +m[1] >= 1,
2772    fmt: (m) => m[0] },
2773  { id: 'border-accent-on-rounded', regex: /border-(?:top|bottom)\s*:\s*(\d+)px\s+solid/gi,
2774    test: (m, line) => +m[1] >= 3 && hasBorderRadius(line),
2775    fmt: (m) => m[0] },
2776  // --- Overused font ---
2777  { id: 'overused-font', regex: /font-family\s*:\s*['"]?(Inter|Roboto|Open Sans|Lato|Montserrat|Arial|Helvetica)\b/gi,
2778    test: () => true,
2779    fmt: (m) => m[0] },
2780  { id: 'overused-font', regex: /fonts\.googleapis\.com\/css2?\?family=(Inter|Roboto|Open\+Sans|Lato|Montserrat)\b/gi,
2781    test: () => true,
2782    fmt: (m) => `Google Fonts: ${m[1].replace(/\+/g, ' ')}` },
2783  // --- Pure black background ---
2784  { id: 'pure-black-white', regex: /background(?:-color)?\s*:\s*(#000000|#000|rgb\(0,\s*0,\s*0\))\b/gi,
2785    test: () => true,
2786    fmt: (m) => m[0] },
2787  // --- Gradient text ---
2788  { id: 'gradient-text', regex: /background-clip\s*:\s*text|-webkit-background-clip\s*:\s*text/gi,
2789    test: (m, line) => /gradient/i.test(line),
2790    fmt: () => 'background-clip: text + gradient' },
2791  // --- Gradient text (Tailwind) ---
2792  { id: 'gradient-text', regex: /\bbg-clip-text\b/g,
2793    test: (m, line) => /\bbg-gradient-to-/i.test(line),
2794    fmt: () => 'bg-clip-text + bg-gradient' },
2795  // --- Tailwind pure black background ---
2796  { id: 'pure-black-white', regex: /\bbg-black\b/g,
2797    test: () => true,
2798    fmt: (m) => m[0] },
2799  // --- Tailwind gray on colored bg ---
2800  { id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
2801    test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
2802    fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
2803  // --- Tailwind AI palette ---
2804  { id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
2805    test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
2806    fmt: (m) => `${m[0]} on heading` },
2807  { id: 'ai-color-palette', regex: /\bfrom-(?:purple|violet|indigo)-(\d+)\b/g,
2808    test: (m, line) => /\bto-(?:purple|violet|indigo|blue|cyan|pink|fuchsia)-\d+\b/.test(line),
2809    fmt: (m) => `${m[0]} gradient` },
2810  // --- Bounce/elastic easing ---
2811  { id: 'bounce-easing', regex: /\banimate-bounce\b/g,
2812    test: () => true,
2813    fmt: () => 'animate-bounce (Tailwind)' },
2814  { id: 'bounce-easing', regex: /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi,
2815    test: () => true,
2816    fmt: (m) => m[0] },
2817  { id: 'bounce-easing', regex: /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g,
2818    test: (m) => {
2819      const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]);
2820      return y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1;
2821    },
2822    fmt: (m) => `cubic-bezier(${m[1]}, ${m[2]}, ${m[3]}, ${m[4]})` },
2823  // --- Layout property transition ---
2824  { id: 'layout-transition', regex: /transition\s*:\s*([^;{}]+)/gi,
2825    test: (m) => {
2826      const val = m[1].toLowerCase();
2827      if (/\ball\b/.test(val)) return false;
2828      return /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding\b|\bmargin\b/.test(val);
2829    },
2830    fmt: (m) => {
2831      const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
2832      return `transition: ${found ? found.join(', ') : m[1].trim()}`;
2833    } },
2834  { id: 'layout-transition', regex: /transition-property\s*:\s*([^;{}]+)/gi,
2835    test: (m) => {
2836      const val = m[1].toLowerCase();
2837      if (/\ball\b/.test(val)) return false;
2838      return /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding\b|\bmargin\b/.test(val);
2839    },
2840    fmt: (m) => {
2841      const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
2842      return `transition-property: ${found ? found.join(', ') : m[1].trim()}`;
2843    } },
2844];
2845
2846const REGEX_ANALYZERS = [
2847  // Single font
2848  (content, filePath) => {
2849    const fontFamilyRe = /font-family\s*:\s*([^;}]+)/gi;
2850    const fonts = new Set();
2851    let m;
2852    while ((m = fontFamilyRe.exec(content)) !== null) {
2853      for (const f of m[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) {
2854        if (f && !GENERIC_FONTS.has(f)) fonts.add(f);
2855      }
2856    }
2857    const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
2858    while ((m = gfRe.exec(content)) !== null) {
2859      for (const f of m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase())) fonts.add(f);
2860    }
2861    if (fonts.size !== 1 || content.split('\n').length < 20) return [];
2862    const name = [...fonts][0];
2863    const lines = content.split('\n');
2864    let line = 1;
2865    for (let i = 0; i < lines.length; i++) { if (lines[i].toLowerCase().includes(name)) { line = i + 1; break; } }
2866    return [finding('single-font', filePath, `only font used is ${name}`, line)];
2867  },
2868  // Flat type hierarchy
2869  (content, filePath) => {
2870    const sizes = new Set();
2871    const REM = 16;
2872    let m;
2873    const sizeRe = /font-size\s*:\s*([\d.]+)(px|rem|em)\b/gi;
2874    while ((m = sizeRe.exec(content)) !== null) {
2875      const px = m[2] === 'px' ? +m[1] : +m[1] * REM;
2876      if (px > 0 && px < 200) sizes.add(Math.round(px * 10) / 10);
2877    }
2878    const clampRe = /font-size\s*:\s*clamp\(\s*([\d.]+)(px|rem|em)\s*,\s*[^,]+,\s*([\d.]+)(px|rem|em)\s*\)/gi;
2879    while ((m = clampRe.exec(content)) !== null) {
2880      sizes.add(Math.round((m[2] === 'px' ? +m[1] : +m[1] * REM) * 10) / 10);
2881      sizes.add(Math.round((m[4] === 'px' ? +m[3] : +m[3] * REM) * 10) / 10);
2882    }
2883    const TW = { 'text-xs': 12, 'text-sm': 14, 'text-base': 16, 'text-lg': 18, 'text-xl': 20, 'text-2xl': 24, 'text-3xl': 30, 'text-4xl': 36, 'text-5xl': 48, 'text-6xl': 60, 'text-7xl': 72, 'text-8xl': 96, 'text-9xl': 128 };
2884    for (const [cls, px] of Object.entries(TW)) { if (new RegExp(`\\b${cls}\\b`).test(content)) sizes.add(px); }
2885    if (sizes.size < 3) return [];
2886    const sorted = [...sizes].sort((a, b) => a - b);
2887    const ratio = sorted[sorted.length - 1] / sorted[0];
2888    if (ratio >= 2.0) return [];
2889    const lines = content.split('\n');
2890    let line = 1;
2891    for (let i = 0; i < lines.length; i++) { if (/font-size/i.test(lines[i]) || /\btext-(?:xs|sm|base|lg|xl|\d)/i.test(lines[i])) { line = i + 1; break; } }
2892    return [finding('flat-type-hierarchy', filePath, `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)`, line)];
2893  },
2894  // Monotonous spacing (regex)
2895  (content, filePath) => {
2896    const vals = [];
2897    let m;
2898    const pxRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*(\d+)px/gi;
2899    while ((m = pxRe.exec(content)) !== null) { const v = +m[1]; if (v > 0 && v < 200) vals.push(v); }
2900    const remRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*([\d.]+)rem/gi;
2901    while ((m = remRe.exec(content)) !== null) { const v = Math.round(parseFloat(m[1]) * 16); if (v > 0 && v < 200) vals.push(v); }
2902    const gapRe = /gap\s*:\s*(\d+)px/gi;
2903    while ((m = gapRe.exec(content)) !== null) vals.push(+m[1]);
2904    const twRe = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+)\b/g;
2905    while ((m = twRe.exec(content)) !== null) vals.push(+m[1] * 4);
2906    const rounded = vals.map(v => Math.round(v / 4) * 4);
2907    if (rounded.length < 10) return [];
2908    const counts = {};
2909    for (const v of rounded) counts[v] = (counts[v] || 0) + 1;
2910    const maxCount = Math.max(...Object.values(counts));
2911    const pct = maxCount / rounded.length;
2912    const unique = [...new Set(rounded)].filter(v => v > 0);
2913    if (pct <= 0.6 || unique.length > 3) return [];
2914    const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
2915    return [finding('monotonous-spacing', filePath, `~${dominant}px used ${maxCount}/${rounded.length} times (${Math.round(pct * 100)}%)`)];
2916  },
2917  // Everything centered (regex)
2918  (content, filePath) => {
2919    const lines = content.split('\n');
2920    let centered = 0, total = 0;
2921    for (const line of lines) {
2922      if (/<(?:h[1-6]|p|div|li|button)\b[^>]*>/i.test(line) && line.trim().length > 20) {
2923        total++;
2924        if (/text-align\s*:\s*center/i.test(line) || /\btext-center\b/.test(line)) centered++;
2925      }
2926    }
2927    if (total < 5 || centered / total <= 0.7) return [];
2928    return [finding('everything-centered', filePath, `${centered}/${total} text elements centered (${Math.round(centered / total * 100)}%)`)];
2929  },
2930  // Dark glow (page-level: dark bg + colored box-shadow with blur)
2931  (content, filePath) => {
2932    // Check if page has a dark background
2933    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;
2934    const twDarkBg = /\bbg-(?:gray|slate|zinc|neutral|stone)-(?:9\d{2}|800)\b/;
2935    const hasDarkBg = darkBgRe.test(content) || twDarkBg.test(content);
2936    if (!hasDarkBg) return [];
2937
2938    // Check for colored box-shadow with blur > 4px
2939    const shadowRe = /box-shadow\s*:\s*([^;{}]+)/gi;
2940    let m;
2941    while ((m = shadowRe.exec(content)) !== null) {
2942      const val = m[1];
2943      const colorMatch = val.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
2944      if (!colorMatch) continue;
2945      const [r, g, b] = [+colorMatch[1], +colorMatch[2], +colorMatch[3]];
2946      if ((Math.max(r, g, b) - Math.min(r, g, b)) < 30) continue; // skip gray
2947      // Check blur: look for pattern like "0 0 20px" (third number > 4)
2948      const pxVals = [...val.matchAll(/(\d+)px|(?<![.\d])\b(0)\b(?![.\d])/g)].map(p => +(p[1] || p[2]));
2949      if (pxVals.length >= 3 && pxVals[2] > 4) {
2950        const lines = content.substring(0, m.index).split('\n');
2951        return [finding('dark-glow', filePath, `Colored glow (rgb(${r},${g},${b})) on dark page`, lines.length)];
2952      }
2953    }
2954    return [];
2955  },
2956];
2957
2958// ---------------------------------------------------------------------------
2959// Style block extraction (Vue/Svelte <style> blocks)
2960// ---------------------------------------------------------------------------
2961
2962function extractStyleBlocks(content, ext) {
2963  ext = ext.toLowerCase();
2964  if (ext !== '.vue' && ext !== '.svelte') return [];
2965  const blocks = [];
2966  const re = /<style[^>]*>([\s\S]*?)<\/style>/gi;
2967  let m;
2968  while ((m = re.exec(content)) !== null) {
2969    const before = content.substring(0, m.index);
2970    const startLine = before.split('\n').length + 1;
2971    blocks.push({ content: m[1], startLine });
2972  }
2973  return blocks;
2974}
2975
2976// ---------------------------------------------------------------------------
2977// CSS-in-JS extraction (styled-components, emotion)
2978// ---------------------------------------------------------------------------
2979
2980const CSS_IN_JS_EXTENSIONS = new Set(['.js', '.ts', '.jsx', '.tsx']);
2981
2982function extractCSSinJS(content, ext) {
2983  ext = ext.toLowerCase();
2984  if (!CSS_IN_JS_EXTENSIONS.has(ext)) return [];
2985  const blocks = [];
2986  const re = /(?:styled(?:\.\w+|\([^)]+\))|css)\s*`([\s\S]*?)`/g;
2987  let m;
2988  while ((m = re.exec(content)) !== null) {
2989    const before = content.substring(0, m.index);
2990    const startLine = before.split('\n').length;
2991    blocks.push({ content: m[1], startLine });
2992  }
2993  return blocks;
2994}
2995
2996function runRegexMatchers(lines, filePath, lineOffset = 0, blockContext = null) {
2997  const findings = [];
2998  for (const matcher of REGEX_MATCHERS) {
2999    for (let i = 0; i < lines.length; i++) {
3000      const line = lines[i];
3001      matcher.regex.lastIndex = 0;
3002      let m;
3003      while ((m = matcher.regex.exec(line)) !== null) {
3004        // For extracted blocks, use nearby lines as context for multi-line CSS patterns
3005        const context = blockContext
3006          ? lines.slice(Math.max(0, i - 3), Math.min(lines.length, i + 4)).join(' ')
3007          : line;
3008        if (matcher.test(m, context)) {
3009          findings.push(finding(matcher.id, filePath, matcher.fmt(m, context), i + 1 + lineOffset));
3010        }
3011      }
3012    }
3013  }
3014  return findings;
3015}
3016
3017function detectText(content, filePath) {
3018  const findings = [];
3019  const lines = content.split('\n');
3020  const ext = filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : '';
3021
3022  // Run regex matchers on the full file content (catches Tailwind classes, inline styles)
3023  // Enable block context for CSS files where related properties span multiple lines
3024  const cssLike = new Set(['.css', '.scss', '.less']);
3025  findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null));
3026
3027  // Extract and scan <style> blocks from Vue/Svelte SFCs
3028  const styleBlocks = extractStyleBlocks(content, ext);
3029  for (const block of styleBlocks) {
3030    const blockLines = block.content.split('\n');
3031    findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true));
3032  }
3033
3034  // Extract and scan CSS-in-JS template literals
3035  const cssJsBlocks = extractCSSinJS(content, ext);
3036  for (const block of cssJsBlocks) {
3037    const blockLines = block.content.split('\n');
3038    findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true));
3039  }
3040
3041  // Deduplicate findings (same antipattern + similar snippet, within 2 lines)
3042  const deduped = [];
3043  for (const f of findings) {
3044    const isDupe = deduped.some(d =>
3045      d.antipattern === f.antipattern &&
3046      d.snippet === f.snippet &&
3047      Math.abs(d.line - f.line) <= 2
3048    );
3049    if (!isDupe) deduped.push(f);
3050  }
3051
3052  // Page-level analyzers only run on full pages
3053  if (isFullPage(content)) {
3054    for (const analyzer of REGEX_ANALYZERS) {
3055      deduped.push(...analyzer(content, filePath));
3056    }
3057  }
3058
3059  return deduped;
3060}
3061
3062// ---------------------------------------------------------------------------
3063// File walker
3064// ---------------------------------------------------------------------------
3065
3066const SKIP_DIRS = new Set([
3067  'node_modules', '.git', 'dist', 'build', '.next', '.nuxt', '.output',
3068  '.svelte-kit', '__pycache__', '.turbo', '.vercel',
3069]);
3070
3071const SCANNABLE_EXTENSIONS = new Set([
3072  '.html', '.htm', '.css', '.scss', '.less',
3073  '.jsx', '.tsx', '.js', '.ts',
3074  '.vue', '.svelte', '.astro',
3075]);
3076
3077const HTML_EXTENSIONS = new Set(['.html', '.htm']);
3078
3079function walkDir(dir) {
3080  const files = [];
3081  let entries;
3082  try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
3083  for (const entry of entries) {
3084    if (SKIP_DIRS.has(entry.name)) continue;
3085    const full = path.join(dir, entry.name);
3086    if (entry.isDirectory()) files.push(...walkDir(full));
3087    else if (SCANNABLE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) files.push(full);
3088  }
3089  return files;
3090}
3091
3092// ---------------------------------------------------------------------------
3093// Output formatting
3094// ---------------------------------------------------------------------------
3095
3096function formatFindings(findings, jsonMode) {
3097  if (jsonMode) return JSON.stringify(findings, null, 2);
3098
3099  const grouped = {};
3100  for (const f of findings) {
3101    if (!grouped[f.file]) grouped[f.file] = [];
3102    grouped[f.file].push(f);
3103  }
3104  const out = [];
3105  for (const [file, items] of Object.entries(grouped)) {
3106    const importNote = items[0]?.importedBy?.length ? ` (imported by ${items[0].importedBy.join(', ')})` : '';
3107    out.push(`\n${file}${importNote}`);
3108    for (const item of items) {
3109      out.push(`  ${item.line ? `line ${item.line}: ` : ''}[${item.antipattern}] ${item.snippet}`);
3110      out.push(`${item.description}`);
3111    }
3112  }
3113  out.push(`\n${findings.length} anti-pattern${findings.length === 1 ? '' : 's'} found.`);
3114  return out.join('\n');
3115}
3116
3117// ---------------------------------------------------------------------------
3118// Stdin handling
3119// ---------------------------------------------------------------------------
3120
3121async function handleStdin() {
3122  const chunks = [];
3123  for await (const chunk of process.stdin) chunks.push(chunk);
3124  const input = Buffer.concat(chunks).toString('utf-8');
3125  try {
3126    const parsed = JSON.parse(input);
3127    const fp = parsed?.tool_input?.file_path;
3128    if (fp && fs.existsSync(fp)) {
3129      return HTML_EXTENSIONS.has(path.extname(fp).toLowerCase())
3130        ? detectHtml(fp) : detectText(fs.readFileSync(fp, 'utf-8'), fp);
3131    }
3132  } catch { /* not JSON */ }
3133  return detectText(input, '<stdin>');
3134}
3135
3136// ---------------------------------------------------------------------------
3137// Import graph (multi-file awareness)
3138// ---------------------------------------------------------------------------
3139
3140function resolveImport(specifier, fromDir, fileSet) {
3141  if (!/^[./]/.test(specifier)) return null; // skip bare specifiers
3142  const base = path.resolve(fromDir, specifier);
3143  if (fileSet.has(base)) return base;
3144  for (const ext of SCANNABLE_EXTENSIONS) {
3145    const withExt = base + ext;
3146    if (fileSet.has(withExt)) return withExt;
3147  }
3148  // index file convention
3149  for (const ext of SCANNABLE_EXTENSIONS) {
3150    const indexFile = path.join(base, 'index' + ext);
3151    if (fileSet.has(indexFile)) return indexFile;
3152  }
3153  return null;
3154}
3155
3156function buildImportGraph(files) {
3157  const fileSet = new Set(files);
3158  const graph = new Map();
3159
3160  for (const file of files) {
3161    const content = fs.readFileSync(file, 'utf-8');
3162    const dir = path.dirname(file);
3163    const imports = new Set();
3164
3165    // ES imports: import ... from '...' and import '...'
3166    const esRe = /import\s+(?:[\s\S]*?from\s+)?['"]([^'"]+)['"]/g;
3167    let m;
3168    while ((m = esRe.exec(content)) !== null) {
3169      const resolved = resolveImport(m[1], dir, fileSet);
3170      if (resolved) imports.add(resolved);
3171    }
3172
3173    // CSS @import
3174    const cssRe = /@import\s+(?:url\(\s*)?['"]?([^'");\s]+)['"]?\s*\)?/g;
3175    while ((m = cssRe.exec(content)) !== null) {
3176      const resolved = resolveImport(m[1], dir, fileSet);
3177      if (resolved) imports.add(resolved);
3178    }
3179
3180    // SCSS @use / @forward
3181    const scssRe = /@(?:use|forward)\s+['"]([^'"]+)['"]/g;
3182    while ((m = scssRe.exec(content)) !== null) {
3183      const resolved = resolveImport(m[1], dir, fileSet);
3184      if (resolved) imports.add(resolved);
3185    }
3186
3187    graph.set(file, imports);
3188  }
3189  return graph;
3190}
3191
3192// ---------------------------------------------------------------------------
3193// Framework dev server detection
3194// ---------------------------------------------------------------------------
3195
3196const FRAMEWORK_CONFIGS = [
3197  { name: 'Next.js', files: ['next.config.js', 'next.config.mjs', 'next.config.ts'], defaultPort: 3000,
3198    portRe: /port\s*[:=]\s*(\d+)/,
3199    fingerprint: { header: 'x-powered-by', value: /next/i } },
3200  { name: 'SvelteKit', files: ['svelte.config.js', 'svelte.config.ts'], defaultPort: 5173,
3201    portRe: /port\s*[:=]\s*(\d+)/,
3202    fingerprint: { header: 'x-sveltekit-page', value: null } },
3203  { name: 'Nuxt', files: ['nuxt.config.js', 'nuxt.config.ts'], defaultPort: 3000,
3204    portRe: /port\s*[:=]\s*(\d+)/,
3205    fingerprint: { header: 'x-powered-by', value: /nuxt/i } },
3206  { name: 'Vite', files: ['vite.config.js', 'vite.config.ts', 'vite.config.mjs'], defaultPort: 5173,
3207    portRe: /port\s*[:=]\s*(\d+)/,
3208    fingerprint: { body: /@vite\/client/ } },
3209  { name: 'Astro', files: ['astro.config.js', 'astro.config.ts', 'astro.config.mjs'], defaultPort: 4321,
3210    portRe: /port\s*[:=]\s*(\d+)/,
3211    fingerprint: { body: /astro/i } },
3212  { name: 'Angular', files: ['angular.json'], defaultPort: 4200,
3213    portRe: /"port"\s*:\s*(\d+)/,
3214    fingerprint: { body: /ng-version/i } },
3215  { name: 'Remix', files: ['remix.config.js', 'remix.config.ts'], defaultPort: 3000,
3216    portRe: /port\s*[:=]\s*(\d+)/,
3217    fingerprint: { header: 'x-powered-by', value: /remix/i } },
3218];
3219
3220function detectFrameworkConfig(dir) {
3221  let entries;
3222  try { entries = fs.readdirSync(dir); } catch { return null; }
3223  const entrySet = new Set(entries);
3224
3225  for (const cfg of FRAMEWORK_CONFIGS) {
3226    const match = cfg.files.find(f => entrySet.has(f));
3227    if (!match) continue;
3228
3229    const configPath = path.join(dir, match);
3230    let port = cfg.defaultPort;
3231    try {
3232      const content = fs.readFileSync(configPath, 'utf-8');
3233      const portMatch = content.match(cfg.portRe);
3234      if (portMatch) port = parseInt(portMatch[1], 10);
3235    } catch { /* use default */ }
3236
3237    return { name: cfg.name, port, configPath, fingerprint: cfg.fingerprint };
3238  }
3239  return null;
3240}
3241
3242/**
3243 * Check if a port is listening and optionally verify it matches the expected framework.
3244 * Returns { listening: true, matched: true/false } or { listening: false }.
3245 */
3246async function isPortListening(port, fingerprint = null) {
3247  if (!fingerprint) {
3248    // Simple TCP probe fallback
3249    const net = await import('node:net');
3250    return new Promise((resolve) => {
3251      const sock = net.default.createConnection({ port, host: '127.0.0.1' });
3252      sock.setTimeout(500);
3253      sock.on('connect', () => { sock.destroy(); resolve({ listening: true, matched: true }); });
3254      sock.on('error', () => resolve({ listening: false }));
3255      sock.on('timeout', () => { sock.destroy(); resolve({ listening: false }); });
3256    });
3257  }
3258
3259  // HTTP probe with fingerprint matching
3260  try {
3261    const controller = new AbortController();
3262    const timeout = setTimeout(() => controller.abort(), 2000);
3263    const res = await fetch(`http://localhost:${port}/`, { signal: controller.signal, redirect: 'follow' });
3264    clearTimeout(timeout);
3265
3266    // Check header fingerprint
3267    if (fingerprint.header) {
3268      const val = res.headers.get(fingerprint.header);
3269      if (val && (!fingerprint.value || fingerprint.value.test(val))) {
3270        return { listening: true, matched: true };
3271      }
3272    }
3273
3274    // Check body fingerprint
3275    if (fingerprint.body) {
3276      const body = await res.text();
3277      if (fingerprint.body.test(body)) {
3278        return { listening: true, matched: true };
3279      }
3280    }
3281
3282    // Port is listening but doesn't match the expected framework
3283    return { listening: true, matched: false };
3284  } catch {
3285    return { listening: false };
3286  }
3287}
3288
3289// ---------------------------------------------------------------------------
3290// CLI
3291// ---------------------------------------------------------------------------
3292
3293async function confirm(question) {
3294  const rl = (await import('node:readline')).default.createInterface({
3295    input: process.stdin, output: process.stderr,
3296  });
3297  return new Promise((resolve) => {
3298    rl.question(`${question} [Y/n] `, (answer) => {
3299      rl.close();
3300      resolve(!answer || /^y(es)?$/i.test(answer.trim()));
3301    });
3302  });
3303}
3304
3305function printUsage() {
3306  console.log(`Usage: impeccable detect [options] [file-or-dir-or-url...]
3307
3308Scan files or URLs for UI anti-patterns and design quality issues.
3309
3310Options:
3311  --fast    Regex-only mode (skip jsdom, faster but misses linked stylesheets)
3312  --json    Output results as JSON
3313  --help    Show this help message
3314
3315Detection modes:
3316  HTML files     jsdom with computed styles (default, catches linked CSS)
3317  Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.)
3318  URLs           Puppeteer full browser rendering (auto-detected)
3319  --fast         Forces regex for all files
3320
3321Examples:
3322  impeccable detect src/
3323  impeccable detect index.html
3324  impeccable detect https://example.com
3325  impeccable detect --fast --json .`);
3326}
3327
3328async function main() {
3329  const args = process.argv.slice(2);
3330  const jsonMode = args.includes('--json');
3331  const helpMode = args.includes('--help');
3332  const fastMode = args.includes('--fast');
3333  const targets = args.filter(a => !a.startsWith('--'));
3334
3335  if (helpMode) { printUsage(); process.exit(0); }
3336
3337  let allFindings = [];
3338
3339  if (!process.stdin.isTTY && targets.length === 0) {
3340    allFindings = await handleStdin();
3341  } else {
3342    const paths = targets.length > 0 ? targets : [process.cwd()];
3343
3344    for (const target of paths) {
3345      if (/^https?:\/\//i.test(target)) {
3346        try { allFindings.push(...await detectUrl(target)); }
3347        catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
3348        continue;
3349      }
3350
3351      const resolved = path.resolve(target);
3352      let stat;
3353      try { stat = fs.statSync(resolved); }
3354      catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
3355
3356      if (stat.isDirectory()) {
3357        // Check for framework dev server config (skip in JSON mode to avoid polluting output)
3358        if (!jsonMode) {
3359          const fwConfig = detectFrameworkConfig(resolved);
3360          if (fwConfig) {
3361            const probe = await isPortListening(fwConfig.port, fwConfig.fingerprint);
3362            if (probe.listening && probe.matched) {
3363              process.stderr.write(
3364                `\n${fwConfig.name} dev server detected on localhost:${fwConfig.port}.\n` +
3365                `For more accurate results, scan the running site:\n` +
3366                `  npx impeccable detect http://localhost:${fwConfig.port}\n\n`
3367              );
3368            } else if (probe.listening && !probe.matched) {
3369              process.stderr.write(
3370                `\n${fwConfig.name} project detected (${path.basename(fwConfig.configPath)}).\n` +
3371                `Port ${fwConfig.port} is in use by another service. Start the ${fwConfig.name} dev server and scan via URL for best results.\n\n`
3372              );
3373            } else {
3374              process.stderr.write(
3375                `\n${fwConfig.name} project detected (${path.basename(fwConfig.configPath)}).\n` +
3376                `Start the dev server and scan via URL for best results:\n` +
3377                `  npx impeccable detect http://localhost:${fwConfig.port}\n\n`
3378              );
3379            }
3380          }
3381        }
3382
3383        const files = walkDir(resolved);
3384        const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
3385
3386        // Warn and confirm if scanning many files (jsdom is slow per HTML file)
3387        if (files.length > 50 && process.stdin.isTTY && !jsonMode) {
3388          process.stderr.write(
3389            `\nFound ${files.length} files (${htmlCount} HTML) in ${target}.\n` +
3390            `Scanning may take a while${htmlCount > 10 ? ' (jsdom processes each HTML file individually)' : ''}.\n` +
3391            `Use --fast to skip jsdom, or target a specific subdirectory.\n`
3392          );
3393          const ok = await confirm('Continue?');
3394          if (!ok) { process.stderr.write('Aborted.\n'); process.exit(0); }
3395        }
3396
3397        // Build import graph for multi-file awareness
3398        const graph = buildImportGraph(files);
3399        // Build reverse map: file -> set of files that import it
3400        const importedByMap = new Map();
3401        for (const [importer, imports] of graph) {
3402          for (const imported of imports) {
3403            if (!importedByMap.has(imported)) importedByMap.set(imported, new Set());
3404            importedByMap.get(imported).add(importer);
3405          }
3406        }
3407
3408        for (const file of files) {
3409          const ext = path.extname(file).toLowerCase();
3410          let fileFindings;
3411          if (!fastMode && HTML_EXTENSIONS.has(ext)) {
3412            fileFindings = await detectHtml(file);
3413          } else {
3414            fileFindings = detectText(fs.readFileSync(file, 'utf-8'), file);
3415          }
3416          // Annotate findings with import context
3417          const importers = importedByMap.get(file);
3418          if (importers && importers.size > 0) {
3419            const importerNames = [...importers].map(f => path.basename(f));
3420            for (const f of fileFindings) {
3421              f.importedBy = importerNames;
3422            }
3423          }
3424          allFindings.push(...fileFindings);
3425        }
3426      } else if (stat.isFile()) {
3427        const ext = path.extname(resolved).toLowerCase();
3428        if (!fastMode && HTML_EXTENSIONS.has(ext)) {
3429          allFindings.push(...await detectHtml(resolved));
3430        } else {
3431          allFindings.push(...detectText(fs.readFileSync(resolved, 'utf-8'), resolved));
3432        }
3433      }
3434    }
3435  }
3436
3437  if (allFindings.length > 0) {
3438    process.stderr.write(formatFindings(allFindings, jsonMode) + '\n');
3439    process.exit(2);
3440  }
3441  if (jsonMode) process.stdout.write('[]\n');
3442  process.exit(0);
3443}
3444
3445// ---------------------------------------------------------------------------
3446// Live detection server
3447// ---------------------------------------------------------------------------
3448
3449async function findOpenPort(start = 8400) {
3450  const net = await import('node:net');
3451  return new Promise((resolve) => {
3452    const server = net.default.createServer();
3453    server.listen(start, '127.0.0.1', () => {
3454      const port = server.address().port;
3455      server.close(() => resolve(port));
3456    });
3457    server.on('error', () => resolve(findOpenPort(start + 1)));
3458  });
3459}
3460
3461const LIVE_PID_FILE = path.join((await import('node:os')).default.tmpdir(), 'impeccable-live.json');
3462
3463async function liveCli() {
3464  const args = process.argv.slice(2);
3465  const helpMode = args.includes('--help');
3466  const stopMode = args.includes('stop');
3467  const portArg = args.find(a => a.startsWith('--port='));
3468  const requestedPort = portArg ? parseInt(portArg.split('=')[1], 10) : null;
3469
3470  if (helpMode) {
3471    console.log(`Usage: impeccable live [options]
3472
3473Start a local server that serves the browser detection overlay script.
3474Inject the script into any page to scan for anti-patterns in real time.
3475
3476Commands:
3477  live          Start the server (default)
3478  live stop     Stop a running live server
3479
3480Options:
3481  --port=PORT   Use a specific port (default: auto-detect unused port)
3482  --help        Show this help message
3483
3484The server provides:
3485  /detect.js    The detection overlay script (inject via <script> tag)
3486  /health       Health check endpoint
3487  /stop         Stop the server remotely`);
3488    process.exit(0);
3489  }
3490
3491  // Stop a running server
3492  if (stopMode) {
3493    try {
3494      const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
3495      const res = await fetch(`http://localhost:${info.port}/stop`);
3496      if (res.ok) {
3497        console.log(`Stopped live server on port ${info.port}.`);
3498      }
3499    } catch {
3500      console.log('No running live server found.');
3501    }
3502    process.exit(0);
3503  }
3504
3505  const http = await import('node:http');
3506  const scriptPath = path.join(path.dirname(new URL(import.meta.url).pathname), 'detect-antipatterns-browser.js');
3507
3508  let browserScript;
3509  try {
3510    browserScript = fs.readFileSync(scriptPath, 'utf-8');
3511  } catch {
3512    process.stderr.write('Error: Browser script not found. Run `npm run build:browser` first.\n');
3513    process.exit(1);
3514  }
3515
3516  const port = requestedPort || await findOpenPort();
3517
3518  const shutdown = () => {
3519    try { fs.unlinkSync(LIVE_PID_FILE); } catch { /* ignore */ }
3520    server.close();
3521    process.exit(0);
3522  };
3523
3524  const server = http.default.createServer((req, res) => {
3525    // CORS headers for cross-origin injection
3526    res.setHeader('Access-Control-Allow-Origin', '*');
3527    res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
3528    if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
3529
3530    if (req.url === '/detect.js' || req.url === '/') {
3531      res.writeHead(200, { 'Content-Type': 'application/javascript' });
3532      res.end(browserScript);
3533    } else if (req.url === '/health') {
3534      res.writeHead(200, { 'Content-Type': 'application/json' });
3535      res.end(JSON.stringify({ status: 'ok', port }));
3536    } else if (req.url === '/stop') {
3537      res.writeHead(200, { 'Content-Type': 'text/plain' });
3538      res.end('stopping');
3539      shutdown();
3540    } else {
3541      res.writeHead(404);
3542      res.end('Not found');
3543    }
3544  });
3545
3546  server.listen(port, '127.0.0.1', () => {
3547    // Write PID file so `live stop` can find us
3548    fs.writeFileSync(LIVE_PID_FILE, JSON.stringify({ pid: process.pid, port }));
3549
3550    const url = `http://localhost:${port}`;
3551    console.log(`Impeccable live detection server running on ${url}\n`);
3552    console.log(`Inject into any page:`);
3553    console.log(`  const s = document.createElement('script');`);
3554    console.log(`  s.src = '${url}/detect.js';`);
3555    console.log(`  document.head.appendChild(s);\n`);
3556    console.log(`Stop: npx impeccable live stop`);
3557  });
3558
3559  process.on('SIGINT', shutdown);
3560  process.on('SIGTERM', shutdown);
3561}
3562
3563// ---------------------------------------------------------------------------
3564// Entry point
3565// ---------------------------------------------------------------------------
3566
3567if (!IS_BROWSER) {
3568  const isMainModule = process.argv[1]?.endsWith('detect-antipatterns.mjs') ||
3569    process.argv[1]?.endsWith('detect-antipatterns.mjs/');
3570  if (isMainModule) main();
3571}
3572
3573// @browser-strip-end
3574
3575// ─── Section 9: Exports ─────────────────────────────────────────────────────
3576// @browser-strip-start
3577
3578export {
3579  ANTIPATTERNS, SAFE_TAGS, OVERUSED_FONTS, GENERIC_FONTS,
3580  checkElementBorders, checkElementMotion, checkElementGlow, checkPageTypography, checkPageLayout, isNeutralColor, isFullPage,
3581  detectHtml, detectUrl, detectText,
3582  walkDir, formatFindings, SCANNABLE_EXTENSIONS, SKIP_DIRS,
3583  extractStyleBlocks, extractCSSinJS,
3584  buildImportGraph, resolveImport,
3585  detectFrameworkConfig, isPortListening, FRAMEWORK_CONFIGS,
3586  main as detectCli,
3587  liveCli,
3588};
3589
3590// @browser-strip-end