checks.mjs

   1import {
   2  BORDER_SAFE_TAGS,
   3  GENERIC_FONTS,
   4  KNOWN_SERIF_FONTS,
   5  OVERUSED_FONTS,
   6  SAFE_TAGS,
   7  WCAG_LARGE_BOLD_TEXT_PX,
   8  WCAG_LARGE_TEXT_PX,
   9  isBrandFontOnOwnDomain,
  10} from '../shared/constants.mjs';
  11import {
  12  colorToHex,
  13  contrastRatio,
  14  getHue,
  15  hasChroma,
  16  isNeutralColor,
  17  parseGradientColors,
  18  parseRgb,
  19  relativeLuminance,
  20} from '../shared/color.mjs';
  21
  22const DETECTOR_IS_BROWSER = typeof window !== 'undefined';
  23
  24// ─── Section 3: Pure Detection ──────────────────────────────────────────────
  25
  26function checkBorders(tag, widths, colors, radius) {
  27  if (BORDER_SAFE_TAGS.has(tag)) return [];
  28  const findings = [];
  29  const sides = ['Top', 'Right', 'Bottom', 'Left'];
  30
  31  for (const side of sides) {
  32    const w = widths[side];
  33    if (w < 1 || isNeutralColor(colors[side])) continue;
  34
  35    const otherSides = sides.filter(s => s !== side);
  36    const maxOther = Math.max(...otherSides.map(s => widths[s]));
  37    if (!(w >= 2 && (maxOther <= 1 || w >= maxOther * 2))) continue;
  38
  39    const sn = side.toLowerCase();
  40    const isSide = side === 'Left' || side === 'Right';
  41
  42    if (isSide) {
  43      if (radius > 0) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` });
  44      else if (w >= 3) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px` });
  45    } else {
  46      if (radius > 0 && w >= 2) findings.push({ id: 'border-accent-on-rounded', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` });
  47    }
  48  }
  49
  50  return findings;
  51}
  52
  53// Returns true if the given text is composed entirely of emoji characters
  54// (plus whitespace / variation selectors). Emojis render as multicolor glyphs
  55// regardless of CSS `color`, so contrast checks against the element's text
  56// color are meaningless for these nodes.
  57const 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;
  58const 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;
  59function isEmojiOnlyText(text) {
  60  if (!text) return false;
  61  if (!EMOJI_CHAR_RE.test(text)) return false;
  62  return text.replace(EMOJI_CHARS_GLOBAL, '').trim() === '';
  63}
  64
  65function checkColors(opts) {
  66  const { tag, textColor, bgColor, effectiveBg, effectiveBgStops, fontSize, fontWeight, hasDirectText, isEmojiOnly, bgClip, bgImage, classList } = opts;
  67  if (SAFE_TAGS.has(tag)) {
  68    // Exception for <a> and <button> elements styled as buttons. SAFE_TAGS
  69    // exists to suppress contrast noise on inline links and unstyled controls,
  70    // where the element has no own background and the contrast against the
  71    // ancestor surface is already the intended visual. When the element has
  72    // its own opaque background and direct text, it is a styled button — and
  73    // contrast on its own surface is a real, frequent bug worth flagging.
  74    const isStyledButton = (tag === 'a' || tag === 'button')
  75      && hasDirectText
  76      && bgColor && bgColor.a > 0.5;
  77    if (!isStyledButton) return [];
  78  }
  79  const findings = [];
  80
  81  // Pure black background (only solid or near-solid, not semi-transparent overlays)
  82  if (bgColor && bgColor.a >= 0.9 && bgColor.r === 0 && bgColor.g === 0 && bgColor.b === 0) {
  83    findings.push({ id: 'pure-black-white', snippet: '#000000 background' });
  84  }
  85
  86  if (hasDirectText && textColor && !isEmojiOnly) {
  87    // Run background-dependent checks against either a solid bg or, if the
  88    // ancestor is a gradient, against every gradient stop (use the worst case).
  89    const bgs = effectiveBg ? [effectiveBg] : (effectiveBgStops && effectiveBgStops.length ? effectiveBgStops : null);
  90    if (bgs) {
  91      // Gray on colored background — flag if every stop is chromatic
  92      const textLum = relativeLuminance(textColor);
  93      const isGray = !hasChroma(textColor, 20) && textLum > 0.05 && textLum < 0.85;
  94      if (isGray && bgs.every(b => hasChroma(b, 40))) {
  95        const bgLabel = effectiveBg ? colorToHex(effectiveBg) : `gradient(${bgs.map(colorToHex).join(', ')})`;
  96        findings.push({ id: 'gray-on-color', snippet: `text ${colorToHex(textColor)} on bg ${bgLabel}` });
  97      }
  98
  99      // Low contrast (WCAG AA) — worst case across all bg stops
 100      const ratios = bgs.map(b => contrastRatio(textColor, b));
 101      let worstIdx = 0;
 102      for (let i = 1; i < ratios.length; i++) if (ratios[i] < ratios[worstIdx]) worstIdx = i;
 103      const ratio = ratios[worstIdx];
 104      const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700);
 105      const threshold = isLargeText ? 3.0 : 4.5;
 106      if (ratio < threshold) {
 107        // Skip the false-positive class where text has alpha < 1 AND we
 108        // couldn't find an opaque ancestor (effectiveBg is null, we're
 109        // comparing against gradient-stop fallback). In jsdom mode the
 110        // detector can't resolve `var(--X)` color tokens, so a dark
 111        // section sitting between the text and the body's decorative
 112        // gradient is invisible to us — we end up measuring contrast
 113        // against the body's paper-grain noise instead of the real
 114        // local bg. Real low-contrast bugs use alpha=1 and have a
 115        // resolvable opaque ancestor; semi-transparent Tailwind tokens
 116        // like `text-paper/60` on `bg-ink` sections are the FP pattern.
 117        const isAlphaFallbackFP = !DETECTOR_IS_BROWSER && !effectiveBg && (textColor.a != null && textColor.a < 1);
 118        if (!isAlphaFallbackFP) {
 119          findings.push({ id: 'low-contrast', snippet: `${ratio.toFixed(1)}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(bgs[worstIdx])}` });
 120        }
 121      }
 122    }
 123
 124    // AI palette: purple/violet on headings
 125    if (hasChroma(textColor, 50)) {
 126      const hue = getHue(textColor);
 127      if (hue >= 260 && hue <= 310 && (['h1', 'h2', 'h3'].includes(tag) || fontSize >= 20)) {
 128        findings.push({ id: 'ai-color-palette', snippet: `Purple/violet text (${colorToHex(textColor)}) on heading` });
 129      }
 130    }
 131  }
 132
 133  // Gradient text
 134  if (bgClip === 'text' && bgImage && bgImage.includes('gradient')) {
 135    findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
 136  }
 137
 138  // Tailwind class checks
 139  if (classList) {
 140    const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
 141    if (/\bbg-black\b(?!\/)/.test(classStr)) {
 142      findings.push({ id: 'pure-black-white', snippet: 'bg-black' });
 143    }
 144
 145    const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
 146    const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
 147    if (grayMatch && colorBgMatch) {
 148      findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
 149    }
 150
 151    if (/\bbg-clip-text\b/.test(classStr) && /\bbg-gradient-to-/.test(classStr)) {
 152      findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' });
 153    }
 154
 155    const purpleText = classStr.match(/\btext-(?:purple|violet|indigo)-\d+\b/);
 156    if (purpleText && (['h1', 'h2', 'h3'].includes(tag) || /\btext-(?:[2-9]xl)\b/.test(classStr))) {
 157      findings.push({ id: 'ai-color-palette', snippet: `${purpleText[0]} on heading` });
 158    }
 159
 160    if (/\bfrom-(?:purple|violet|indigo)-\d+\b/.test(classStr) && /\bto-(?:purple|violet|indigo|blue|cyan|pink|fuchsia)-\d+\b/.test(classStr)) {
 161      findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet gradient (Tailwind)' });
 162    }
 163  }
 164
 165  return findings;
 166}
 167
 168function isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg) {
 169  if (!hasShadow && !hasBorder) return false;
 170  return hasRadius || hasBg;
 171}
 172
 173const HEADING_TAGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
 174
 175// Pure check: given a heading and metrics about its previousElementSibling,
 176// decide if the sibling is the canonical "icon-tile-stacked-above-heading" shape.
 177//
 178// Triggers when ALL of the following hold for the sibling:
 179//   • size 32–128px on both axes (not too small, not a hero image)
 180//   • aspect ratio 0.7–1.4 (squarish — excludes wide thumbnails / pill badges)
 181//   • has a non-transparent background-color, background-image, OR a visible border
 182//     (covers solid colors, white-with-border, gradients — anything that visually
 183//      defines a tile)
 184//   • border-radius < width/2 (excludes round avatars; rounded squares pass)
 185//   • contains an <svg> or icon-class <i> element that's smaller than the tile
 186//   • the tile sits above the heading (its bottom is above the heading's top)
 187function checkIconTile(opts) {
 188  const { headingTag, headingText, headingTop,
 189          siblingTag, siblingWidth, siblingHeight, siblingBottom,
 190          siblingBgColor, siblingBgImage, siblingBorderWidth, siblingBorderRadius,
 191          hasIconChild, iconChildWidth } = opts;
 192  if (!HEADING_TAGS.has(headingTag)) return [];
 193  if (!siblingTag) return [];
 194  // Don't recurse into nested headings (e.g. h2 above h3 in a section header)
 195  if (HEADING_TAGS.has(siblingTag)) return [];
 196
 197  // Size window: 32–128px on each axis
 198  if (!(siblingWidth >= 32 && siblingWidth <= 128)) return [];
 199  if (!(siblingHeight >= 32 && siblingHeight <= 128)) return [];
 200
 201  // Squarish aspect ratio
 202  const ratio = siblingWidth / siblingHeight;
 203  if (ratio < 0.7 || ratio > 1.4) return [];
 204
 205  // Must have something that visually defines the tile
 206  const bgVisible = (siblingBgColor && siblingBgColor.a > 0.1)
 207    || (siblingBgImage && siblingBgImage !== 'none' && siblingBgImage !== '');
 208  const borderVisible = siblingBorderWidth > 0;
 209  if (!bgVisible && !borderVisible) return [];
 210
 211  // Exclude circles (avatars). Rounded squares pass.
 212  if (siblingBorderRadius >= siblingWidth / 2) return [];
 213
 214  // Must contain an icon element smaller than the tile
 215  if (!hasIconChild) return [];
 216  if (iconChildWidth && iconChildWidth >= siblingWidth * 0.95) return [];
 217
 218  // Vertical stacking: tile must end above where the heading starts.
 219  // (Allow the check to skip when both top/bottom are 0 — jsdom layout case.)
 220  if (headingTop && siblingBottom && siblingBottom > headingTop + 4) return [];
 221
 222  const text = (headingText || '').trim().slice(0, 60);
 223  return [{
 224    id: 'icon-tile-stack',
 225    snippet: `${Math.round(siblingWidth)}x${Math.round(siblingHeight)}px icon tile above ${headingTag} "${text}"`,
 226  }];
 227}
 228
 229// Resolve the primary (non-generic) face from a font-family string and return
 230// whether the resolved primary is serif. Two paths:
 231//   1. Primary face is in KNOWN_SERIF_FONTS → serif.
 232//   2. Primary face is unknown but the stack ends in the generic `serif`
 233//      token → treat as serif. Authors who declare `font-family: 'X', serif`
 234//      almost always have a serif primary; a sans declared with a serif
 235//      fallback is a code smell, not the common case.
 236// Returns { primary, isSerif } so the snippet can name the face.
 237function resolveSerif(fontFamily) {
 238  if (!fontFamily) return { primary: null, isSerif: false };
 239  const tokens = fontFamily.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
 240  const primary = tokens.find(f => f && !GENERIC_FONTS.has(f)) || null;
 241  if (!primary) return { primary: null, isSerif: false };
 242  if (KNOWN_SERIF_FONTS.has(primary)) return { primary, isSerif: true };
 243  if (tokens.includes('serif')) return { primary, isSerif: true };
 244  return { primary, isSerif: false };
 245}
 246
 247function checkItalicSerif(opts) {
 248  const { tag, fontStyle, fontFamily, fontSize, headingText } = opts;
 249  if (fontStyle !== 'italic') return [];
 250  // Anchor the rule on hero-scale text. h1 is the canonical hero element;
 251  // h2 ≥ 48px catches the cases where the design demotes the visual hero
 252  // to an h2 but keeps the size.
 253  if (tag !== 'h1' && !(tag === 'h2' && fontSize >= 48)) return [];
 254  if (fontSize < 48) return [];
 255  const { primary, isSerif } = resolveSerif(fontFamily);
 256  if (!isSerif) return [];
 257
 258  const text = (headingText || '').trim().slice(0, 60);
 259  return [{
 260    id: 'italic-serif-display',
 261    snippet: `italic serif ${tag} (${primary || 'serif'}) at ${Math.round(fontSize)}px "${text}"`,
 262  }];
 263}
 264
 265// Color saturation check. Returns true when the color has visible
 266// chroma — i.e., it's an "accent color" rather than near-neutral.
 267// Handles rgb()/rgba(), #hex, oklch(), and hsl(). var() refs are
 268// expected to be pre-resolved by the caller.
 269function isAccentColor(cssColor) {
 270  if (!cssColor) return false;
 271  const s = String(cssColor).trim();
 272  // rgb / rgba — direct channel-distance check.
 273  const rgbM = /rgba?\(\s*(\d+)\s*,?\s+|\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(s.replace(/rgba?\(\s*/, 'rgb(').replace(/,/g, ', '));
 274  const rgbStrict = /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(s);
 275  if (rgbStrict) {
 276    const r = +rgbStrict[1], g = +rgbStrict[2], b = +rgbStrict[3];
 277    return (Math.max(r, g, b) - Math.min(r, g, b)) >= 40;
 278  }
 279  // #hex — 3, 4, 6, or 8 digit.
 280  const hexM = /^#([0-9a-f]{3,8})\b/i.exec(s);
 281  if (hexM) {
 282    let h = hexM[1];
 283    if (h.length === 3 || h.length === 4) h = h.split('').map((c) => c + c).join('').slice(0, 6);
 284    else h = h.slice(0, 6);
 285    if (h.length === 6) {
 286      const r = parseInt(h.slice(0, 2), 16);
 287      const g = parseInt(h.slice(2, 4), 16);
 288      const b = parseInt(h.slice(4, 6), 16);
 289      return (Math.max(r, g, b) - Math.min(r, g, b)) >= 40;
 290    }
 291  }
 292  // oklch(L C H) — chroma C is what matters. Typical neutral grays
 293  // have C < 0.02; visible accents are 0.05+. CSS minification can
 294  // collapse spaces between L% and C ("oklch(43%.15 34)"), so we
 295  // extract all numbers and take the second rather than matching a
 296  // strict L-then-whitespace-then-C pattern.
 297  if (/^oklch\(/i.test(s)) {
 298    const nums = s.match(/\d*\.\d+|\d+/g);
 299    if (nums && nums.length >= 2) {
 300      const c = parseFloat(nums[1]);
 301      return !Number.isNaN(c) && c >= 0.05;
 302    }
 303  }
 304  // hsl(H, S%, L%) — saturation > 20% reads as accent.
 305  const hslM = /hsla?\(\s*[\d.]+\s*,\s*([\d.]+)%/i.exec(s);
 306  if (hslM) {
 307    const sat = parseFloat(hslM[1]);
 308    return !Number.isNaN(sat) && sat >= 20;
 309  }
 310  return false;
 311}
 312
 313// Sibling-relationship rule. Anchor on a hero-scale h1, look at the
 314// previousElementSibling, and gate on EITHER the classic tracked-
 315// uppercase eyebrow OR the modern accent-colored bold eyebrow.
 316function checkHeroEyebrow(opts) {
 317  const {
 318    headingTag, headingText, headingFontSize,
 319    siblingTag, siblingText, siblingTextTransform,
 320    siblingFontSize, siblingLetterSpacing,
 321    siblingFontWeight, siblingColor,
 322  } = opts;
 323  if (headingTag !== 'h1') return [];
 324  // We previously gated on headingFontSize >= 48 to anchor "hero scale".
 325  // But modern hero h1s use clamp() / vw / var(--text-*), none of which
 326  // jsdom can resolve — the computed value comes back as "2em" or
 327  // "var(--text-9xl)" and parseFloat returns 2 or NaN. The gate fails
 328  // on virtually every Tailwind v4 / framework build. The other gates
 329  // (sibling text 2-60 chars, font-size ≤ 14px, accent-bold OR
 330  // tracked-caps) are tight enough to avoid false positives on non-
 331  // hero h1s — a tiny tan label directly above any h1 is the
 332  // antipattern regardless of how big the h1 ends up.
 333  if (!siblingTag) return [];
 334  // An h2 above an h1 is a different anti-pattern (heading hierarchy / dual
 335  // headings) — never an eyebrow.
 336  if (HEADING_TAGS.has(siblingTag)) return [];
 337
 338  const text = (siblingText || '').trim();
 339  if (text.length < 2 || text.length > 60) return [];
 340  if (!(siblingFontSize > 0 && siblingFontSize <= 14)) return [];
 341
 342  // Branch A: classic tracked-uppercase eyebrow.
 343  const isUppercased = siblingTextTransform === 'uppercase'
 344    || (/[A-Z]/.test(text) && !/[a-z]/.test(text));
 345  const isClassicTracked = isUppercased && siblingLetterSpacing >= 1.6;
 346
 347  // Branch B: modern accent-bold eyebrow — sentence case, low
 348  // tracking, but bold + accent-colored. The style choices changed;
 349  // the pattern is the same kicker-above-headline anti-pattern.
 350  const weight = Number(siblingFontWeight) || 400;
 351  const isAccentBold = weight >= 700 && isAccentColor(siblingColor || '');
 352
 353  if (!isClassicTracked && !isAccentBold) return [];
 354
 355  const headingTextSnippet = (headingText || '').trim().slice(0, 60);
 356  const eyebrowSnippet = text.slice(0, 40);
 357  const style = isClassicTracked ? 'tracked-caps' : 'accent-bold';
 358  return [{
 359    id: 'hero-eyebrow-chip',
 360    snippet: `eyebrow chip (${style}) "${eyebrowSnippet}" above ${headingTag} "${headingTextSnippet}"`,
 361  }];
 362}
 363
 364function checkRepeatedSectionKickers(opts) {
 365  const { candidates, minCount = 3 } = opts;
 366  if (!Array.isArray(candidates) || candidates.length < minCount) return [];
 367  return candidates.map(candidate => ({
 368    id: 'repeated-section-kickers',
 369    snippet: `repeated section kicker "${candidate.kickerText}" before ${candidate.headingTag} "${candidate.headingText}" (${candidates.length} on page)`,
 370  }));
 371}
 372
 373const LAYOUT_TRANSITION_PROPS = new Set([
 374  'width', 'height', 'padding', 'margin',
 375  'max-height', 'max-width', 'min-height', 'min-width',
 376  'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
 377  'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
 378]);
 379
 380function checkMotion(opts) {
 381  const { tag, transitionProperty, animationName, timingFunctions, classList } = opts;
 382  if (SAFE_TAGS.has(tag)) return [];
 383  const findings = [];
 384
 385  // --- Bounce/elastic easing ---
 386  if (animationName && animationName !== 'none' && /bounce|elastic|wobble|jiggle|spring/i.test(animationName)) {
 387    findings.push({ id: 'bounce-easing', snippet: `animation: ${animationName}` });
 388  }
 389  if (classList && /\banimate-bounce\b/.test(classList)) {
 390    findings.push({ id: 'bounce-easing', snippet: 'animate-bounce (Tailwind)' });
 391  }
 392
 393  // Check timing functions for overshoot cubic-bezier (y values outside [0, 1])
 394  if (timingFunctions) {
 395    const bezierRe = /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g;
 396    let m;
 397    while ((m = bezierRe.exec(timingFunctions)) !== null) {
 398      const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]);
 399      if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) {
 400        findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${m[1]}, ${m[2]}, ${m[3]}, ${m[4]})` });
 401        break;
 402      }
 403    }
 404  }
 405
 406  // --- Layout property transition ---
 407  if (transitionProperty && transitionProperty !== 'all' && transitionProperty !== 'none') {
 408    const props = transitionProperty.split(',').map(p => p.trim().toLowerCase());
 409    const layoutFound = props.filter(p => LAYOUT_TRANSITION_PROPS.has(p));
 410    if (layoutFound.length > 0) {
 411      findings.push({ id: 'layout-transition', snippet: `transition: ${layoutFound.join(', ')}` });
 412    }
 413  }
 414
 415  return findings;
 416}
 417
 418function checkGlow(opts) {
 419  const { boxShadow, effectiveBg } = opts;
 420  if (!boxShadow || boxShadow === 'none') return [];
 421  if (!effectiveBg) return [];
 422
 423  // Only flag on dark backgrounds (luminance < 0.1)
 424  const bgLum = relativeLuminance(effectiveBg);
 425  if (bgLum >= 0.1) return [];
 426
 427  // Split multiple shadows (commas not inside parentheses)
 428  const parts = boxShadow.split(/,(?![^(]*\))/);
 429  for (const shadow of parts) {
 430    const colorMatch = shadow.match(/rgba?\([^)]+\)/);
 431    if (!colorMatch) continue;
 432    const color = parseRgb(colorMatch[0]);
 433    if (!color || !hasChroma(color, 30)) continue;
 434
 435    // Extract px values — in computed style: "color Xpx Ypx BLURpx [SPREADpx]"
 436    const afterColor = shadow.substring(shadow.indexOf(colorMatch[0]) + colorMatch[0].length);
 437    const beforeColor = shadow.substring(0, shadow.indexOf(colorMatch[0]));
 438    const pxVals = [...beforeColor.matchAll(/([\d.]+)px/g), ...afterColor.matchAll(/([\d.]+)px/g)]
 439      .map(m => parseFloat(m[1]));
 440
 441    // Third value is blur (offset-x, offset-y, blur, [spread])
 442    if (pxVals.length >= 3 && pxVals[2] > 4) {
 443      return [{ id: 'dark-glow', snippet: `Colored glow (${colorToHex(color)}) on dark background` }];
 444    }
 445  }
 446
 447  return [];
 448}
 449
 450/**
 451 * Regex-on-HTML checks shared between browser and Node page-level detection.
 452 * These don't need DOM access, just the raw HTML string.
 453 */
 454function checkHtmlPatterns(html) {
 455  const findings = [];
 456
 457  // --- Color ---
 458
 459  // Pure black background
 460  const pureBlackBgRe = /background(?:-color)?\s*:\s*(?:#000000|#000|rgb\(\s*0,\s*0,\s*0\s*\))\b/gi;
 461  if (pureBlackBgRe.test(html)) {
 462    findings.push({ id: 'pure-black-white', snippet: 'Pure #000 background' });
 463  }
 464
 465  // AI color palette: purple/violet
 466  const purpleHexRe = /#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9|6366f1|764ba2|667eea)\b/gi;
 467  if (purpleHexRe.test(html)) {
 468    const purpleTextRe = /(?:(?:^|;)\s*color\s*:\s*(?:.*?)(?:#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9))|gradient.*?#(?:7c3aed|8b5cf6|a855f7|764ba2|667eea))/gi;
 469    if (purpleTextRe.test(html)) {
 470      findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected' });
 471    }
 472  }
 473
 474  // Gradient text (background-clip: text + gradient)
 475  const gradientRe = /(?:-webkit-)?background-clip\s*:\s*text/gi;
 476  let gm;
 477  while ((gm = gradientRe.exec(html)) !== null) {
 478    const start = Math.max(0, gm.index - 200);
 479    const context = html.substring(start, gm.index + gm[0].length + 200);
 480    if (/gradient/i.test(context)) {
 481      findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
 482      break;
 483    }
 484  }
 485  if (/\bbg-clip-text\b/.test(html) && /\bbg-gradient-to-/.test(html)) {
 486    findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' });
 487  }
 488
 489  // --- Layout ---
 490
 491  // Monotonous spacing
 492  const spacingValues = [];
 493  const spacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*(\d+)px/gi;
 494  let sm;
 495  while ((sm = spacingRe.exec(html)) !== null) {
 496    const v = parseInt(sm[1], 10);
 497    if (v > 0 && v < 200) spacingValues.push(v);
 498  }
 499  const gapRe = /gap\s*:\s*(\d+)px/gi;
 500  while ((sm = gapRe.exec(html)) !== null) {
 501    spacingValues.push(parseInt(sm[1], 10));
 502  }
 503  const twSpaceRe = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+)\b/g;
 504  while ((sm = twSpaceRe.exec(html)) !== null) {
 505    spacingValues.push(parseInt(sm[1], 10) * 4);
 506  }
 507  const remSpacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*([\d.]+)rem/gi;
 508  while ((sm = remSpacingRe.exec(html)) !== null) {
 509    const v = Math.round(parseFloat(sm[1]) * 16);
 510    if (v > 0 && v < 200) spacingValues.push(v);
 511  }
 512  const roundedSpacing = spacingValues.map(v => Math.round(v / 4) * 4);
 513  if (roundedSpacing.length >= 10) {
 514    const counts = {};
 515    for (const v of roundedSpacing) counts[v] = (counts[v] || 0) + 1;
 516    const maxCount = Math.max(...Object.values(counts));
 517    const dominantPct = maxCount / roundedSpacing.length;
 518    const unique = [...new Set(roundedSpacing)].filter(v => v > 0);
 519    if (dominantPct > 0.6 && unique.length <= 3) {
 520      const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
 521      findings.push({
 522        id: 'monotonous-spacing',
 523        snippet: `~${dominant}px used ${maxCount}/${roundedSpacing.length} times (${Math.round(dominantPct * 100)}%)`,
 524      });
 525    }
 526  }
 527
 528  // --- Motion ---
 529
 530  // Bounce/elastic animation names
 531  const bounceRe = /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi;
 532  if (bounceRe.test(html)) {
 533    findings.push({ id: 'bounce-easing', snippet: 'Bounce/elastic animation in CSS' });
 534  }
 535
 536  // Overshoot cubic-bezier
 537  const bezierRe = /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g;
 538  let bm;
 539  while ((bm = bezierRe.exec(html)) !== null) {
 540    const y1 = parseFloat(bm[2]), y2 = parseFloat(bm[4]);
 541    if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) {
 542      findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})` });
 543      break;
 544    }
 545  }
 546
 547  // Layout property transitions
 548  const transRe = /transition(?:-property)?\s*:\s*([^;{}]+)/gi;
 549  let tm;
 550  while ((tm = transRe.exec(html)) !== null) {
 551    const val = tm[1].toLowerCase();
 552    if (/\ball\b/.test(val)) continue;
 553    const found = val.match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
 554    if (found) {
 555      findings.push({ id: 'layout-transition', snippet: `transition: ${found.join(', ')}` });
 556      break;
 557    }
 558  }
 559
 560  // --- Dark glow ---
 561
 562  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;
 563  const twDarkBg = /\bbg-(?:gray|slate|zinc|neutral|stone)-(?:9\d{2}|800)\b/;
 564  if (darkBgRe.test(html) || twDarkBg.test(html)) {
 565    const shadowRe = /box-shadow\s*:\s*([^;{}]+)/gi;
 566    let shm;
 567    while ((shm = shadowRe.exec(html)) !== null) {
 568      const val = shm[1];
 569      const colorMatch = val.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
 570      if (!colorMatch) continue;
 571      const [r, g, b] = [+colorMatch[1], +colorMatch[2], +colorMatch[3]];
 572      if ((Math.max(r, g, b) - Math.min(r, g, b)) < 30) continue;
 573      const pxVals = [...val.matchAll(/(\d+)px|(?<![.\d])\b(0)\b(?![.\d])/g)].map(p => +(p[1] || p[2]));
 574      if (pxVals.length >= 3 && pxVals[2] > 4) {
 575        findings.push({ id: 'dark-glow', snippet: `Colored glow (rgb(${r},${g},${b})) on dark page` });
 576        break;
 577      }
 578    }
 579  }
 580
 581  return findings;
 582}
 583
 584// ─── Section 4: resolveBackground (unified) ─────────────────────────────────
 585
 586// Read the element's own background color, computed-style first, with a
 587// jsdom-friendly fallback that parses the inline `background:` shorthand
 588// from the raw style attribute. jsdom (~v29) does not decompose the
 589// shorthand into `backgroundColor`, so without this fallback the CLI silently
 590// returns null for any element styled via `background: rgb(...)` or
 591// `background: #abc`. Real browsers always decompose, so the fallback is
 592// a no-op there.
 593function readOwnBackgroundColor(el, computedStyle) {
 594  const bg = parseRgb(computedStyle.backgroundColor);
 595  if (DETECTOR_IS_BROWSER || (bg && bg.a >= 0.1)) return bg;
 596  const rawStyle = el.getAttribute?.('style') || '';
 597  const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
 598  const inlineBg = bgMatch ? bgMatch[1].trim() : '';
 599  if (!inlineBg) return bg;
 600  if (/gradient/i.test(inlineBg) || /url\s*\(/i.test(inlineBg)) return bg;
 601  const fromRgb = parseRgb(inlineBg);
 602  if (fromRgb) return fromRgb;
 603  const hexMatch = inlineBg.match(/#([0-9a-f]{6}|[0-9a-f]{3})\b/i);
 604  if (hexMatch) {
 605    const h = hexMatch[1];
 606    if (h.length === 6) {
 607      return { r: parseInt(h.slice(0, 2), 16), g: parseInt(h.slice(2, 4), 16), b: parseInt(h.slice(4, 6), 16), a: 1 };
 608    }
 609    return { r: parseInt(h[0] + h[0], 16), g: parseInt(h[1] + h[1], 16), b: parseInt(h[2] + h[2], 16), a: 1 };
 610  }
 611  return bg;
 612}
 613
 614function resolveBackground(el, win, customPropMap) {
 615  let current = el;
 616  while (current && current.nodeType === 1) {
 617    const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
 618    const bgImage = style.backgroundImage || '';
 619    const hasGradientOrUrl = bgImage && bgImage !== 'none' && (/gradient/i.test(bgImage) || /url\s*\(/i.test(bgImage));
 620
 621    // Try the solid bg-color FIRST. If the element has both a solid color
 622    // and a gradient/url overlay (a common pattern: `background: var(--paper)
 623    // radial-gradient(...)` for paper-grain texture), the solid color is the
 624    // dominant visible surface for contrast purposes; the overlay is
 625    // decorative. The old behavior bailed on any gradient ancestor, which
 626    // caused massive false-positive contrast findings on grain-textured
 627    // body backgrounds.
 628    let bg = parseRgb(style.backgroundColor);
 629    if (!DETECTOR_IS_BROWSER && (!bg || bg.a < 0.1)) {
 630      // jsdom returns literal "var(--X)" / "oklch(...)" strings. Resolve
 631      // through customPropMap so Tailwind v4 color tokens become RGB.
 632      if (customPropMap) {
 633        bg = parseColorResolved(style.backgroundColor, customPropMap);
 634      }
 635      if (!bg || bg.a < 0.1) {
 636        // Inline-style fallback. jsdom doesn't decompose background
 637        // shorthand, so colors set via inline style are otherwise invisible.
 638        const rawStyle = current.getAttribute?.('style') || '';
 639        const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
 640        const inlineBg = bgMatch ? bgMatch[1].trim() : '';
 641        if (inlineBg && !/gradient/i.test(inlineBg) && !/url\s*\(/i.test(inlineBg)) {
 642          bg = parseColorResolved(inlineBg, customPropMap) || parseAnyColor(inlineBg);
 643        }
 644      }
 645    }
 646
 647    if (bg && bg.a > 0.1) {
 648      if (DETECTOR_IS_BROWSER || bg.a >= 0.5) return bg;
 649    }
 650    // No solid bg-color at this level. If THIS level has a gradient/url
 651    // with no underlying solid color we can read:
 652    //   • on body/html: assume white. Body-level gradients are almost
 653    //     always decorative texture (paper grain, noise) on top of a
 654    //     solid bg-color the page set via `background: var(--paper)`
 655    //     shorthand — which jsdom can't decompose into bg-color. The
 656    //     downstream gradient-stops fallback path produces catastrophic
 657    //     false positives in this case (gradient noise stops have
 658    //     accidental browns/blacks that look like card backgrounds).
 659    //   • on other elements: bail to null and let the caller fall back
 660    //     to gradient stops (gradient buttons / hero sections are real
 661    //     bgs worth checking against).
 662    if (hasGradientOrUrl) {
 663      if (current.tagName === 'BODY' || current.tagName === 'HTML') {
 664        return { r: 255, g: 255, b: 255, a: 1 };
 665      }
 666      return null;
 667    }
 668    current = current.parentElement;
 669  }
 670  return { r: 255, g: 255, b: 255 };
 671}
 672
 673// Walk parents looking for a gradient background and return its color stops.
 674// Used as a fallback when resolveBackground() returns null because the
 675// effective background is a gradient (no single solid color to compare against).
 676function resolveGradientStops(el, win) {
 677  let current = el;
 678  while (current && current.nodeType === 1) {
 679    const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
 680    const bgImage = style.backgroundImage || '';
 681    if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
 682      const stops = parseGradientColors(bgImage);
 683      if (stops.length > 0) return stops;
 684    }
 685    if (!DETECTOR_IS_BROWSER) {
 686      // jsdom doesn't decompose `background:` shorthand — peek at the raw inline style
 687      const rawStyle = current.getAttribute?.('style') || '';
 688      const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
 689      if (bgMatch && /gradient/i.test(bgMatch[1])) {
 690        const stops = parseGradientColors(bgMatch[1]);
 691        if (stops.length > 0) return stops;
 692      }
 693    }
 694    current = current.parentElement;
 695  }
 696  return null;
 697}
 698
 699// Parse a single CSS length token to pixels. Accepts "12px", "50%", a
 700// shorthand like "12px 4px" (uses the first value), or empty / null.
 701// Returns the pixel value, or null when the input is unparseable.
 702// Percentages convert against `widthPx` when one is supplied. Without a
 703// usable width (jsdom returns "auto" for many real-world elements,
 704// which parseFloat collapses to 0), fall back to the raw percentage
 705// number so callers gating on `> 0` (border-accent-on-rounded,
 706// isCardLike's hasRadius) still see a positive value, matching the
 707// original parseFloat("50%") === 50 behavior.
 708function parseRadiusToPx(value, widthPx) {
 709  if (!value || typeof value !== 'string') return null;
 710  const trimmed = value.trim();
 711  if (!trimmed) return null;
 712  const first = trimmed.split(/\s+/)[0];
 713  const num = parseFloat(first);
 714  if (Number.isNaN(num)) return null;
 715  if (/%$/.test(first)) {
 716    if (widthPx && widthPx > 0) return (num / 100) * widthPx;
 717    return num;
 718  }
 719  return num;
 720}
 721
 722function resolveBorderRadiusPx(el, style, widthPx, win) {
 723  const fromComputed = parseRadiusToPx(style.borderRadius, widthPx);
 724  if (fromComputed !== null) return fromComputed;
 725  return 0;
 726}
 727
 728// ─── Section 5: Element Adapters ────────────────────────────────────────────
 729
 730// Browser adapters — call getComputedStyle/getBoundingClientRect on live DOM
 731
 732function checkElementBordersDOM(el) {
 733  const tag = el.tagName.toLowerCase();
 734  if (BORDER_SAFE_TAGS.has(tag)) return [];
 735  const rect = el.getBoundingClientRect();
 736  if (rect.width < 20 || rect.height < 20) return [];
 737  const style = getComputedStyle(el);
 738  const sides = ['Top', 'Right', 'Bottom', 'Left'];
 739  const widths = {}, colors = {};
 740  for (const s of sides) {
 741    widths[s] = parseFloat(style[`border${s}Width`]) || 0;
 742    colors[s] = style[`border${s}Color`] || '';
 743  }
 744  return checkBorders(tag, widths, colors, parseFloat(style.borderRadius) || 0);
 745}
 746
 747function checkElementColorsDOM(el) {
 748  const tag = el.tagName.toLowerCase();
 749  // No early SAFE_TAGS bail here — checkColors() does its own gating that
 750  // includes the styled-button exception for <a> / <button> with their own
 751  // opaque background. Bailing here would prevent that exception from firing.
 752  const rect = el.getBoundingClientRect();
 753  if (rect.width < 10 || rect.height < 10) return [];
 754  const style = getComputedStyle(el);
 755  const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
 756  const hasDirectText = directText.trim().length > 0;
 757  const effectiveBg = resolveBackground(el);
 758  return checkColors({
 759    tag,
 760    textColor: parseRgb(style.color),
 761    bgColor: readOwnBackgroundColor(el, style),
 762    effectiveBg,
 763    effectiveBgStops: effectiveBg ? null : resolveGradientStops(el),
 764    fontSize: parseFloat(style.fontSize) || 16,
 765    fontWeight: parseInt(style.fontWeight) || 400,
 766    hasDirectText,
 767    isEmojiOnly: isEmojiOnlyText(directText),
 768    bgClip: style.webkitBackgroundClip || style.backgroundClip || '',
 769    bgImage: style.backgroundImage || '',
 770    classList: el.getAttribute('class') || '',
 771  });
 772}
 773
 774function checkElementIconTileDOM(el) {
 775  const tag = el.tagName.toLowerCase();
 776  if (!HEADING_TAGS.has(tag)) return [];
 777  const sibling = el.previousElementSibling;
 778  if (!sibling) return [];
 779
 780  const sibRect = sibling.getBoundingClientRect();
 781  const headRect = el.getBoundingClientRect();
 782  const sibStyle = getComputedStyle(sibling);
 783
 784  // The tile may either contain an <svg>/<i> icon child, OR the tile itself
 785  // may contain an emoji/symbol character directly as its only text content
 786  // (the "card-icon" pattern from many AI-generated demos).
 787  const iconChild = sibling.querySelector('svg, i[data-lucide], i[class*="fa-"], i[class*="icon"]');
 788  const iconRect = iconChild?.getBoundingClientRect();
 789  const sibDirectText = [...sibling.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
 790  const hasInlineEmojiIcon = sibling.children.length === 0 && isEmojiOnlyText(sibDirectText);
 791
 792  return checkIconTile({
 793    headingTag: tag,
 794    headingText: el.textContent || '',
 795    headingTop: headRect.top,
 796    siblingTag: sibling.tagName.toLowerCase(),
 797    siblingWidth: sibRect.width,
 798    siblingHeight: sibRect.height,
 799    siblingBottom: sibRect.bottom,
 800    siblingBgColor: parseRgb(sibStyle.backgroundColor),
 801    siblingBgImage: sibStyle.backgroundImage || '',
 802    siblingBorderWidth: parseFloat(sibStyle.borderTopWidth) || 0,
 803    siblingBorderRadius: parseFloat(sibStyle.borderRadius) || 0,
 804    hasIconChild: !!iconChild || hasInlineEmojiIcon,
 805    iconChildWidth: iconRect?.width || 0,
 806  });
 807}
 808
 809function checkElementItalicSerifDOM(el) {
 810  const tag = el.tagName.toLowerCase();
 811  if (tag !== 'h1' && tag !== 'h2') return [];
 812  const style = getComputedStyle(el);
 813  return checkItalicSerif({
 814    tag,
 815    fontStyle: style.fontStyle || '',
 816    fontFamily: style.fontFamily || '',
 817    fontSize: parseFloat(style.fontSize) || 0,
 818    headingText: el.textContent || '',
 819  });
 820}
 821
 822function checkElementHeroEyebrowDOM(el) {
 823  const tag = el.tagName.toLowerCase();
 824  if (tag !== 'h1') return [];
 825  const sibling = el.previousElementSibling;
 826  if (!sibling) return [];
 827  const headStyle = getComputedStyle(el);
 828  const sibStyle = getComputedStyle(sibling);
 829  return checkHeroEyebrow({
 830    headingTag: tag,
 831    headingText: el.textContent || '',
 832    headingFontSize: parseFloat(headStyle.fontSize) || 0,
 833    siblingTag: sibling.tagName.toLowerCase(),
 834    siblingText: sibling.textContent || '',
 835    siblingTextTransform: sibStyle.textTransform || '',
 836    siblingFontSize: parseFloat(sibStyle.fontSize) || 0,
 837    siblingLetterSpacing: parseFloat(sibStyle.letterSpacing) || 0,
 838    siblingFontWeight: sibStyle.fontWeight || '',
 839    siblingColor: sibStyle.color || '',
 840  });
 841}
 842
 843// Build a map of CSS custom properties declared on :root / :host / html.
 844// Used to resolve var(--X) refs that jsdom returns verbatim in
 845// getComputedStyle. Tailwind v4 routes every utility class through
 846// CSS vars (font-weight: var(--font-weight-bold), font-size:
 847// var(--text-xs), letter-spacing: var(--tracking-widest)), so without
 848// resolution every style-based check silently fails on Tailwind v4
 849// builds — the values come back as literal "var(--font-weight-bold)"
 850// strings and parseFloat returns NaN.
 851function buildCustomPropMap(document) {
 852  const map = new Map();
 853  let sheets;
 854  try { sheets = Array.from(document.styleSheets || []); }
 855  catch { return map; }
 856  for (const sheet of sheets) {
 857    let rules;
 858    try { rules = Array.from(sheet.cssRules || []); }
 859    catch { continue; }
 860    for (const rule of rules) {
 861      // Style rules only (type 1). Walk @media / @supports if present.
 862      if (rule.type === 4 /* MEDIA_RULE */ || rule.type === 12 /* SUPPORTS_RULE */) {
 863        try { rules.push(...Array.from(rule.cssRules || [])); } catch { /* ignore */ }
 864        continue;
 865      }
 866      if (rule.type !== 1 /* STYLE_RULE */) continue;
 867      const sel = rule.selectorText || '';
 868      if (!/(^|,\s*)(:root|html|:host)\b/i.test(sel)) continue;
 869      const style = rule.style;
 870      if (!style) continue;
 871      for (let i = 0; i < style.length; i++) {
 872        const prop = style[i];
 873        if (!prop || !prop.startsWith('--')) continue;
 874        const val = style.getPropertyValue(prop).trim();
 875        if (val) map.set(prop, val);
 876      }
 877    }
 878  }
 879  return map;
 880}
 881
 882// Resolve var(--X[, fallback]) refs in a computed-style value string.
 883// Recurses up to 8 levels for chained refs (--a: var(--b)). Returns
 884// the original string when no refs are present or the chain doesn't
 885// resolve. Safe to call on already-resolved values.
 886function resolveVarRefs(raw, customPropMap, depth = 0) {
 887  if (typeof raw !== 'string' || !raw.includes('var(')) return raw;
 888  if (depth > 8) return raw;
 889  return raw.replace(/var\(\s*(--[a-zA-Z0-9_-]+)\s*(?:,\s*([^)]+))?\)/g, (_m, name, fallback) => {
 890    const v = customPropMap.get(name);
 891    if (v != null) return resolveVarRefs(v, customPropMap, depth + 1);
 892    return fallback ? resolveVarRefs(fallback.trim(), customPropMap, depth + 1) : _m;
 893  });
 894}
 895
 896// OKLCH → sRGB conversion (Björn Ottosson's matrices). L in 0..1 (or %),
 897// C in 0..~0.4 typical, H in degrees. Returns clamped {r,g,b,a:1} in 0..255.
 898// Needed because jsdom doesn't compute oklch() values — getComputedStyle
 899// returns the literal "oklch(...)" string. Without this, the entire
 900// Tailwind v4 color palette (which is OKLCH-based) is invisible to the
 901// detector's contrast / color checks.
 902function oklchToRgb(L, C, H) {
 903  const hRad = (H * Math.PI) / 180;
 904  const a = C * Math.cos(hRad);
 905  const b = C * Math.sin(hRad);
 906  const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
 907  const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
 908  const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
 909  const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;
 910  const rLin =  4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc;
 911  const gLin = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc;
 912  const bLin = -0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc;
 913  const enc = (x) => {
 914    const c = Math.max(0, Math.min(1, x));
 915    return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
 916  };
 917  return {
 918    r: Math.round(enc(rLin) * 255),
 919    g: Math.round(enc(gLin) * 255),
 920    b: Math.round(enc(bLin) * 255),
 921    a: 1,
 922  };
 923}
 924
 925// Extended color parser: rgb/rgba/hex/oklch. Returns null on no match.
 926// Use this when the input might be any CSS color form; use plain parseRgb
 927// when you only expect computed rgb() values from real browsers.
 928function parseAnyColor(s) {
 929  if (!s || typeof s !== 'string') return null;
 930  const str = s.trim();
 931  if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;
 932  let m;
 933  m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+))?\s*\)/);
 934  if (m) return { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: m[4] !== undefined ? +m[4] : 1 };
 935  m = str.match(/^#([0-9a-f]{3,8})$/i);
 936  if (m) {
 937    const h = m[1];
 938    if (h.length === 3 || h.length === 4) {
 939      return {
 940        r: parseInt(h[0] + h[0], 16),
 941        g: parseInt(h[1] + h[1], 16),
 942        b: parseInt(h[2] + h[2], 16),
 943        a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
 944      };
 945    }
 946    if (h.length === 6 || h.length === 8) {
 947      return {
 948        r: parseInt(h.slice(0, 2), 16),
 949        g: parseInt(h.slice(2, 4), 16),
 950        b: parseInt(h.slice(4, 6), 16),
 951        a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
 952      };
 953    }
 954  }
 955  // OKLCH parser. Tailwind v4's CSS minifier squishes the space after
 956  // `%` ("21.5%.02 50"), so the separator between L and C may be absent.
 957  // Match L (with optional %), then C and H separated permissively.
 958  m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?\s*\)/i);
 959  if (m) {
 960    const Lnum = parseFloat(m[1]);
 961    const L = m[2] === '%' ? Lnum / 100 : Lnum;
 962    return oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
 963  }
 964  return null;
 965}
 966
 967// Resolve var() refs in a color string (via customPropMap), then parse.
 968// Returns null on any failure. Used in jsdom-mode paths where
 969// getComputedStyle returns literal "var(--X)" or "oklch(...)" strings.
 970function parseColorResolved(str, customPropMap) {
 971  if (!str) return null;
 972  const resolved = customPropMap ? resolveVarRefs(str, customPropMap) : str;
 973  return parseAnyColor(resolved);
 974}
 975
 976const REPEATED_KICKER_SKIP_SELECTOR = [
 977  'nav',
 978  'form',
 979  'table',
 980  'thead',
 981  'tbody',
 982  'tfoot',
 983  'figure',
 984  'figcaption',
 985  'ol',
 986  'ul',
 987  'li',
 988  '[role="navigation"]',
 989  '[aria-label*="breadcrumb" i]',
 990  '[class*="breadcrumb" i]',
 991  '[data-impeccable-allow-kickers]',
 992].join(',');
 993
 994function cleanInlineText(el) {
 995  return [...el.childNodes]
 996    .filter(n => n.nodeType === 3)
 997    .map(n => n.textContent)
 998    .join(' ')
 999    .replace(/\s+/g, ' ')
1000    .trim();
1001}
1002
1003function isRepeatedKickerCandidate(opts) {
1004  const {
1005    headingTag,
1006    headingText,
1007    headingFontSize,
1008    kickerTag,
1009    kickerText,
1010    kickerTextTransform,
1011    kickerFontSize,
1012    kickerLetterSpacing,
1013  } = opts;
1014  if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
1015  if (!headingText || headingText.length < 3) return false;
1016  if (!(headingFontSize >= 20)) return false;
1017  if (!kickerTag || HEADING_TAGS.has(kickerTag)) return false;
1018  if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false;
1019  if (!kickerText || kickerText.length < 2 || kickerText.length > 34) return false;
1020  if (/^step\s*\d+/i.test(kickerText) || /^\d{1,2}$/.test(kickerText)) return false;
1021
1022  const isUppercased = kickerTextTransform === 'uppercase'
1023    || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText));
1024  if (!isUppercased) return false;
1025  if (!(kickerFontSize > 0 && kickerFontSize <= 14)) return false;
1026  const minTrackedSpacing = Math.max(1, kickerFontSize * 0.08);
1027  if (!(kickerLetterSpacing >= minTrackedSpacing)) return false;
1028  return true;
1029}
1030
1031function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpacing) {
1032  const candidates = [];
1033  for (const heading of doc.querySelectorAll('h2, h3, h4')) {
1034    if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
1035    const kicker = heading.previousElementSibling;
1036    if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
1037
1038    const headingStyle = getStyle(heading);
1039    const kickerStyle = getStyle(kicker);
1040    const headingText = (heading.textContent || '').replace(/\s+/g, ' ').trim();
1041    const kickerText = cleanInlineText(kicker) || (kicker.textContent || '').replace(/\s+/g, ' ').trim();
1042    const headingFontSize = resolveLetterSpacing(headingStyle.fontSize || '', 16) || parseFloat(headingStyle.fontSize) || 0;
1043    const kickerFontSize = resolveLetterSpacing(kickerStyle.fontSize || '', 16) || parseFloat(kickerStyle.fontSize) || 0;
1044    const kickerLetterSpacing = resolveLetterSpacing(kickerStyle.letterSpacing || '', kickerFontSize);
1045
1046    if (!isRepeatedKickerCandidate({
1047      headingTag: heading.tagName.toLowerCase(),
1048      headingText,
1049      headingFontSize,
1050      kickerTag: kicker.tagName.toLowerCase(),
1051      kickerText,
1052      kickerTextTransform: kickerStyle.textTransform || '',
1053      kickerFontSize,
1054      kickerLetterSpacing,
1055    })) {
1056      continue;
1057    }
1058
1059    candidates.push({
1060      headingTag: heading.tagName.toLowerCase(),
1061      headingText: headingText.replace(/^"|"$/g, '').slice(0, 60),
1062      kickerText: kickerText.slice(0, 40),
1063    });
1064  }
1065  return candidates;
1066}
1067
1068function checkRepeatedSectionKickersDOM() {
1069  const candidates = collectRepeatedSectionKickerCandidates(
1070    document,
1071    (el) => getComputedStyle(el),
1072    (value, fontSize) => resolveLengthPx(value, fontSize) || 0,
1073  );
1074  return checkRepeatedSectionKickers({ candidates });
1075}
1076
1077function checkElementMotionDOM(el) {
1078  const tag = el.tagName.toLowerCase();
1079  if (SAFE_TAGS.has(tag)) return [];
1080  const style = getComputedStyle(el);
1081  return checkMotion({
1082    tag,
1083    transitionProperty: style.transitionProperty || '',
1084    animationName: style.animationName || '',
1085    timingFunctions: [style.animationTimingFunction, style.transitionTimingFunction].filter(Boolean).join(' '),
1086    classList: el.getAttribute('class') || '',
1087  });
1088}
1089
1090function checkElementGlowDOM(el) {
1091  const tag = el.tagName.toLowerCase();
1092  const style = getComputedStyle(el);
1093  if (!style.boxShadow || style.boxShadow === 'none') return [];
1094  // Use parent's background — glow radiates outward, so the surrounding context matters
1095  // If resolveBackground returns null (gradient), try to infer from the gradient colors
1096  let parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el);
1097  if (!parentBg) {
1098    // Gradient background — sample its colors to determine if it's dark
1099    let cur = el.parentElement;
1100    while (cur && cur.nodeType === 1) {
1101      const bgImage = getComputedStyle(cur).backgroundImage || '';
1102      const gradColors = parseGradientColors(bgImage);
1103      if (gradColors.length > 0) {
1104        // Average the gradient colors
1105        const avg = { r: 0, g: 0, b: 0 };
1106        for (const c of gradColors) { avg.r += c.r; avg.g += c.g; avg.b += c.b; }
1107        avg.r = Math.round(avg.r / gradColors.length);
1108        avg.g = Math.round(avg.g / gradColors.length);
1109        avg.b = Math.round(avg.b / gradColors.length);
1110        parentBg = avg;
1111        break;
1112      }
1113      cur = cur.parentElement;
1114    }
1115  }
1116  return checkGlow({ tag, boxShadow: style.boxShadow, effectiveBg: parentBg });
1117}
1118
1119function checkElementAIPaletteDOM(el) {
1120  const style = getComputedStyle(el);
1121  const findings = [];
1122
1123  // Check gradient backgrounds for purple/violet or cyan
1124  const bgImage = style.backgroundImage || '';
1125  const gradColors = parseGradientColors(bgImage);
1126  for (const c of gradColors) {
1127    if (hasChroma(c, 50)) {
1128      const hue = getHue(c);
1129      if (hue >= 260 && hue <= 310) {
1130        findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet gradient background' });
1131        break;
1132      }
1133      if (hue >= 160 && hue <= 200) {
1134        findings.push({ id: 'ai-color-palette', snippet: 'Cyan gradient background' });
1135        break;
1136      }
1137    }
1138  }
1139
1140  // Check for neon text (vivid cyan/purple color on dark background)
1141  const textColor = parseRgb(style.color);
1142  if (textColor && hasChroma(textColor, 80)) {
1143    const hue = getHue(textColor);
1144    const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
1145    if (isAIPalette) {
1146      const parentBg = el.parentElement ? resolveBackground(el.parentElement) : null;
1147      // Also check gradient parents
1148      let effectiveBg = parentBg;
1149      if (!effectiveBg) {
1150        let cur = el.parentElement;
1151        while (cur && cur.nodeType === 1) {
1152          const gi = getComputedStyle(cur).backgroundImage || '';
1153          const gc = parseGradientColors(gi);
1154          if (gc.length > 0) {
1155            const avg = { r: 0, g: 0, b: 0 };
1156            for (const c of gc) { avg.r += c.r; avg.g += c.g; avg.b += c.b; }
1157            avg.r = Math.round(avg.r / gc.length);
1158            avg.g = Math.round(avg.g / gc.length);
1159            avg.b = Math.round(avg.b / gc.length);
1160            effectiveBg = avg;
1161            break;
1162          }
1163          cur = cur.parentElement;
1164        }
1165      }
1166      if (effectiveBg && relativeLuminance(effectiveBg) < 0.1) {
1167        const label = hue >= 260 ? 'Purple/violet' : 'Cyan';
1168        findings.push({ id: 'ai-color-palette', snippet: `${label} neon text on dark background` });
1169      }
1170    }
1171  }
1172
1173  return findings;
1174}
1175
1176const QUALITY_TEXT_TAGS = new Set(['p', 'li', 'td', 'th', 'dd', 'blockquote', 'figcaption']);
1177
1178// Resolve a CSS font-size value to pixels by walking up the parent chain.
1179// Browsers resolve em/rem/% to px in getComputedStyle, but jsdom returns the
1180// specified value verbatim — so for the Node path we walk parents ourselves.
1181function resolveFontSizePx(el, win) {
1182  const chain = []; // raw font-size strings, leaf → root
1183  let cur = el;
1184  while (cur && cur.nodeType === 1) {
1185    const fs = (win ? win.getComputedStyle(cur) : getComputedStyle(cur)).fontSize;
1186    chain.push(fs || '');
1187    cur = cur.parentElement;
1188  }
1189  // Walk root → leaf, resolving each value relative to its parent context.
1190  let px = 16; // root default
1191  for (let i = chain.length - 1; i >= 0; i--) {
1192    const v = chain[i];
1193    if (!v || v === 'inherit') continue;
1194    const num = parseFloat(v);
1195    if (isNaN(num)) continue;
1196    if (v.endsWith('px')) px = num;
1197    else if (v.endsWith('rem')) px = num * 16;
1198    else if (v.endsWith('em')) px = num * px;
1199    else if (v.endsWith('%')) px = (num / 100) * px;
1200    else px = num; // unitless — already resolved
1201  }
1202  return px;
1203}
1204
1205// Resolve a CSS length value (line-height, letter-spacing, etc.) given a
1206// known font-size context. Returns null for "normal" / unparseable values.
1207function resolveLengthPx(value, fontSizePx) {
1208  if (!value || value === 'normal' || value === 'auto' || value === 'inherit') return null;
1209  const num = parseFloat(value);
1210  if (isNaN(num)) return null;
1211  if (value.endsWith('px')) return num;
1212  if (value.endsWith('rem')) return num * 16;
1213  if (value.endsWith('em')) return num * fontSizePx;
1214  if (value.endsWith('%')) return (num / 100) * fontSizePx;
1215  // Unitless line-height = multiplier, return px equivalent
1216  return num * fontSizePx;
1217}
1218
1219// Pure quality checks. Most run on computed CSS and DOM-only inputs (work in
1220// jsdom and the browser). Two checks (line-length, cramped-padding) gate on
1221// element rect dimensions, which jsdom can't compute — pass `rect: null` from
1222// the Node adapter to skip those.
1223//
1224// Both adapters resolve font-size, line-height and letter-spacing to pixels
1225// before calling this so the pure function only deals with numbers.
1226function checkQuality(opts) {
1227  const { el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax = 80, viewportWidth = 0 } = opts;
1228  const findings = [];
1229  // Skip browser extension injected elements
1230  const elId = el.id || '';
1231  if (elId.startsWith('claude-') || elId.startsWith('cic-')) return findings;
1232
1233  // --- Line length too long --- (browser-only: needs rect.width)
1234  if (rect && hasDirectText && QUALITY_TEXT_TAGS.has(tag) && rect.width > 0 && textLen > lineMax) {
1235    const charsPerLine = rect.width / (fontSize * 0.5);
1236    if (charsPerLine > lineMax + 5) {
1237      findings.push({ id: 'line-length', snippet: `~${Math.round(charsPerLine)} chars/line (aim for <${lineMax})` });
1238    }
1239  }
1240
1241  // --- Cramped padding --- (browser-only: needs rect to skip small badges/labels)
1242  // Vertical and horizontal thresholds are independent because line-height
1243  // already provides built-in vertical breathing room (the line box is taller
1244  // than the cap height), but horizontal has no equivalent. Both scale with
1245  // font-size — bigger text demands proportionally more padding.
1246  //   vertical:   max(4px, fontSize × 0.3)
1247  //   horizontal: max(8px, fontSize × 0.5)
1248  if (rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) {
1249    const borders = {
1250      top: parseFloat(style.borderTopWidth) || 0,
1251      right: parseFloat(style.borderRightWidth) || 0,
1252      bottom: parseFloat(style.borderBottomWidth) || 0,
1253      left: parseFloat(style.borderLeftWidth) || 0,
1254    };
1255    const borderCount = Object.values(borders).filter(w => w > 0).length;
1256    const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)';
1257    if (borderCount >= 2 || hasBg) {
1258      const vPads = [], hPads = [];
1259      if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0);
1260      if (hasBg || borders.bottom > 0) vPads.push(parseFloat(style.paddingBottom) || 0);
1261      if (hasBg || borders.left > 0) hPads.push(parseFloat(style.paddingLeft) || 0);
1262      if (hasBg || borders.right > 0) hPads.push(parseFloat(style.paddingRight) || 0);
1263
1264      const vMin = vPads.length ? Math.min(...vPads) : Infinity;
1265      const hMin = hPads.length ? Math.min(...hPads) : Infinity;
1266      const vThresh = Math.max(4, fontSize * 0.3);
1267      const hThresh = Math.max(8, fontSize * 0.5);
1268
1269      // Emit at most one finding per element — pick whichever axis is worse.
1270      if (vMin < vThresh) {
1271        findings.push({ id: 'cramped-padding', snippet: `${vMin}px vertical padding (need ≥${vThresh.toFixed(1)}px for ${fontSize}px text)` });
1272      } else if (hMin < hThresh) {
1273        findings.push({ id: 'cramped-padding', snippet: `${hMin}px horizontal padding (need ≥${hThresh.toFixed(1)}px for ${fontSize}px text)` });
1274      }
1275    }
1276  }
1277
1278  // --- Body text touching viewport edge --- (browser-only: needs rect)
1279  // Catches the failure mode where the agent ships body paragraphs
1280  // with NO container providing horizontal padding — text bleeds
1281  // directly to the viewport edge. Different from cramped-padding,
1282  // which requires a colored/bordered container. Here the failure
1283  // is the absence of the container entirely.
1284  //
1285  // Gate aggressively to avoid false positives:
1286  //   - <p> or <li> only (body content; not headings, not nav, not
1287  //     wrappers)
1288  //   - text > 40 chars (paragraph-like, not a label)
1289  //   - rect.width > 50% of viewport (real body, not a pull-quote)
1290  //   - rect.left < 16 OR rect.right > viewport - 16 (actually
1291  //     touching the edge)
1292  //   - not inside <nav> or <header> (those legitimately bleed)
1293  //   - element itself has no background-color (intentional full-bleed
1294  //     sections set a bg-color and provide their own internal padding)
1295  if (rect && hasDirectText && textLen > 40 && ['P', 'LI'].includes(tag.toUpperCase()) && viewportWidth > 0) {
1296    const inNavHeader = el.closest && (el.closest('nav') || el.closest('header'));
1297    const hasOwnBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)' && style.backgroundColor !== 'transparent';
1298    const isPositioned = ['fixed', 'absolute'].includes(style.position || '');
1299    const widthRatio = rect.width / viewportWidth;
1300    const leftClose = rect.left < 16;
1301    const rightClose = rect.right > viewportWidth - 16;
1302    if (!inNavHeader && !hasOwnBg && !isPositioned && widthRatio > 0.5 && (leftClose || rightClose)) {
1303      const which = leftClose && rightClose
1304        ? `left ${Math.round(rect.left)}px / right ${Math.round(viewportWidth - rect.right)}px`
1305        : leftClose
1306          ? `left ${Math.round(rect.left)}px`
1307          : `right ${Math.round(viewportWidth - rect.right)}px`;
1308      findings.push({ id: 'body-text-viewport-edge', snippet: `<${tag.toLowerCase()}> with ${textLen}-char body bleeds to viewport edge (${which})` });
1309    }
1310  }
1311
1312  // --- Tight line height ---
1313  if (hasDirectText && textLen > 50 && !['h1','h2','h3','h4','h5','h6'].includes(tag)) {
1314    if (lineHeightPx != null && fontSize > 0) {
1315      const ratio = lineHeightPx / fontSize;
1316      if (ratio > 0 && ratio < 1.3) {
1317        findings.push({ id: 'tight-leading', snippet: `line-height ${ratio.toFixed(2)}x (need >=1.3)` });
1318      }
1319    }
1320  }
1321
1322  // --- Justified text (without hyphens) ---
1323  if (hasDirectText && style.textAlign === 'justify') {
1324    const hyphens = style.hyphens || style.webkitHyphens || '';
1325    if (hyphens !== 'auto') {
1326      findings.push({ id: 'justified-text', snippet: 'text-align: justify without hyphens: auto' });
1327    }
1328  }
1329
1330  // --- Tiny body text ---
1331  // Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.)
1332  if (hasDirectText && textLen > 20 && fontSize < 12) {
1333    const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption'];
1334    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]');
1335    const isUppercase = style.textTransform === 'uppercase';
1336    if (!skipTags.includes(tag) && !inUIContext && !isUppercase) {
1337      findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` });
1338    }
1339  }
1340
1341  // --- All-caps body text ---
1342  if (hasDirectText && textLen > 30 && style.textTransform === 'uppercase') {
1343    if (!['h1','h2','h3','h4','h5','h6'].includes(tag)) {
1344      findings.push({ id: 'all-caps-body', snippet: `text-transform: uppercase on ${textLen} chars of body text` });
1345    }
1346  }
1347
1348  // --- Wide letter spacing on body text ---
1349  if (hasDirectText && textLen > 20 && style.textTransform !== 'uppercase') {
1350    if (letterSpacingPx != null && letterSpacingPx > 0 && fontSize > 0) {
1351      const trackingEm = letterSpacingPx / fontSize;
1352      if (trackingEm > 0.05) {
1353        findings.push({ id: 'wide-tracking', snippet: `letter-spacing: ${trackingEm.toFixed(2)}em on body text` });
1354      }
1355    }
1356  }
1357
1358  return findings;
1359}
1360
1361function checkElementQualityDOM(el) {
1362  const tag = el.tagName.toLowerCase();
1363  const style = getComputedStyle(el);
1364  const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 10);
1365  const textLen = el.textContent?.trim().length || 0;
1366  // Browser getComputedStyle resolves everything to px — direct parseFloat
1367  // works.
1368  const fontSize = parseFloat(style.fontSize) || 16;
1369  const lineHeightPx = resolveLengthPx(style.lineHeight, fontSize);
1370  const letterSpacingPx = resolveLengthPx(style.letterSpacing, fontSize);
1371  const rect = el.getBoundingClientRect();
1372  const lineMax = (typeof window !== 'undefined' && window.__IMPECCABLE_CONFIG__?.lineLengthMax) || 80;
1373  const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0;
1374  return checkQuality({ el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax, viewportWidth });
1375}
1376
1377// Pure page-level skipped-heading walk. Takes a Document so it works in both
1378// the browser and jsdom.
1379function checkPageQualityFromDoc(doc) {
1380  const findings = [];
1381  const headings = doc.querySelectorAll('h1, h2, h3, h4, h5, h6');
1382  let prevLevel = 0;
1383  let prevText = '';
1384  for (const h of headings) {
1385    const level = parseInt(h.tagName[1]);
1386    const text = (h.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 60);
1387    if (prevLevel > 0 && level > prevLevel + 1) {
1388      findings.push({
1389        id: 'skipped-heading',
1390        snippet: `<h${prevLevel}> "${prevText}" followed by <h${level}> "${text}" (missing h${prevLevel + 1})`,
1391      });
1392    }
1393    prevLevel = level;
1394    prevText = text;
1395  }
1396  return findings;
1397}
1398
1399// Browser adapter (returns the legacy { type, detail } shape used by the overlay loop)
1400function checkPageQualityDOM() {
1401  return checkPageQualityFromDoc(document).map(f => ({ type: f.id, detail: f.snippet }));
1402}
1403
1404// Node adapters — take pre-extracted jsdom computed style
1405
1406// jsdom doesn't lay out OR resolve em/rem/% to px — so we pre-resolve every
1407// CSS length the rule needs ourselves (walking the parent chain for
1408// font-size inheritance), and pass `rect: null` to skip the two rules that
1409// genuinely need element rects (line-length, cramped-padding).
1410function checkElementQuality(el, style, tag, window) {
1411  const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 10);
1412  const textLen = el.textContent?.trim().length || 0;
1413  const fontSize = resolveFontSizePx(el, window);
1414  const lineHeightPx = resolveLengthPx(style.lineHeight, fontSize);
1415  const letterSpacingPx = resolveLengthPx(style.letterSpacing, fontSize);
1416  return checkQuality({ el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect: null });
1417}
1418
1419function checkElementBorders(tag, style, overrides, resolvedRadius) {
1420  const sides = ['Top', 'Right', 'Bottom', 'Left'];
1421  const widths = {}, colors = {};
1422  for (const s of sides) {
1423    widths[s] = parseFloat(style[`border${s}Width`]) || 0;
1424    colors[s] = style[`border${s}Color`] || '';
1425    // jsdom silently drops any border shorthand containing var(), leaving
1426    // both width and color empty on the computed style. When the detectHtml
1427    // pre-pass pulled a resolved value off the rule, use it to fill in the
1428    // missing side so the side-tab check can run. Real browsers resolve
1429    // var() natively, so this fallback is a no-op in the browser path.
1430    if (widths[s] === 0 && overrides && overrides[s]) {
1431      widths[s] = overrides[s].width;
1432      colors[s] = overrides[s].color;
1433    } else if (colors[s] && colors[s].startsWith('var(') && overrides && overrides[s]) {
1434      // Longhand case: jsdom kept the width but left the color as the
1435      // literal `var(...)` string. Substitute the resolved color.
1436      colors[s] = overrides[s].color;
1437    }
1438  }
1439  // resolvedRadius lets the caller pre-resolve the radius via
1440  // resolveBorderRadiusPx so the value survives jsdom 29.1.0's broken
1441  // shorthand serialization. Falls back to the computed value for tests
1442  // and browser callers that don't pre-resolve.
1443  const radius = resolvedRadius != null
1444    ? resolvedRadius
1445    : (parseFloat(style.borderRadius) || 0);
1446  return checkBorders(tag, widths, colors, radius);
1447}
1448
1449function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInheritRule) {
1450  const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
1451  const hasDirectText = directText.trim().length > 0;
1452
1453  const effectiveBg = resolveBackground(el, window, customPropMap);
1454  // jsdom returns literal "var(--X)" / "oklch(...)" for color, so plain
1455  // parseRgb misses Tailwind-tokenized text colors. Resolve through the
1456  // customPropMap first; fall back to parseRgb for vanilla rgb() pages.
1457  let textColor = customPropMap ? parseColorResolved(style.color, customPropMap) : null;
1458  if (!textColor) textColor = parseRgb(style.color);
1459
1460  // Anchor-inherit FP workaround: jsdom's UA stylesheet has `:link { color:
1461  // blue }` at high specificity. The page's `a { color: inherit }` rule
1462  // (Tailwind v4 preflight) loses to jsdom even though it WINS in real
1463  // browsers (Chrome's UA wraps :link in :where() — zero specificity).
1464  // When the page declares the inherit rule AND we see jsdom's default
1465  // link blue on an anchor, walk to the nearest non-anchor ancestor and
1466  // use its color instead.
1467  if (
1468    hasAnchorInheritRule &&
1469    textColor &&
1470    textColor.r === 0 && textColor.g === 0 && textColor.b === 238 &&
1471    (tag === 'a' || el.closest?.('a'))
1472  ) {
1473    let cur = el.parentElement;
1474    while (cur && cur.tagName !== 'HTML') {
1475      if (cur.tagName !== 'A') {
1476        const ps = window.getComputedStyle(cur);
1477        const inh = (customPropMap ? parseColorResolved(ps.color, customPropMap) : null) || parseRgb(ps.color);
1478        if (inh && !(inh.r === 0 && inh.g === 0 && inh.b === 238)) {
1479          textColor = inh;
1480          break;
1481        }
1482      }
1483      cur = cur.parentElement;
1484    }
1485  }
1486
1487  return checkColors({
1488    tag,
1489    textColor,
1490    bgColor: readOwnBackgroundColor(el, style),
1491    effectiveBg,
1492    effectiveBgStops: effectiveBg ? null : resolveGradientStops(el, window),
1493    fontSize: parseFloat(style.fontSize) || 16,
1494    fontWeight: parseInt(style.fontWeight) || 400,
1495    hasDirectText,
1496    isEmojiOnly: isEmojiOnlyText(directText),
1497    bgClip: style.webkitBackgroundClip || style.backgroundClip || '',
1498    bgImage: style.backgroundImage || '',
1499    classList: el.getAttribute?.('class') || el.className || '',
1500  });
1501}
1502
1503function checkElementIconTile(el, tag, window) {
1504  if (!HEADING_TAGS.has(tag)) return [];
1505  const sibling = el.previousElementSibling;
1506  if (!sibling) return [];
1507
1508  const sibStyle = window.getComputedStyle(sibling);
1509  // jsdom doesn't lay out — read explicit pixel dimensions from CSS instead.
1510  const sibWidth = parseFloat(sibStyle.width) || 0;
1511  const sibHeight = parseFloat(sibStyle.height) || 0;
1512
1513  const iconChild = sibling.querySelector('svg, i[data-lucide], i[class*="fa-"], i[class*="icon"]');
1514  let iconWidth = 0;
1515  if (iconChild) {
1516    const iconStyle = window.getComputedStyle(iconChild);
1517    iconWidth = parseFloat(iconStyle.width) || parseFloat(iconChild.getAttribute('width')) || 0;
1518  }
1519  // Or: tile contains an emoji/symbol character directly as its only content
1520  const sibDirectText = [...sibling.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
1521  const hasInlineEmojiIcon = sibling.children.length === 0 && isEmojiOnlyText(sibDirectText);
1522
1523  return checkIconTile({
1524    headingTag: tag,
1525    headingText: el.textContent || '',
1526    headingTop: 0, // jsdom: no layout, skip vertical-stacking gate
1527    siblingTag: sibling.tagName.toLowerCase(),
1528    siblingWidth: sibWidth,
1529    siblingHeight: sibHeight,
1530    siblingBottom: 0,
1531    siblingBgColor: parseRgb(sibStyle.backgroundColor),
1532    siblingBgImage: sibStyle.backgroundImage || '',
1533    siblingBorderWidth: parseFloat(sibStyle.borderTopWidth) || 0,
1534    siblingBorderRadius: resolveBorderRadiusPx(sibling, sibStyle, sibWidth, window),
1535    hasIconChild: !!iconChild || hasInlineEmojiIcon,
1536    iconChildWidth: iconWidth,
1537  });
1538}
1539
1540function checkElementItalicSerif(el, style, tag) {
1541  if (tag !== 'h1' && tag !== 'h2') return [];
1542  return checkItalicSerif({
1543    tag,
1544    fontStyle: style.fontStyle || '',
1545    fontFamily: style.fontFamily || '',
1546    fontSize: parseFloat(style.fontSize) || 0,
1547    headingText: el.textContent || '',
1548  });
1549}
1550
1551function checkElementHeroEyebrow(el, style, tag, window, customPropMap) {
1552  if (tag !== 'h1') return [];
1553  const sibling = el.previousElementSibling;
1554  if (!sibling) return [];
1555  const sibStyle = window.getComputedStyle(sibling);
1556  // Resolve Tailwind v4 CSS-variable wrappers (font-weight:var(--font-weight-bold)
1557  // etc.) before parsing. jsdom returns these verbatim from getComputedStyle;
1558  // without resolution every style-based gate fails silently on Tailwind v4 builds.
1559  const fontSizeRaw = customPropMap ? resolveVarRefs(sibStyle.fontSize, customPropMap) : sibStyle.fontSize;
1560  const fontWeightRaw = customPropMap ? resolveVarRefs(sibStyle.fontWeight, customPropMap) : sibStyle.fontWeight;
1561  const letterSpacingRaw = customPropMap ? resolveVarRefs(sibStyle.letterSpacing, customPropMap) : sibStyle.letterSpacing;
1562  const colorRaw = customPropMap ? resolveVarRefs(sibStyle.color, customPropMap) : sibStyle.color;
1563  const headingFontSizeRaw = customPropMap ? resolveVarRefs(style.fontSize, customPropMap) : style.fontSize;
1564  const siblingFontSize = parseFloat(fontSizeRaw) || 0;
1565  // resolveLengthPx returns null for 'normal' / 'auto'; coerce to 0 so the
1566  // gate falls through cleanly. jsdom returns letter-spacing verbatim
1567  // (e.g. '0.15em'), unlike real browsers, so this conversion is required.
1568  return checkHeroEyebrow({
1569    headingTag: tag,
1570    headingText: el.textContent || '',
1571    headingFontSize: parseFloat(headingFontSizeRaw) || 0,
1572    siblingTag: sibling.tagName.toLowerCase(),
1573    siblingText: sibling.textContent || '',
1574    siblingTextTransform: sibStyle.textTransform || '',
1575    siblingFontSize,
1576    siblingLetterSpacing: resolveLengthPx(letterSpacingRaw, siblingFontSize) || 0,
1577    siblingFontWeight: fontWeightRaw || '',
1578    siblingColor: colorRaw || '',
1579  });
1580}
1581
1582function checkRepeatedSectionKickersFromDoc(doc, win) {
1583  const candidates = collectRepeatedSectionKickerCandidates(
1584    doc,
1585    (el) => win.getComputedStyle(el),
1586    (value, fontSize) => resolveLengthPx(value, fontSize) || 0,
1587  );
1588  return checkRepeatedSectionKickers({ candidates });
1589}
1590
1591function checkElementMotion(tag, style) {
1592  return checkMotion({
1593    tag,
1594    transitionProperty: style.transitionProperty || '',
1595    animationName: style.animationName || '',
1596    timingFunctions: [style.animationTimingFunction, style.transitionTimingFunction].filter(Boolean).join(' '),
1597    classList: '',
1598  });
1599}
1600
1601function checkElementGlow(tag, style, effectiveBg) {
1602  if (!style.boxShadow || style.boxShadow === 'none') return [];
1603  return checkGlow({ tag, boxShadow: style.boxShadow, effectiveBg });
1604}
1605
1606// ─── Section 6: Page-Level Checks ───────────────────────────────────────────
1607
1608// Browser page-level checks — use document/getComputedStyle globals
1609
1610function checkTypography() {
1611  const findings = [];
1612
1613  // Walk actual text-bearing elements and tally font usage by *computed style*.
1614  // This is much more accurate than scanning CSS rules — it ignores rules that
1615  // exist in the stylesheet but apply to nothing (e.g. demo classes showing
1616  // anti-patterns), and counts what the user actually sees.
1617  const fontUsage = new Map(); // primary font name → count of elements
1618  let totalTextElements = 0;
1619  for (const el of document.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, td, th, dd, blockquote, figcaption, a, button, label, span')) {
1620    // Skip impeccable's own elements
1621    if (el.closest && el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue;
1622    // Only count elements that actually have visible direct text
1623    const hasText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
1624    if (!hasText) continue;
1625    const style = getComputedStyle(el);
1626    const ff = style.fontFamily;
1627    if (!ff) continue;
1628    const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
1629    const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
1630    if (!primary) continue;
1631    fontUsage.set(primary, (fontUsage.get(primary) || 0) + 1);
1632    totalTextElements++;
1633  }
1634
1635  if (totalTextElements >= 20) {
1636    // A font is "primary" if it's used by at least 15% of text elements
1637    const PRIMARY_THRESHOLD = 0.15;
1638    for (const [font, count] of fontUsage) {
1639      const share = count / totalTextElements;
1640      if (share < PRIMARY_THRESHOLD) continue;
1641      if (!OVERUSED_FONTS.has(font)) continue;
1642      if (isBrandFontOnOwnDomain(font)) continue;
1643      findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
1644    }
1645
1646    // Single-font check: only one distinct primary font across all text
1647    if (fontUsage.size === 1) {
1648      const only = [...fontUsage.keys()][0];
1649      findings.push({ type: 'single-font', detail: `only font used is ${only}` });
1650    }
1651  }
1652
1653  const sizes = new Set();
1654  for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) {
1655    const fs = parseFloat(getComputedStyle(el).fontSize);
1656    if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10);
1657  }
1658  if (sizes.size >= 3) {
1659    const sorted = [...sizes].sort((a, b) => a - b);
1660    const ratio = sorted[sorted.length - 1] / sorted[0];
1661    if (ratio < 2.0) {
1662      findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` });
1663    }
1664  }
1665
1666  return findings;
1667}
1668
1669function isCardLikeDOM(el) {
1670  const tag = el.tagName.toLowerCase();
1671  if (SAFE_TAGS.has(tag) || ['input','select','textarea','img','video','canvas','picture'].includes(tag)) return false;
1672  const style = getComputedStyle(el);
1673  const cls = el.getAttribute('class') || '';
1674  const hasShadow = (style.boxShadow && style.boxShadow !== 'none') || /\bshadow(?:-sm|-md|-lg|-xl|-2xl)?\b/.test(cls);
1675  const hasBorder = /\bborder\b/.test(cls);
1676  const hasRadius = parseFloat(style.borderRadius) > 0 || /\brounded(?:-sm|-md|-lg|-xl|-2xl|-full)?\b/.test(cls);
1677  const hasBg = (style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)') || /\bbg-(?:white|gray-\d+|slate-\d+)\b/.test(cls);
1678  return isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg);
1679}
1680
1681function checkLayout() {
1682  const findings = [];
1683  const flaggedEls = new Set();
1684
1685  for (const el of document.querySelectorAll('*')) {
1686    if (!isCardLikeDOM(el) || flaggedEls.has(el)) continue;
1687    const cls = el.getAttribute('class') || '';
1688    const style = getComputedStyle(el);
1689    if (style.position === 'absolute' || style.position === 'fixed') continue;
1690    if (/\b(?:dropdown|popover|tooltip|menu|modal|dialog)\b/i.test(cls)) continue;
1691    if ((el.textContent?.trim().length || 0) < 10) continue;
1692    const rect = el.getBoundingClientRect();
1693    if (rect.width < 50 || rect.height < 30) continue;
1694
1695    let parent = el.parentElement;
1696    while (parent) {
1697      if (isCardLikeDOM(parent)) { flaggedEls.add(el); break; }
1698      parent = parent.parentElement;
1699    }
1700  }
1701
1702  for (const el of flaggedEls) {
1703    let isAncestor = false;
1704    for (const other of flaggedEls) {
1705      if (other !== el && el.contains(other)) { isAncestor = true; break; }
1706    }
1707    if (!isAncestor) findings.push({ type: 'nested-cards', detail: 'Card inside card', el });
1708  }
1709
1710  return findings;
1711}
1712
1713// Node page-level checks — take document/window as parameters
1714
1715function checkPageTypography(doc, win) {
1716  const findings = [];
1717
1718  const fonts = new Set();
1719  const overusedFound = new Set();
1720
1721  for (const sheet of doc.styleSheets) {
1722    let rules;
1723    try { rules = sheet.cssRules || sheet.rules; } catch { continue; }
1724    if (!rules) continue;
1725    for (const rule of rules) {
1726      if (rule.type !== 1) continue;
1727      const ff = rule.style?.fontFamily;
1728      if (!ff) continue;
1729      const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
1730      const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
1731      if (primary) {
1732        fonts.add(primary);
1733        if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
1734      }
1735    }
1736  }
1737
1738  // Check Google Fonts links in HTML
1739  const html = doc.documentElement?.outerHTML || '';
1740  const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
1741  let m;
1742  while ((m = gfRe.exec(html)) !== null) {
1743    const families = m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase());
1744    for (const f of families) {
1745      fonts.add(f);
1746      if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
1747    }
1748  }
1749
1750  // Also parse raw HTML/style content for font-family (jsdom may not expose all via CSSOM)
1751  const ffRe = /font-family\s*:\s*([^;}]+)/gi;
1752  let fm;
1753  while ((fm = ffRe.exec(html)) !== null) {
1754    for (const f of fm[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) {
1755      if (f && !GENERIC_FONTS.has(f)) {
1756        fonts.add(f);
1757        if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
1758      }
1759    }
1760  }
1761
1762  for (const font of overusedFound) {
1763    findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` });
1764  }
1765
1766  // Single font
1767  if (fonts.size === 1) {
1768    const els = doc.querySelectorAll('*');
1769    if (els.length >= 20) {
1770      findings.push({ id: 'single-font', snippet: `only font used is ${[...fonts][0]}` });
1771    }
1772  }
1773
1774  // Flat type hierarchy
1775  const sizes = new Set();
1776  const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div');
1777  for (const el of textEls) {
1778    const fontSize = parseFloat(win.getComputedStyle(el).fontSize);
1779    // Filter out sub-8px values (jsdom doesn't resolve relative units properly)
1780    if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10);
1781  }
1782  if (sizes.size >= 3) {
1783    const sorted = [...sizes].sort((a, b) => a - b);
1784    const ratio = sorted[sorted.length - 1] / sorted[0];
1785    if (ratio < 2.0) {
1786      findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` });
1787    }
1788  }
1789
1790  return findings;
1791}
1792
1793function isCardLike(el, win) {
1794  const tag = el.tagName.toLowerCase();
1795  if (SAFE_TAGS.has(tag) || ['input', 'select', 'textarea', 'img', 'video', 'canvas', 'picture'].includes(tag)) return false;
1796
1797  const style = win.getComputedStyle(el);
1798  const rawStyle = el.getAttribute?.('style') || '';
1799  const cls = el.getAttribute?.('class') || '';
1800
1801  const hasShadow = (style.boxShadow && style.boxShadow !== 'none') ||
1802    /\bshadow(?:-sm|-md|-lg|-xl|-2xl)?\b/.test(cls) || /box-shadow/i.test(rawStyle);
1803  const hasBorder = /\bborder\b/.test(cls);
1804  const widthPx = parseFloat(style.width) || 0;
1805  const hasRadius = resolveBorderRadiusPx(el, style, widthPx, win) > 0 ||
1806    /\brounded(?:-sm|-md|-lg|-xl|-2xl|-full)?\b/.test(cls) || /border-radius/i.test(rawStyle);
1807  const hasBg = /\bbg-(?:white|gray-\d+|slate-\d+)\b/.test(cls) ||
1808    /background(?:-color)?\s*:\s*(?!transparent)/i.test(rawStyle);
1809
1810  return isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg);
1811}
1812
1813function checkPageLayout(doc, win) {
1814  const findings = [];
1815
1816  // Nested cards
1817  const allEls = doc.querySelectorAll('*');
1818  const flaggedEls = new Set();
1819  for (const el of allEls) {
1820    if (!isCardLike(el, win)) continue;
1821    if (flaggedEls.has(el)) continue;
1822
1823    const tag = el.tagName.toLowerCase();
1824    const cls = el.getAttribute?.('class') || '';
1825    const rawStyle = el.getAttribute?.('style') || '';
1826
1827    if (['pre', 'code'].includes(tag)) continue;
1828    if (/\b(?:absolute|fixed)\b/.test(cls) || /position\s*:\s*(?:absolute|fixed)/i.test(rawStyle)) continue;
1829    if ((el.textContent?.trim().length || 0) < 10) continue;
1830    if (/\b(?:dropdown|popover|tooltip|menu|modal|dialog)\b/i.test(cls)) continue;
1831
1832    // Walk up to find card-like ancestor
1833    let parent = el.parentElement;
1834    while (parent) {
1835      if (isCardLike(parent, win)) {
1836        flaggedEls.add(el);
1837        break;
1838      }
1839      parent = parent.parentElement;
1840    }
1841  }
1842
1843  // Only report innermost nested cards
1844  for (const el of flaggedEls) {
1845    let isAncestorOfFlagged = false;
1846    for (const other of flaggedEls) {
1847      if (other !== el && el.contains(other)) {
1848        isAncestorOfFlagged = true;
1849        break;
1850      }
1851    }
1852    if (!isAncestorOfFlagged) {
1853      findings.push({ id: 'nested-cards', snippet: `Card inside card (${el.tagName.toLowerCase()})` });
1854    }
1855  }
1856
1857  // Everything centered
1858  const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, li, div, button');
1859  let centeredCount = 0;
1860  let totalText = 0;
1861  for (const el of textEls) {
1862    const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length >= 3);
1863    if (!hasDirectText) continue;
1864    totalText++;
1865
1866    let cur = el;
1867    let isCentered = false;
1868    while (cur && cur.nodeType === 1) {
1869      const rawStyle = cur.getAttribute?.('style') || '';
1870      const cls = cur.getAttribute?.('class') || '';
1871      if (/text-align\s*:\s*center/i.test(rawStyle) || /\btext-center\b/.test(cls)) {
1872        isCentered = true;
1873        break;
1874      }
1875      if (cur.tagName === 'BODY') break;
1876      cur = cur.parentElement;
1877    }
1878    if (isCentered) centeredCount++;
1879  }
1880
1881  if (totalText >= 5 && centeredCount / totalText > 0.7) {
1882    findings.push({
1883      id: 'everything-centered',
1884      snippet: `${centeredCount}/${totalText} text elements centered (${Math.round(centeredCount / totalText * 100)}%)`,
1885    });
1886  }
1887
1888  return findings;
1889}
1890
1891export {
1892  checkBorders,
1893  isEmojiOnlyText,
1894  checkColors,
1895  isCardLikeFromProps,
1896  checkIconTile,
1897  resolveSerif,
1898  checkItalicSerif,
1899  isAccentColor,
1900  checkHeroEyebrow,
1901  checkRepeatedSectionKickers,
1902  checkMotion,
1903  checkGlow,
1904  checkHtmlPatterns,
1905  readOwnBackgroundColor,
1906  resolveBackground,
1907  resolveGradientStops,
1908  parseRadiusToPx,
1909  resolveBorderRadiusPx,
1910  checkElementBordersDOM,
1911  checkElementColorsDOM,
1912  checkElementIconTileDOM,
1913  checkElementItalicSerifDOM,
1914  checkElementHeroEyebrowDOM,
1915  buildCustomPropMap,
1916  resolveVarRefs,
1917  oklchToRgb,
1918  parseAnyColor,
1919  parseColorResolved,
1920  cleanInlineText,
1921  isRepeatedKickerCandidate,
1922  collectRepeatedSectionKickerCandidates,
1923  checkRepeatedSectionKickersDOM,
1924  checkElementMotionDOM,
1925  checkElementGlowDOM,
1926  checkElementAIPaletteDOM,
1927  resolveFontSizePx,
1928  resolveLengthPx,
1929  checkQuality,
1930  checkElementQualityDOM,
1931  checkPageQualityFromDoc,
1932  checkPageQualityDOM,
1933  checkElementQuality,
1934  checkElementBorders,
1935  checkElementColors,
1936  checkElementIconTile,
1937  checkElementItalicSerif,
1938  checkElementHeroEyebrow,
1939  checkRepeatedSectionKickersFromDoc,
1940  checkElementMotion,
1941  checkElementGlow,
1942  checkTypography,
1943  isCardLikeDOM,
1944  checkLayout,
1945  checkPageTypography,
1946  isCardLike,
1947  checkPageLayout,
1948};