Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
One-time setup that gathers your project's design context and saves it to CLAUDE.md for future sessions.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/detector/rules/checks.mjs
1import {2BORDER_SAFE_TAGS,3GENERIC_FONTS,4KNOWN_SERIF_FONTS,5OVERUSED_FONTS,6SAFE_TAGS,7WCAG_LARGE_BOLD_TEXT_PX,8WCAG_LARGE_TEXT_PX,9isBrandFontOnOwnDomain,10} from '../shared/constants.mjs';11import {12colorToHex,13contrastRatio,14getHue,15hasChroma,16isNeutralColor,17parseGradientColors,18parseRgb,19relativeLuminance,20} from '../shared/color.mjs';2122const DETECTOR_IS_BROWSER = typeof window !== 'undefined';2324// ─── Section 3: Pure Detection ──────────────────────────────────────────────2526function checkBorders(tag, widths, colors, radius) {27if (BORDER_SAFE_TAGS.has(tag)) return [];28const findings = [];29const sides = ['Top', 'Right', 'Bottom', 'Left'];3031for (const side of sides) {32const w = widths[side];33if (w < 1 || isNeutralColor(colors[side])) continue;3435const otherSides = sides.filter(s => s !== side);36const maxOther = Math.max(...otherSides.map(s => widths[s]));37if (!(w >= 2 && (maxOther <= 1 || w >= maxOther * 2))) continue;3839const sn = side.toLowerCase();40const isSide = side === 'Left' || side === 'Right';4142if (isSide) {43if (radius > 0) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` });44else if (w >= 3) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px` });45} else {46if (radius > 0 && w >= 2) findings.push({ id: 'border-accent-on-rounded', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` });47}48}4950return findings;51}5253// Returns true if the given text is composed entirely of emoji characters54// (plus whitespace / variation selectors). Emojis render as multicolor glyphs55// regardless of CSS `color`, so contrast checks against the element's text56// 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) {60if (!text) return false;61if (!EMOJI_CHAR_RE.test(text)) return false;62return text.replace(EMOJI_CHARS_GLOBAL, '').trim() === '';63}6465function checkColors(opts) {66const { tag, textColor, bgColor, effectiveBg, effectiveBgStops, fontSize, fontWeight, hasDirectText, isEmojiOnly, bgClip, bgImage, classList } = opts;67if (SAFE_TAGS.has(tag)) {68// Exception for <a> and <button> elements styled as buttons. SAFE_TAGS69// exists to suppress contrast noise on inline links and unstyled controls,70// where the element has no own background and the contrast against the71// ancestor surface is already the intended visual. When the element has72// its own opaque background and direct text, it is a styled button — and73// contrast on its own surface is a real, frequent bug worth flagging.74const isStyledButton = (tag === 'a' || tag === 'button')75&& hasDirectText76&& bgColor && bgColor.a > 0.5;77if (!isStyledButton) return [];78}79const findings = [];8081if (hasDirectText && textColor && !isEmojiOnly) {82// Run background-dependent checks against either a solid bg or, if the83// ancestor is a gradient, against every gradient stop (use the worst case).84const bgs = effectiveBg ? [effectiveBg] : (effectiveBgStops && effectiveBgStops.length ? effectiveBgStops : null);85if (bgs) {86// Gray on colored background — flag if every stop is chromatic87const textLum = relativeLuminance(textColor);88const isGray = !hasChroma(textColor, 20) && textLum > 0.05 && textLum < 0.85;89if (isGray && bgs.every(b => hasChroma(b, 40))) {90const bgLabel = effectiveBg ? colorToHex(effectiveBg) : `gradient(${bgs.map(colorToHex).join(', ')})`;91findings.push({ id: 'gray-on-color', snippet: `text ${colorToHex(textColor)} on bg ${bgLabel}` });92}9394// Low contrast (WCAG AA) — worst case across all bg stops95const ratios = bgs.map(b => contrastRatio(textColor, b));96let worstIdx = 0;97for (let i = 1; i < ratios.length; i++) if (ratios[i] < ratios[worstIdx]) worstIdx = i;98const ratio = ratios[worstIdx];99const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700);100const threshold = isLargeText ? 3.0 : 4.5;101if (ratio < threshold) {102// Skip the false-positive class where text has alpha < 1 AND we103// couldn't find an opaque ancestor (effectiveBg is null, we're104// comparing against gradient-stop fallback). In jsdom mode the105// detector can't resolve `var(--X)` color tokens, so a dark106// section sitting between the text and the body's decorative107// gradient is invisible to us — we end up measuring contrast108// against the body's paper-grain noise instead of the real109// local bg. Real low-contrast bugs use alpha=1 and have a110// resolvable opaque ancestor; semi-transparent Tailwind tokens111// like `text-paper/60` on `bg-ink` sections are the FP pattern.112const isAlphaFallbackFP = !DETECTOR_IS_BROWSER && !effectiveBg && (textColor.a != null && textColor.a < 1);113if (!isAlphaFallbackFP) {114findings.push({ id: 'low-contrast', snippet: `${ratio.toFixed(1)}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(bgs[worstIdx])}` });115}116}117}118119// AI palette: purple/violet on headings120if (hasChroma(textColor, 50)) {121const hue = getHue(textColor);122if (hue >= 260 && hue <= 310 && (['h1', 'h2', 'h3'].includes(tag) || fontSize >= 20)) {123findings.push({ id: 'ai-color-palette', snippet: `Purple/violet text (${colorToHex(textColor)}) on heading` });124}125}126}127128// Gradient text129if (bgClip === 'text' && bgImage && bgImage.includes('gradient')) {130findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });131}132133// Tailwind class checks134if (classList) {135const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');136137const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);138const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);139if (grayMatch && colorBgMatch) {140findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });141}142143if (/\bbg-clip-text\b/.test(classStr) && /\bbg-gradient-to-/.test(classStr)) {144findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' });145}146147const purpleText = classStr.match(/\btext-(?:purple|violet|indigo)-\d+\b/);148if (purpleText && (['h1', 'h2', 'h3'].includes(tag) || /\btext-(?:[2-9]xl)\b/.test(classStr))) {149findings.push({ id: 'ai-color-palette', snippet: `${purpleText[0]} on heading` });150}151152if (/\bfrom-(?:purple|violet|indigo)-\d+\b/.test(classStr) && /\bto-(?:purple|violet|indigo|blue|cyan|pink|fuchsia)-\d+\b/.test(classStr)) {153findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet gradient (Tailwind)' });154}155}156157return findings;158}159160function isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg) {161if (!hasShadow && !hasBorder) return false;162return hasRadius || hasBg;163}164165const HEADING_TAGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);166167// Pure check: given a heading and metrics about its previousElementSibling,168// decide if the sibling is the canonical "icon-tile-stacked-above-heading" shape.169//170// Triggers when ALL of the following hold for the sibling:171// • size 32–128px on both axes (not too small, not a hero image)172// • aspect ratio 0.7–1.4 (squarish — excludes wide thumbnails / pill badges)173// • has a non-transparent background-color, background-image, OR a visible border174// (covers solid colors, white-with-border, gradients — anything that visually175// defines a tile)176// • border-radius < width/2 (excludes round avatars; rounded squares pass)177// • contains an <svg> or icon-class <i> element that's smaller than the tile178// • the tile sits above the heading (its bottom is above the heading's top)179function checkIconTile(opts) {180const { headingTag, headingText, headingTop,181siblingTag, siblingWidth, siblingHeight, siblingBottom,182siblingBgColor, siblingBgImage, siblingBorderWidth, siblingBorderRadius,183hasIconChild, iconChildWidth } = opts;184if (!HEADING_TAGS.has(headingTag)) return [];185if (!siblingTag) return [];186// Don't recurse into nested headings (e.g. h2 above h3 in a section header)187if (HEADING_TAGS.has(siblingTag)) return [];188189// Size window: 32–128px on each axis190if (!(siblingWidth >= 32 && siblingWidth <= 128)) return [];191if (!(siblingHeight >= 32 && siblingHeight <= 128)) return [];192193// Squarish aspect ratio194const ratio = siblingWidth / siblingHeight;195if (ratio < 0.7 || ratio > 1.4) return [];196197// Must have something that visually defines the tile198const bgVisible = (siblingBgColor && siblingBgColor.a > 0.1)199|| (siblingBgImage && siblingBgImage !== 'none' && siblingBgImage !== '');200const borderVisible = siblingBorderWidth > 0;201if (!bgVisible && !borderVisible) return [];202203// Exclude circles (avatars). Rounded squares pass.204if (siblingBorderRadius >= siblingWidth / 2) return [];205206// Must contain an icon element smaller than the tile207if (!hasIconChild) return [];208if (iconChildWidth && iconChildWidth >= siblingWidth * 0.95) return [];209210// Vertical stacking: tile must end above where the heading starts.211// (Allow the check to skip when both top/bottom are 0 — jsdom layout case.)212if (headingTop && siblingBottom && siblingBottom > headingTop + 4) return [];213214const text = (headingText || '').trim().slice(0, 60);215return [{216id: 'icon-tile-stack',217snippet: `${Math.round(siblingWidth)}x${Math.round(siblingHeight)}px icon tile above ${headingTag} "${text}"`,218}];219}220221// Resolve the primary (non-generic) face from a font-family string and return222// whether the resolved primary is serif. Two paths:223// 1. Primary face is in KNOWN_SERIF_FONTS → serif.224// 2. Primary face is unknown but the stack ends in the generic `serif`225// token → treat as serif. Authors who declare `font-family: 'X', serif`226// almost always have a serif primary; a sans declared with a serif227// fallback is a code smell, not the common case.228// Returns { primary, isSerif } so the snippet can name the face.229function resolveSerif(fontFamily) {230if (!fontFamily) return { primary: null, isSerif: false };231const tokens = fontFamily.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());232const primary = tokens.find(f => f && !GENERIC_FONTS.has(f)) || null;233if (!primary) return { primary: null, isSerif: false };234if (KNOWN_SERIF_FONTS.has(primary)) return { primary, isSerif: true };235if (tokens.includes('serif')) return { primary, isSerif: true };236return { primary, isSerif: false };237}238239function checkItalicSerif(opts) {240const { tag, fontStyle, fontFamily, fontSize, headingText } = opts;241if (fontStyle !== 'italic') return [];242// Anchor the rule on hero-scale text. h1 is the canonical hero element;243// h2 ≥ 48px catches the cases where the design demotes the visual hero244// to an h2 but keeps the size.245if (tag !== 'h1' && !(tag === 'h2' && fontSize >= 48)) return [];246if (fontSize < 48) return [];247const { primary, isSerif } = resolveSerif(fontFamily);248if (!isSerif) return [];249250const text = (headingText || '').trim().slice(0, 60);251return [{252id: 'italic-serif-display',253snippet: `italic serif ${tag} (${primary || 'serif'}) at ${Math.round(fontSize)}px "${text}"`,254}];255}256257// Color saturation check. Returns true when the color has visible258// chroma — i.e., it's an "accent color" rather than near-neutral.259// Handles rgb()/rgba(), #hex, oklch(), and hsl(). var() refs are260// expected to be pre-resolved by the caller.261function isAccentColor(cssColor) {262if (!cssColor) return false;263const s = String(cssColor).trim();264// rgb / rgba — direct channel-distance check.265const rgbM = /rgba?\(\s*(\d+)\s*,?\s+|\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(s.replace(/rgba?\(\s*/, 'rgb(').replace(/,/g, ', '));266const rgbStrict = /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(s);267if (rgbStrict) {268const r = +rgbStrict[1], g = +rgbStrict[2], b = +rgbStrict[3];269return (Math.max(r, g, b) - Math.min(r, g, b)) >= 40;270}271// #hex — 3, 4, 6, or 8 digit.272const hexM = /^#([0-9a-f]{3,8})\b/i.exec(s);273if (hexM) {274let h = hexM[1];275if (h.length === 3 || h.length === 4) h = h.split('').map((c) => c + c).join('').slice(0, 6);276else h = h.slice(0, 6);277if (h.length === 6) {278const r = parseInt(h.slice(0, 2), 16);279const g = parseInt(h.slice(2, 4), 16);280const b = parseInt(h.slice(4, 6), 16);281return (Math.max(r, g, b) - Math.min(r, g, b)) >= 40;282}283}284// oklch(L C H) — chroma C is what matters. Typical neutral grays285// have C < 0.02; visible accents are 0.05+. CSS minification can286// collapse spaces between L% and C ("oklch(43%.15 34)"), so we287// extract all numbers and take the second rather than matching a288// strict L-then-whitespace-then-C pattern.289if (/^oklch\(/i.test(s)) {290const nums = s.match(/\d*\.\d+|\d+/g);291if (nums && nums.length >= 2) {292const c = parseFloat(nums[1]);293return !Number.isNaN(c) && c >= 0.05;294}295}296// hsl(H, S%, L%) — saturation > 20% reads as accent.297const hslM = /hsla?\(\s*[\d.]+\s*,\s*([\d.]+)%/i.exec(s);298if (hslM) {299const sat = parseFloat(hslM[1]);300return !Number.isNaN(sat) && sat >= 20;301}302return false;303}304305// Sibling-relationship rule. Anchor on a hero-scale h1, look at the306// previousElementSibling, and gate on EITHER the classic tracked-307// uppercase eyebrow OR the modern accent-colored bold eyebrow.308function checkHeroEyebrow(opts) {309const {310headingTag, headingText, headingFontSize,311siblingTag, siblingText, siblingTextTransform,312siblingFontSize, siblingLetterSpacing,313siblingFontWeight, siblingColor,314} = opts;315if (headingTag !== 'h1') return [];316// We previously gated on headingFontSize >= 48 to anchor "hero scale".317// But modern hero h1s use clamp() / vw / var(--text-*), none of which318// jsdom can resolve — the computed value comes back as "2em" or319// "var(--text-9xl)" and parseFloat returns 2 or NaN. The gate fails320// on virtually every Tailwind v4 / framework build. The other gates321// (sibling text 2-60 chars, font-size ≤ 14px, accent-bold OR322// tracked-caps) are tight enough to avoid false positives on non-323// hero h1s — a tiny tan label directly above any h1 is the324// antipattern regardless of how big the h1 ends up.325if (!siblingTag) return [];326// An h2 above an h1 is a different anti-pattern (heading hierarchy / dual327// headings) — never an eyebrow.328if (HEADING_TAGS.has(siblingTag)) return [];329330const text = (siblingText || '').trim();331if (text.length < 2 || text.length > 60) return [];332if (!(siblingFontSize > 0 && siblingFontSize <= 14)) return [];333334// Branch A: classic tracked-uppercase eyebrow.335const isUppercased = siblingTextTransform === 'uppercase'336|| (/[A-Z]/.test(text) && !/[a-z]/.test(text));337const isClassicTracked = isUppercased && siblingLetterSpacing >= 1.6;338339// Branch B: modern accent-bold eyebrow — sentence case, low340// tracking, but bold + accent-colored. The style choices changed;341// the pattern is the same kicker-above-headline anti-pattern.342const weight = Number(siblingFontWeight) || 400;343const isAccentBold = weight >= 700 && isAccentColor(siblingColor || '');344345if (!isClassicTracked && !isAccentBold) return [];346347const headingTextSnippet = (headingText || '').trim().slice(0, 60);348const eyebrowSnippet = text.slice(0, 40);349const style = isClassicTracked ? 'tracked-caps' : 'accent-bold';350return [{351id: 'hero-eyebrow-chip',352snippet: `eyebrow chip (${style}) "${eyebrowSnippet}" above ${headingTag} "${headingTextSnippet}"`,353}];354}355356function checkRepeatedSectionKickers(opts) {357const { candidates, minCount = 3 } = opts;358if (!Array.isArray(candidates) || candidates.length < minCount) return [];359return candidates.map(candidate => ({360id: 'repeated-section-kickers',361snippet: `repeated section kicker "${candidate.kickerText}" before ${candidate.headingTag} "${candidate.headingText}" (${candidates.length} on page)`,362}));363}364365const LAYOUT_TRANSITION_PROPS = new Set([366'width', 'height', 'padding', 'margin',367'max-height', 'max-width', 'min-height', 'min-width',368'padding-top', 'padding-right', 'padding-bottom', 'padding-left',369'margin-top', 'margin-right', 'margin-bottom', 'margin-left',370]);371372function checkMotion(opts) {373const { tag, transitionProperty, animationName, timingFunctions, classList } = opts;374if (SAFE_TAGS.has(tag)) return [];375const findings = [];376377// --- Bounce/elastic easing ---378if (animationName && animationName !== 'none' && /bounce|elastic|wobble|jiggle|spring/i.test(animationName)) {379findings.push({ id: 'bounce-easing', snippet: `animation: ${animationName}` });380}381if (classList && /\banimate-bounce\b/.test(classList)) {382findings.push({ id: 'bounce-easing', snippet: 'animate-bounce (Tailwind)' });383}384385// Check timing functions for overshoot cubic-bezier (y values outside [0, 1])386if (timingFunctions) {387const bezierRe = /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g;388let m;389while ((m = bezierRe.exec(timingFunctions)) !== null) {390const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]);391if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) {392findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${m[1]}, ${m[2]}, ${m[3]}, ${m[4]})` });393break;394}395}396}397398// --- Layout property transition ---399if (transitionProperty && transitionProperty !== 'all' && transitionProperty !== 'none') {400const props = transitionProperty.split(',').map(p => p.trim().toLowerCase());401const layoutFound = props.filter(p => LAYOUT_TRANSITION_PROPS.has(p));402if (layoutFound.length > 0) {403findings.push({ id: 'layout-transition', snippet: `transition: ${layoutFound.join(', ')}` });404}405}406407return findings;408}409410function checkGlow(opts) {411const { boxShadow, effectiveBg } = opts;412if (!boxShadow || boxShadow === 'none') return [];413if (!effectiveBg) return [];414415// Only flag on dark backgrounds (luminance < 0.1)416const bgLum = relativeLuminance(effectiveBg);417if (bgLum >= 0.1) return [];418419// Split multiple shadows (commas not inside parentheses)420const parts = boxShadow.split(/,(?![^(]*\))/);421for (const shadow of parts) {422const colorMatch = shadow.match(/rgba?\([^)]+\)/);423if (!colorMatch) continue;424const color = parseRgb(colorMatch[0]);425if (!color || !hasChroma(color, 30)) continue;426427// Extract px values — in computed style: "color Xpx Ypx BLURpx [SPREADpx]"428const afterColor = shadow.substring(shadow.indexOf(colorMatch[0]) + colorMatch[0].length);429const beforeColor = shadow.substring(0, shadow.indexOf(colorMatch[0]));430const pxVals = [...beforeColor.matchAll(/([\d.]+)px/g), ...afterColor.matchAll(/([\d.]+)px/g)]431.map(m => parseFloat(m[1]));432433// Third value is blur (offset-x, offset-y, blur, [spread])434if (pxVals.length >= 3 && pxVals[2] > 4) {435return [{ id: 'dark-glow', snippet: `Colored glow (${colorToHex(color)}) on dark background` }];436}437}438439return [];440}441442/**443* Regex-on-HTML checks shared between browser and Node page-level detection.444* These don't need DOM access, just the raw HTML string.445*/446function checkHtmlPatterns(html) {447const findings = [];448449// --- Color ---450451// AI color palette: purple/violet452const purpleHexRe = /#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9|6366f1|764ba2|667eea)\b/gi;453if (purpleHexRe.test(html)) {454const purpleTextRe = /(?:(?:^|;)\s*color\s*:\s*(?:.*?)(?:#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9))|gradient.*?#(?:7c3aed|8b5cf6|a855f7|764ba2|667eea))/gi;455if (purpleTextRe.test(html)) {456findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected' });457}458}459460// Gradient text (background-clip: text + gradient)461const gradientRe = /(?:-webkit-)?background-clip\s*:\s*text/gi;462let gm;463while ((gm = gradientRe.exec(html)) !== null) {464const start = Math.max(0, gm.index - 200);465const context = html.substring(start, gm.index + gm[0].length + 200);466if (/gradient/i.test(context)) {467findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });468break;469}470}471if (/\bbg-clip-text\b/.test(html) && /\bbg-gradient-to-/.test(html)) {472findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' });473}474475// --- Layout ---476477// Monotonous spacing478const spacingValues = [];479const spacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*(\d+)px/gi;480let sm;481while ((sm = spacingRe.exec(html)) !== null) {482const v = parseInt(sm[1], 10);483if (v > 0 && v < 200) spacingValues.push(v);484}485const gapRe = /gap\s*:\s*(\d+)px/gi;486while ((sm = gapRe.exec(html)) !== null) {487spacingValues.push(parseInt(sm[1], 10));488}489const twSpaceRe = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+)\b/g;490while ((sm = twSpaceRe.exec(html)) !== null) {491spacingValues.push(parseInt(sm[1], 10) * 4);492}493const remSpacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*([\d.]+)rem/gi;494while ((sm = remSpacingRe.exec(html)) !== null) {495const v = Math.round(parseFloat(sm[1]) * 16);496if (v > 0 && v < 200) spacingValues.push(v);497}498const roundedSpacing = spacingValues.map(v => Math.round(v / 4) * 4);499if (roundedSpacing.length >= 10) {500const counts = {};501for (const v of roundedSpacing) counts[v] = (counts[v] || 0) + 1;502const maxCount = Math.max(...Object.values(counts));503const dominantPct = maxCount / roundedSpacing.length;504const unique = [...new Set(roundedSpacing)].filter(v => v > 0);505if (dominantPct > 0.6 && unique.length <= 3) {506const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];507findings.push({508id: 'monotonous-spacing',509snippet: `~${dominant}px used ${maxCount}/${roundedSpacing.length} times (${Math.round(dominantPct * 100)}%)`,510});511}512}513514// --- Motion ---515516// Bounce/elastic animation names517const bounceRe = /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi;518const bounceMatch = bounceRe.exec(html);519if (bounceMatch) {520const animationToken = bounceMatch[1]521.split(/[,\s]+/)522.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));523findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}` });524}525526// Overshoot cubic-bezier527const bezierRe = /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g;528let bm;529while ((bm = bezierRe.exec(html)) !== null) {530const y1 = parseFloat(bm[2]), y2 = parseFloat(bm[4]);531if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) {532findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})` });533break;534}535}536537// Layout property transitions538const transRe = /transition(?:-property)?\s*:\s*([^;{}]+)/gi;539let tm;540while ((tm = transRe.exec(html)) !== null) {541const val = tm[1].toLowerCase();542if (/\ball\b/.test(val)) continue;543const found = val.match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);544if (found) {545findings.push({ id: 'layout-transition', snippet: `transition: ${found.join(', ')}` });546break;547}548}549550// --- Dark glow ---551552const 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;553const twDarkBg = /\bbg-(?:gray|slate|zinc|neutral|stone)-(?:9\d{2}|800)\b/;554if (darkBgRe.test(html) || twDarkBg.test(html)) {555const shadowRe = /box-shadow\s*:\s*([^;{}]+)/gi;556let shm;557while ((shm = shadowRe.exec(html)) !== null) {558const val = shm[1];559const colorMatch = val.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);560if (!colorMatch) continue;561const [r, g, b] = [+colorMatch[1], +colorMatch[2], +colorMatch[3]];562if ((Math.max(r, g, b) - Math.min(r, g, b)) < 30) continue;563const pxVals = [...val.matchAll(/(\d+)px|(?<![.\d])\b(0)\b(?![.\d])/g)].map(p => +(p[1] || p[2]));564if (pxVals.length >= 3 && pxVals[2] > 4) {565findings.push({ id: 'dark-glow', snippet: `Colored glow (rgb(${r},${g},${b})) on dark page` });566break;567}568}569}570571// --- Provider tells (gated): repeating-gradient stripes (GPT) ---572if (/repeating-(?:linear|radial|conic)-gradient\s*\(/i.test(html)) {573findings.push({ id: 'repeating-stripes-gradient', snippet: 'repeating-gradient decorative stripes' });574}575576// --- Provider tells (gated): "X theater" framing copy (GPT) ---577// Lives here (regex-on-HTML) rather than in the text-content analyzers so it578// runs in the bundled browser path too, not just the CLI/static path.579{580const bodyText = html581.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ')582.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ')583.replace(/<[^>]+>/g, ' ');584const tm = /\b(\w+)\s+theater\b/i.exec(bodyText);585if (tm) findings.push({ id: 'theater-slop-phrase', snippet: `"${tm[0].trim()}"` });586}587588// --- Provider tells (gated): image hover transform (Gemini) ---589// A CSS `img...:hover { transform: ... }` rule, or a Tailwind hover:scale /590// hover:rotate / hover:translate utility on an <img>. Each distinct591// mechanism is its own finding.592const imgHoverCss = /\bimg\b[^,{}]*:hover\b[^{}]*\{[^}]*\btransform\s*:\s*(?:scale|rotate|translate|matrix|skew)/i;593if (imgHoverCss.test(html)) {594findings.push({ id: 'image-hover-transform', snippet: 'img:hover { transform } rule' });595}596const imgTagRe = /<img\b[^>]*\bclass\s*=\s*"([^"]*)"/gi;597let im;598while ((im = imgTagRe.exec(html)) !== null) {599if (/\bhover:(?:scale|rotate|translate|skew)-/.test(im[1])) {600findings.push({ id: 'image-hover-transform', snippet: 'Tailwind hover transform on <img>' });601}602}603604return findings;605}606607// ─── Section 4: resolveBackground (unified) ─────────────────────────────────608609// Read the element's own background color, computed-style first, with a610// jsdom-friendly fallback that parses the inline `background:` shorthand611// from the raw style attribute. jsdom (~v29) does not decompose the612// shorthand into `backgroundColor`, so without this fallback the CLI silently613// returns null for any element styled via `background: rgb(...)` or614// `background: #abc`. Real browsers always decompose, so the fallback is615// a no-op there.616function readOwnBackgroundColor(el, computedStyle) {617const bg = parseRgb(computedStyle.backgroundColor);618if (DETECTOR_IS_BROWSER || (bg && bg.a >= 0.1)) return bg;619const rawStyle = el.getAttribute?.('style') || '';620const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);621const inlineBg = bgMatch ? bgMatch[1].trim() : '';622if (!inlineBg) return bg;623if (/gradient/i.test(inlineBg) || /url\s*\(/i.test(inlineBg)) return bg;624const fromRgb = parseRgb(inlineBg);625if (fromRgb) return fromRgb;626const hexMatch = inlineBg.match(/#([0-9a-f]{6}|[0-9a-f]{3})\b/i);627if (hexMatch) {628const h = hexMatch[1];629if (h.length === 6) {630return { r: parseInt(h.slice(0, 2), 16), g: parseInt(h.slice(2, 4), 16), b: parseInt(h.slice(4, 6), 16), a: 1 };631}632return { r: parseInt(h[0] + h[0], 16), g: parseInt(h[1] + h[1], 16), b: parseInt(h[2] + h[2], 16), a: 1 };633}634return bg;635}636637function resolveBackground(el, win, customPropMap) {638let current = el;639while (current && current.nodeType === 1) {640const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);641const bgImage = style.backgroundImage || '';642const hasGradientOrUrl = bgImage && bgImage !== 'none' && (/gradient/i.test(bgImage) || /url\s*\(/i.test(bgImage));643644// Try the solid bg-color FIRST. If the element has both a solid color645// and a gradient/url overlay (a common pattern: `background: var(--paper)646// radial-gradient(...)` for paper-grain texture), the solid color is the647// dominant visible surface for contrast purposes; the overlay is648// decorative. The old behavior bailed on any gradient ancestor, which649// caused massive false-positive contrast findings on grain-textured650// body backgrounds.651let bg = parseRgb(style.backgroundColor);652if (!DETECTOR_IS_BROWSER && (!bg || bg.a < 0.1)) {653// jsdom returns literal "var(--X)" / "oklch(...)" strings. Resolve654// through customPropMap so Tailwind v4 color tokens become RGB.655if (customPropMap) {656bg = parseColorResolved(style.backgroundColor, customPropMap);657}658if (!bg || bg.a < 0.1) {659// Inline-style fallback. jsdom doesn't decompose background660// shorthand, so colors set via inline style are otherwise invisible.661const rawStyle = current.getAttribute?.('style') || '';662const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);663const inlineBg = bgMatch ? bgMatch[1].trim() : '';664if (inlineBg && !/gradient/i.test(inlineBg) && !/url\s*\(/i.test(inlineBg)) {665bg = parseColorResolved(inlineBg, customPropMap) || parseAnyColor(inlineBg);666}667}668}669670if (bg && bg.a > 0.1) {671if (DETECTOR_IS_BROWSER || bg.a >= 0.5) return bg;672}673// No solid bg-color at this level. If THIS level has a gradient/url674// with no underlying solid color we can read:675// • on body/html: assume white. Body-level gradients are almost676// always decorative texture (paper grain, noise) on top of a677// solid bg-color the page set via `background: var(--paper)`678// shorthand — which jsdom can't decompose into bg-color. The679// downstream gradient-stops fallback path produces catastrophic680// false positives in this case (gradient noise stops have681// accidental browns/blacks that look like card backgrounds).682// • on other elements: bail to null and let the caller fall back683// to gradient stops (gradient buttons / hero sections are real684// bgs worth checking against).685if (hasGradientOrUrl) {686if (current.tagName === 'BODY' || current.tagName === 'HTML') {687return { r: 255, g: 255, b: 255, a: 1 };688}689return null;690}691current = current.parentElement;692}693return { r: 255, g: 255, b: 255 };694}695696// Walk parents looking for a gradient background and return its color stops.697// Used as a fallback when resolveBackground() returns null because the698// effective background is a gradient (no single solid color to compare against).699function resolveGradientStops(el, win) {700let current = el;701while (current && current.nodeType === 1) {702const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);703const bgImage = style.backgroundImage || '';704if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {705const stops = parseGradientColors(bgImage);706if (stops.length > 0) return stops;707}708if (!DETECTOR_IS_BROWSER) {709// jsdom doesn't decompose `background:` shorthand — peek at the raw inline style710const rawStyle = current.getAttribute?.('style') || '';711const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);712if (bgMatch && /gradient/i.test(bgMatch[1])) {713const stops = parseGradientColors(bgMatch[1]);714if (stops.length > 0) return stops;715}716}717current = current.parentElement;718}719return null;720}721722// Parse a single CSS length token to pixels. Accepts "12px", "50%", a723// shorthand like "12px 4px" (uses the first value), or empty / null.724// Returns the pixel value, or null when the input is unparseable.725// Percentages convert against `widthPx` when one is supplied. Without a726// usable width (jsdom returns "auto" for many real-world elements,727// which parseFloat collapses to 0), fall back to the raw percentage728// number so callers gating on `> 0` (border-accent-on-rounded,729// isCardLike's hasRadius) still see a positive value, matching the730// original parseFloat("50%") === 50 behavior.731function parseRadiusToPx(value, widthPx) {732if (!value || typeof value !== 'string') return null;733const trimmed = value.trim();734if (!trimmed) return null;735const first = trimmed.split(/\s+/)[0];736const num = parseFloat(first);737if (Number.isNaN(num)) return null;738if (/%$/.test(first)) {739if (widthPx && widthPx > 0) return (num / 100) * widthPx;740return num;741}742return num;743}744745function resolveBorderRadiusPx(el, style, widthPx, win) {746const fromComputed = parseRadiusToPx(style.borderRadius, widthPx);747if (fromComputed !== null) return fromComputed;748return 0;749}750751// ─── Section 5: Element Adapters ────────────────────────────────────────────752753// Browser adapters — call getComputedStyle/getBoundingClientRect on live DOM754755function checkElementBordersDOM(el) {756const tag = el.tagName.toLowerCase();757if (BORDER_SAFE_TAGS.has(tag)) return [];758const rect = el.getBoundingClientRect();759if (rect.width < 20 || rect.height < 20) return [];760const style = getComputedStyle(el);761const sides = ['Top', 'Right', 'Bottom', 'Left'];762const widths = {}, colors = {};763for (const s of sides) {764widths[s] = parseFloat(style[`border${s}Width`]) || 0;765colors[s] = style[`border${s}Color`] || '';766}767return checkBorders(tag, widths, colors, parseFloat(style.borderRadius) || 0);768}769770function checkElementColorsDOM(el) {771const tag = el.tagName.toLowerCase();772// No early SAFE_TAGS bail here — checkColors() does its own gating that773// includes the styled-button exception for <a> / <button> with their own774// opaque background. Bailing here would prevent that exception from firing.775const rect = el.getBoundingClientRect();776if (rect.width < 10 || rect.height < 10) return [];777const style = getComputedStyle(el);778const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');779const hasDirectText = directText.trim().length > 0;780const effectiveBg = resolveBackground(el);781return checkColors({782tag,783textColor: parseRgb(style.color),784bgColor: readOwnBackgroundColor(el, style),785effectiveBg,786effectiveBgStops: effectiveBg ? null : resolveGradientStops(el),787fontSize: parseFloat(style.fontSize) || 16,788fontWeight: parseInt(style.fontWeight) || 400,789hasDirectText,790isEmojiOnly: isEmojiOnlyText(directText),791bgClip: style.webkitBackgroundClip || style.backgroundClip || '',792bgImage: style.backgroundImage || '',793classList: el.getAttribute('class') || '',794});795}796797function checkElementIconTileDOM(el) {798const tag = el.tagName.toLowerCase();799if (!HEADING_TAGS.has(tag)) return [];800const sibling = el.previousElementSibling;801if (!sibling) return [];802803const sibRect = sibling.getBoundingClientRect();804const headRect = el.getBoundingClientRect();805const sibStyle = getComputedStyle(sibling);806807// The tile may either contain an <svg>/<i> icon child, OR the tile itself808// may contain an emoji/symbol character directly as its only text content809// (the "card-icon" pattern from many AI-generated demos).810const iconChild = sibling.querySelector('svg, i[data-lucide], i[class*="fa-"], i[class*="icon"]');811const iconRect = iconChild?.getBoundingClientRect();812const sibDirectText = [...sibling.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');813const hasInlineEmojiIcon = sibling.children.length === 0 && isEmojiOnlyText(sibDirectText);814815return checkIconTile({816headingTag: tag,817headingText: el.textContent || '',818headingTop: headRect.top,819siblingTag: sibling.tagName.toLowerCase(),820siblingWidth: sibRect.width,821siblingHeight: sibRect.height,822siblingBottom: sibRect.bottom,823siblingBgColor: parseRgb(sibStyle.backgroundColor),824siblingBgImage: sibStyle.backgroundImage || '',825siblingBorderWidth: parseFloat(sibStyle.borderTopWidth) || 0,826siblingBorderRadius: parseFloat(sibStyle.borderRadius) || 0,827hasIconChild: !!iconChild || hasInlineEmojiIcon,828iconChildWidth: iconRect?.width || 0,829});830}831832function checkElementItalicSerifDOM(el) {833const tag = el.tagName.toLowerCase();834if (tag !== 'h1' && tag !== 'h2') return [];835const style = getComputedStyle(el);836return checkItalicSerif({837tag,838fontStyle: style.fontStyle || '',839fontFamily: style.fontFamily || '',840fontSize: parseFloat(style.fontSize) || 0,841headingText: el.textContent || '',842});843}844845function checkElementHeroEyebrowDOM(el) {846const tag = el.tagName.toLowerCase();847if (tag !== 'h1') return [];848const sibling = el.previousElementSibling;849if (!sibling) return [];850const headStyle = getComputedStyle(el);851const sibStyle = getComputedStyle(sibling);852return checkHeroEyebrow({853headingTag: tag,854headingText: el.textContent || '',855headingFontSize: parseFloat(headStyle.fontSize) || 0,856siblingTag: sibling.tagName.toLowerCase(),857siblingText: sibling.textContent || '',858siblingTextTransform: sibStyle.textTransform || '',859siblingFontSize: parseFloat(sibStyle.fontSize) || 0,860siblingLetterSpacing: parseFloat(sibStyle.letterSpacing) || 0,861siblingFontWeight: sibStyle.fontWeight || '',862siblingColor: sibStyle.color || '',863});864}865866// Build a map of CSS custom properties declared on :root / :host / html.867// Used to resolve var(--X) refs that jsdom returns verbatim in868// getComputedStyle. Tailwind v4 routes every utility class through869// CSS vars (font-weight: var(--font-weight-bold), font-size:870// var(--text-xs), letter-spacing: var(--tracking-widest)), so without871// resolution every style-based check silently fails on Tailwind v4872// builds — the values come back as literal "var(--font-weight-bold)"873// strings and parseFloat returns NaN.874function buildCustomPropMap(document) {875const map = new Map();876let sheets;877try { sheets = Array.from(document.styleSheets || []); }878catch { return map; }879for (const sheet of sheets) {880let rules;881try { rules = Array.from(sheet.cssRules || []); }882catch { continue; }883for (const rule of rules) {884// Style rules only (type 1). Walk @media / @supports if present.885if (rule.type === 4 /* MEDIA_RULE */ || rule.type === 12 /* SUPPORTS_RULE */) {886try { rules.push(...Array.from(rule.cssRules || [])); } catch { /* ignore */ }887continue;888}889if (rule.type !== 1 /* STYLE_RULE */) continue;890const sel = rule.selectorText || '';891if (!/(^|,\s*)(:root|html|:host)\b/i.test(sel)) continue;892const style = rule.style;893if (!style) continue;894for (let i = 0; i < style.length; i++) {895const prop = style[i];896if (!prop || !prop.startsWith('--')) continue;897const val = style.getPropertyValue(prop).trim();898if (val) map.set(prop, val);899}900}901}902return map;903}904905// Resolve var(--X[, fallback]) refs in a computed-style value string.906// Recurses up to 8 levels for chained refs (--a: var(--b)). Returns907// the original string when no refs are present or the chain doesn't908// resolve. Safe to call on already-resolved values.909function resolveVarRefs(raw, customPropMap, depth = 0) {910if (typeof raw !== 'string' || !raw.includes('var(')) return raw;911if (depth > 8) return raw;912return raw.replace(/var\(\s*(--[a-zA-Z0-9_-]+)\s*(?:,\s*([^)]+))?\)/g, (_m, name, fallback) => {913const v = customPropMap.get(name);914if (v != null) return resolveVarRefs(v, customPropMap, depth + 1);915return fallback ? resolveVarRefs(fallback.trim(), customPropMap, depth + 1) : _m;916});917}918919// OKLCH → sRGB conversion (Björn Ottosson's matrices). L in 0..1 (or %),920// C in 0..~0.4 typical, H in degrees. Returns clamped {r,g,b,a:1} in 0..255.921// Needed because jsdom doesn't compute oklch() values — getComputedStyle922// returns the literal "oklch(...)" string. Without this, the entire923// Tailwind v4 color palette (which is OKLCH-based) is invisible to the924// detector's contrast / color checks.925function oklchToRgb(L, C, H) {926const hRad = (H * Math.PI) / 180;927const a = C * Math.cos(hRad);928const b = C * Math.sin(hRad);929const l_ = L + 0.3963377774 * a + 0.2158037573 * b;930const m_ = L - 0.1055613458 * a - 0.0638541728 * b;931const s_ = L - 0.0894841775 * a - 1.2914855480 * b;932const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;933const rLin = 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc;934const gLin = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc;935const bLin = -0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc;936const enc = (x) => {937const c = Math.max(0, Math.min(1, x));938return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;939};940return {941r: Math.round(enc(rLin) * 255),942g: Math.round(enc(gLin) * 255),943b: Math.round(enc(bLin) * 255),944a: 1,945};946}947948// Extended color parser: rgb/rgba/hex/oklch. Returns null on no match.949// Use this when the input might be any CSS color form; use plain parseRgb950// when you only expect computed rgb() values from real browsers.951function parseAnyColor(s) {952if (!s || typeof s !== 'string') return null;953const str = s.trim();954if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;955let m;956m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+))?\s*\)/);957if (m) return { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: m[4] !== undefined ? +m[4] : 1 };958m = str.match(/^#([0-9a-f]{3,8})$/i);959if (m) {960const h = m[1];961if (h.length === 3 || h.length === 4) {962return {963r: parseInt(h[0] + h[0], 16),964g: parseInt(h[1] + h[1], 16),965b: parseInt(h[2] + h[2], 16),966a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,967};968}969if (h.length === 6 || h.length === 8) {970return {971r: parseInt(h.slice(0, 2), 16),972g: parseInt(h.slice(2, 4), 16),973b: parseInt(h.slice(4, 6), 16),974a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,975};976}977}978// OKLCH parser. Tailwind v4's CSS minifier squishes the space after979// `%` ("21.5%.02 50"), so the separator between L and C may be absent.980// Match L (with optional %), then C and H separated permissively.981m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);982if (m) {983const Lnum = parseFloat(m[1]);984const L = m[2] === '%' ? Lnum / 100 : Lnum;985const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));986if (m[5] !== undefined) {987const alpha = parseFloat(m[5]);988rgb.a = m[6] === '%' ? alpha / 100 : alpha;989}990return rgb;991}992return null;993}994995// Resolve var() refs in a color string (via customPropMap), then parse.996// Returns null on any failure. Used in jsdom-mode paths where997// getComputedStyle returns literal "var(--X)" or "oklch(...)" strings.998function parseColorResolved(str, customPropMap) {999if (!str) return null;1000const resolved = customPropMap ? resolveVarRefs(str, customPropMap) : str;1001return parseAnyColor(resolved);1002}10031004const REPEATED_KICKER_SKIP_SELECTOR = [1005'nav',1006'form',1007'table',1008'thead',1009'tbody',1010'tfoot',1011'figure',1012'figcaption',1013'ol',1014'ul',1015'li',1016'[role="navigation"]',1017'[aria-label*="breadcrumb" i]',1018'[class*="breadcrumb" i]',1019'[aria-hidden="true"]',1020'[data-impeccable-allow-kickers]',1021].join(',');10221023const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [1024'article',1025'button',1026'a',1027'li',1028'[role="listitem"]',1029'[role="option"]',1030].join(',');10311032function cleanInlineText(el) {1033return [...el.childNodes]1034.filter(n => n.nodeType === 3)1035.map(n => n.textContent)1036.join(' ')1037.replace(/\s+/g, ' ')1038.trim();1039}10401041function isRepeatedKickerCardContext(heading, kicker) {1042const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR);1043return Boolean(item && (!item.contains || item.contains(kicker)));1044}10451046function isRepeatedKickerCandidate(opts) {1047const {1048headingTag,1049headingText,1050headingFontSize,1051kickerTag,1052kickerText,1053kickerTextTransform,1054kickerFontSize,1055kickerLetterSpacing,1056} = opts;1057if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;1058if (!headingText || headingText.length < 3) return false;1059if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false;1060if (!(headingFontSize >= 20)) return false;1061if (!kickerTag || HEADING_TAGS.has(kickerTag)) return false;1062if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false;1063if (!kickerText || kickerText.length < 2 || kickerText.length > 34) return false;1064if (/^step\s*\d+/i.test(kickerText) || /^\d{1,2}$/.test(kickerText)) return false;10651066const isUppercased = kickerTextTransform === 'uppercase'1067|| (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText));1068if (!isUppercased) return false;1069if (!(kickerFontSize > 0 && kickerFontSize <= 14)) return false;1070const minTrackedSpacing = Math.max(1, kickerFontSize * 0.08);1071if (!(kickerLetterSpacing >= minTrackedSpacing)) return false;1072return true;1073}10741075function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpacing) {1076const candidates = [];1077for (const heading of doc.querySelectorAll('h2, h3, h4')) {1078if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;1079const kicker = heading.previousElementSibling;1080if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;1081if (isRepeatedKickerCardContext(heading, kicker)) continue;10821083const headingStyle = getStyle(heading);1084const kickerStyle = getStyle(kicker);1085const headingText = (heading.textContent || '').replace(/\s+/g, ' ').trim();1086const kickerText = cleanInlineText(kicker) || (kicker.textContent || '').replace(/\s+/g, ' ').trim();1087const headingFontSize = resolveLetterSpacing(headingStyle.fontSize || '', 16) || parseFloat(headingStyle.fontSize) || 0;1088const kickerFontSize = resolveLetterSpacing(kickerStyle.fontSize || '', 16) || parseFloat(kickerStyle.fontSize) || 0;1089const kickerLetterSpacing = resolveLetterSpacing(kickerStyle.letterSpacing || '', kickerFontSize);10901091if (!isRepeatedKickerCandidate({1092headingTag: heading.tagName.toLowerCase(),1093headingText,1094headingFontSize,1095kickerTag: kicker.tagName.toLowerCase(),1096kickerText,1097kickerTextTransform: kickerStyle.textTransform || '',1098kickerFontSize,1099kickerLetterSpacing,1100})) {1101continue;1102}11031104candidates.push({1105headingTag: heading.tagName.toLowerCase(),1106headingText: headingText.replace(/^"|"$/g, '').slice(0, 60),1107kickerText: kickerText.slice(0, 40),1108});1109}1110return candidates;1111}11121113function checkRepeatedSectionKickersDOM() {1114const candidates = collectRepeatedSectionKickerCandidates(1115document,1116(el) => getComputedStyle(el),1117(value, fontSize) => resolveLengthPx(value, fontSize) || 0,1118);1119return checkRepeatedSectionKickers({ candidates });1120}11211122function checkElementMotionDOM(el) {1123const tag = el.tagName.toLowerCase();1124if (SAFE_TAGS.has(tag)) return [];1125const style = getComputedStyle(el);1126return checkMotion({1127tag,1128transitionProperty: style.transitionProperty || '',1129animationName: style.animationName || '',1130timingFunctions: [style.animationTimingFunction, style.transitionTimingFunction].filter(Boolean).join(' '),1131classList: el.getAttribute('class') || '',1132});1133}11341135function checkElementGlowDOM(el) {1136const tag = el.tagName.toLowerCase();1137const style = getComputedStyle(el);1138if (!style.boxShadow || style.boxShadow === 'none') return [];1139// Use parent's background — glow radiates outward, so the surrounding context matters1140// If resolveBackground returns null (gradient), try to infer from the gradient colors1141let parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el);1142if (!parentBg) {1143// Gradient background — sample its colors to determine if it's dark1144let cur = el.parentElement;1145while (cur && cur.nodeType === 1) {1146const bgImage = getComputedStyle(cur).backgroundImage || '';1147const gradColors = parseGradientColors(bgImage);1148if (gradColors.length > 0) {1149// Average the gradient colors1150const avg = { r: 0, g: 0, b: 0 };1151for (const c of gradColors) { avg.r += c.r; avg.g += c.g; avg.b += c.b; }1152avg.r = Math.round(avg.r / gradColors.length);1153avg.g = Math.round(avg.g / gradColors.length);1154avg.b = Math.round(avg.b / gradColors.length);1155parentBg = avg;1156break;1157}1158cur = cur.parentElement;1159}1160}1161return checkGlow({ tag, boxShadow: style.boxShadow, effectiveBg: parentBg });1162}11631164function checkElementAIPaletteDOM(el) {1165const style = getComputedStyle(el);1166const findings = [];11671168// Check gradient backgrounds for purple/violet or cyan1169const bgImage = style.backgroundImage || '';1170const gradColors = parseGradientColors(bgImage);1171for (const c of gradColors) {1172if (hasChroma(c, 50)) {1173const hue = getHue(c);1174if (hue >= 260 && hue <= 310) {1175findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet gradient background' });1176break;1177}1178if (hue >= 160 && hue <= 200) {1179findings.push({ id: 'ai-color-palette', snippet: 'Cyan gradient background' });1180break;1181}1182}1183}11841185// Check for neon text (vivid cyan/purple color on dark background)1186const textColor = parseRgb(style.color);1187if (textColor && hasChroma(textColor, 80)) {1188const hue = getHue(textColor);1189const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);1190if (isAIPalette) {1191const parentBg = el.parentElement ? resolveBackground(el.parentElement) : null;1192// Also check gradient parents1193let effectiveBg = parentBg;1194if (!effectiveBg) {1195let cur = el.parentElement;1196while (cur && cur.nodeType === 1) {1197const gi = getComputedStyle(cur).backgroundImage || '';1198const gc = parseGradientColors(gi);1199if (gc.length > 0) {1200const avg = { r: 0, g: 0, b: 0 };1201for (const c of gc) { avg.r += c.r; avg.g += c.g; avg.b += c.b; }1202avg.r = Math.round(avg.r / gc.length);1203avg.g = Math.round(avg.g / gc.length);1204avg.b = Math.round(avg.b / gc.length);1205effectiveBg = avg;1206break;1207}1208cur = cur.parentElement;1209}1210}1211if (effectiveBg && relativeLuminance(effectiveBg) < 0.1) {1212const label = hue >= 260 ? 'Purple/violet' : 'Cyan';1213findings.push({ id: 'ai-color-palette', snippet: `${label} neon text on dark background` });1214}1215}1216}12171218return findings;1219}12201221const QUALITY_TEXT_TAGS = new Set(['p', 'li', 'td', 'th', 'dd', 'blockquote', 'figcaption']);12221223// Resolve a CSS font-size value to pixels by walking up the parent chain.1224// Browsers resolve em/rem/% to px in getComputedStyle, but jsdom returns the1225// specified value verbatim — so for the Node path we walk parents ourselves.1226function resolveFontSizePx(el, win) {1227const chain = []; // raw font-size strings, leaf → root1228let cur = el;1229while (cur && cur.nodeType === 1) {1230const fs = (win ? win.getComputedStyle(cur) : getComputedStyle(cur)).fontSize;1231chain.push(fs || '');1232cur = cur.parentElement;1233}1234// Walk root → leaf, resolving each value relative to its parent context.1235let px = 16; // root default1236for (let i = chain.length - 1; i >= 0; i--) {1237const v = chain[i];1238if (!v || v === 'inherit') continue;1239const num = parseFloat(v);1240if (isNaN(num)) continue;1241if (v.endsWith('px')) px = num;1242else if (v.endsWith('rem')) px = num * 16;1243else if (v.endsWith('em')) px = num * px;1244else if (v.endsWith('%')) px = (num / 100) * px;1245else px = num; // unitless — already resolved1246}1247return px;1248}12491250// Resolve a CSS length value (line-height, letter-spacing, etc.) given a1251// known font-size context. Returns null for "normal" / unparseable values.1252function resolveLengthPx(value, fontSizePx) {1253if (!value || value === 'normal' || value === 'auto' || value === 'inherit') return null;1254const num = parseFloat(value);1255if (isNaN(num)) return null;1256if (value.endsWith('px')) return num;1257if (value.endsWith('rem')) return num * 16;1258if (value.endsWith('em')) return num * fontSizePx;1259if (value.endsWith('%')) return (num / 100) * fontSizePx;1260// Unitless line-height = multiplier, return px equivalent1261return num * fontSizePx;1262}12631264function cssColorIsTransparent(value) {1265if (!value) return true;1266const str = String(value).trim().toLowerCase();1267if (!str || str === 'transparent' || str === 'rgba(0, 0, 0, 0)') return true;1268const parsed = parseAnyColor(str);1269if (parsed) return (parsed.a ?? 1) <= 0.05;1270return /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(str);1271}12721273function colorsNearlyMatch(a, b) {1274const ca = parseAnyColor(a);1275const cb = parseAnyColor(b);1276if (!ca || !cb) return false;1277const alphaDelta = Math.abs((ca.a ?? 1) - (cb.a ?? 1));1278const channelDelta = Math.max(1279Math.abs(ca.r - cb.r),1280Math.abs(ca.g - cb.g),1281Math.abs(ca.b - cb.b),1282);1283return alphaDelta <= 0.03 && channelDelta <= 3;1284}12851286function getComputedStyleFor(win, el) {1287if (win && typeof win.getComputedStyle === 'function') {1288try { return win.getComputedStyle(el); } catch {}1289}1290if (typeof getComputedStyle === 'function') {1291try { return getComputedStyle(el); } catch {}1292}1293return null;1294}12951296function hasVisibleBackgroundBoundary(style, el, win) {1297const bg = style?.backgroundColor || '';1298if (cssColorIsTransparent(bg)) return false;12991300let parent = el?.parentElement || null;1301while (parent) {1302const parentStyle = getComputedStyleFor(win, parent);1303const parentBg = parentStyle?.backgroundColor || '';1304if (!cssColorIsTransparent(parentBg)) {1305return !colorsNearlyMatch(bg, parentBg);1306}1307parent = parent.parentElement;1308}13091310return true;1311}13121313const TEXT_EDGE_TAGS = new Set(['A', 'BUTTON', 'CODE', 'DD', 'DT', 'FIGCAPTION', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'P', 'PRE', 'SPAN', 'TD', 'TH']);13141315function hasMeaningfulDirectText(node) {1316if (!node?.childNodes) return false;1317for (const child of node.childNodes) {1318if (child.nodeType === 3 && child.textContent.trim().length > 4) return true;1319}1320return false;1321}13221323function textDescendantsFlushSides(el, rect) {1324const flush = { top: false, right: false, bottom: false, left: false };1325if (!rect || !el?.querySelectorAll) return flush;1326const TEXT_EDGE_THRESHOLD = 4;1327const candidates = el.querySelectorAll('a, button, code, dd, dt, figcaption, h1, h2, h3, h4, h5, h6, li, p, pre, span, td, th');1328for (const node of candidates) {1329if (!TEXT_EDGE_TAGS.has(node.tagName) || !hasMeaningfulDirectText(node)) continue;1330let nodeRect = null;1331try { nodeRect = node.getBoundingClientRect(); } catch {}1332if (!nodeRect || nodeRect.width <= 0 || nodeRect.height <= 0) continue;1333if (nodeRect.bottom < rect.top || nodeRect.top > rect.bottom || nodeRect.right < rect.left || nodeRect.left > rect.right) continue;1334if (nodeRect.top - rect.top <= TEXT_EDGE_THRESHOLD) flush.top = true;1335if (rect.right - nodeRect.right <= TEXT_EDGE_THRESHOLD) flush.right = true;1336if (rect.bottom - nodeRect.bottom <= TEXT_EDGE_THRESHOLD) flush.bottom = true;1337if (nodeRect.left - rect.left <= TEXT_EDGE_THRESHOLD) flush.left = true;1338}1339return flush;1340}13411342// Pure quality checks. Most run on computed CSS and DOM-only inputs (work in1343// jsdom and the browser). Two checks (line-length, cramped-padding) gate on1344// element rect dimensions, which jsdom can't compute — pass `rect: null` from1345// the Node adapter to skip those.1346//1347// Both adapters resolve font-size, line-height and letter-spacing to pixels1348// before calling this so the pure function only deals with numbers.1349function checkQuality(opts) {1350const { el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax = 80, viewportWidth = 0, win = null } = opts;1351const findings = [];1352// Skip browser extension injected elements1353const elId = el.id || '';1354if (elId.startsWith('claude-') || elId.startsWith('cic-')) return findings;13551356// --- Line length too long --- (browser-only: needs rect.width)1357if (rect && hasDirectText && QUALITY_TEXT_TAGS.has(tag) && rect.width > 0 && textLen > lineMax) {1358const charsPerLine = rect.width / (fontSize * 0.5);1359if (charsPerLine > lineMax + 5) {1360findings.push({ id: 'line-length', snippet: `~${Math.round(charsPerLine)} chars/line (aim for <${lineMax})` });1361}1362}13631364// --- Cramped padding --- (browser-only: needs rect to skip small badges/labels)1365// Vertical and horizontal thresholds are independent because line-height1366// already provides built-in vertical breathing room (the line box is taller1367// than the cap height), but horizontal has no equivalent. Both scale with1368// font-size — bigger text demands proportionally more padding.1369// vertical: max(4px, fontSize × 0.3)1370// horizontal: max(8px, fontSize × 0.5)1371const isInlineCode = tag === 'code' && !(el.closest && el.closest('pre'));1372if (!isInlineCode && rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) {1373const borders = {1374top: parseFloat(style.borderTopWidth) || 0,1375right: parseFloat(style.borderRightWidth) || 0,1376bottom: parseFloat(style.borderBottomWidth) || 0,1377left: parseFloat(style.borderLeftWidth) || 0,1378};1379const borderCount = Object.values(borders).filter(w => w > 0).length;1380const hasBg = hasVisibleBackgroundBoundary(style, el, win);1381if (borderCount >= 2 || hasBg) {1382const vPads = [], hPads = [];1383if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0);1384if (hasBg || borders.bottom > 0) vPads.push(parseFloat(style.paddingBottom) || 0);1385if (hasBg || borders.left > 0) hPads.push(parseFloat(style.paddingLeft) || 0);1386if (hasBg || borders.right > 0) hPads.push(parseFloat(style.paddingRight) || 0);13871388const vMin = vPads.length ? Math.min(...vPads) : Infinity;1389const hMin = hPads.length ? Math.min(...hPads) : Infinity;1390const vThresh = Math.max(4, fontSize * 0.3);1391const hThresh = Math.max(8, fontSize * 0.5);13921393// Emit at most one finding per element — pick whichever axis is worse.1394if (vMin < vThresh) {1395findings.push({ id: 'cramped-padding', snippet: `${vMin}px vertical padding (need ≥${vThresh.toFixed(1)}px for ${fontSize}px text)` });1396} else if (hMin < hThresh) {1397findings.push({ id: 'cramped-padding', snippet: `${hMin}px horizontal padding (need ≥${hThresh.toFixed(1)}px for ${fontSize}px text)` });1398}1399}1400}14011402// --- Flush against a visible boundary ---1403// Fires when a container has a visible boundary (border, outline, OR a1404// non-transparent background) AND near-zero padding on the bounded1405// side(s) AND text-bearing children land flush against the boundary.1406//1407// Distinct from cramped-padding: that rule needs the element itself to1408// have direct text (hasDirectText). This rule targets the OPPOSITE1409// shape — a container with NO direct text, only children — which is1410// exactly what cramped-padding misses (a section wrapping a label +1411// list lands a free pass).1412//1413// The classic shape: agent writes `padding: 28px 0 0` shorthand on a1414// section that also has a border, zeroing horizontal padding so the1415// text-bearing children touch the side borders. Background and1416// outline count too: a colored card with zero padding has the same1417// visual failure mode.1418{1419const FLUSH_SKIP_TAGS = new Set(['HTML', 'BODY', 'MAIN', 'HEADER', 'FOOTER', 'NAV', 'ARTICLE', 'ASIDE', 'BUTTON', 'A', 'LABEL', 'SUMMARY', 'CODE', 'PRE', 'INPUT', 'TEXTAREA', 'SELECT', 'FORM', 'FIGURE', 'TABLE', 'TBODY', 'THEAD', 'TR', 'TD', 'TH']);1420const upperTag = tag ? tag.toUpperCase() : '';1421const elPosition = style.position || '';1422if (1423!FLUSH_SKIP_TAGS.has(upperTag) &&1424!hasDirectText &&1425!['fixed', 'absolute'].includes(elPosition) &&1426el.children && el.children.length > 01427) {1428const borderW = {1429top: parseFloat(style.borderTopWidth) || 0,1430right: parseFloat(style.borderRightWidth) || 0,1431bottom: parseFloat(style.borderBottomWidth) || 0,1432left: parseFloat(style.borderLeftWidth) || 0,1433};1434const borderVisible = {1435top: borderW.top > 0 && !cssColorIsTransparent(style.borderTopColor),1436right: borderW.right > 0 && !cssColorIsTransparent(style.borderRightColor),1437bottom: borderW.bottom > 0 && !cssColorIsTransparent(style.borderBottomColor),1438left: borderW.left > 0 && !cssColorIsTransparent(style.borderLeftColor),1439};1440// Outline detection. jsdom decomposes `border` shorthand into1441// border{Top,…}Width/Color but does NOT decompose `outline` —1442// the longhands come back empty when the value was set via the1443// shorthand. Fall back to parsing `style.outline` ourselves.1444let outlineW = parseFloat(style.outlineWidth) || 0;1445let outlineStyleVal = style.outlineStyle || '';1446let outlineColorVal = style.outlineColor || '';1447if (!outlineW && style.outline) {1448const wMatch = style.outline.match(/(\d+(?:\.\d+)?)\s*px/);1449if (wMatch) outlineW = parseFloat(wMatch[1]) || 0;1450if (!outlineStyleVal) {1451outlineStyleVal = /\b(solid|dashed|dotted|double|groove|ridge|inset|outset)\b/.test(style.outline) ? 'solid' : '';1452}1453if (!outlineColorVal) {1454const cMatch = style.outline.match(/(rgba?\([^)]+\)|#[0-9a-fA-F]{3,8}|[a-zA-Z]+)\s*$/);1455if (cMatch) outlineColorVal = cMatch[1];1456}1457}1458const outlineVisible = outlineW > 0 && !cssColorIsTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none';1459const bgVisible = hasVisibleBackgroundBoundary(style, el, win);14601461const anyVisible = borderVisible.top || borderVisible.right || borderVisible.bottom || borderVisible.left || outlineVisible || bgVisible;1462if (anyVisible) {1463// Resolve padding to px (jsdom returns raw "1.5rem" etc., not the1464// computed px value; parseFloat would strip the unit and treat1465// 1.5rem as 1.5px, false-flagging legitimate insets).1466const pad = {1467top: resolveLengthPx(style.paddingTop, fontSize) ?? 0,1468right: resolveLengthPx(style.paddingRight, fontSize) ?? 0,1469bottom: resolveLengthPx(style.paddingBottom, fontSize) ?? 0,1470left: resolveLengthPx(style.paddingLeft, fontSize) ?? 0,1471};1472const PAD_THRESHOLD = 2;1473// Children-insulate-this-side: a side is insulated if ANY direct1474// child has its own padding ≥ 4px on that side. Rationale: in1475// typical flow, only the first/last (or leftmost/rightmost)1476// children actually sit at the parent's edges. If even one of1477// them has its own padding, the visual flush is broken on that1478// side. Classic example: a column-flow card frame where the1479// top child (header) has padding-top:12 and the bottom child1480// (footer) has padding-bottom:8 — the parent's padding:0 doesn't1481// matter; nothing is actually flush. The `any-child-insulates`1482// heuristic accepts some false negatives (a card with one heavily1483// padded middle child won't flag) for far fewer false positives.1484const CHILD_INSULATE_THRESHOLD = 4;1485const childrenInsulate = { top: false, right: false, bottom: false, left: false };1486for (const child of el.children) {1487let childStyle = getComputedStyleFor(win, child);1488if (!childStyle) continue;1489const childPad = {1490top: resolveLengthPx(childStyle.paddingTop, fontSize) ?? 0,1491right: resolveLengthPx(childStyle.paddingRight, fontSize) ?? 0,1492bottom: resolveLengthPx(childStyle.paddingBottom, fontSize) ?? 0,1493left: resolveLengthPx(childStyle.paddingLeft, fontSize) ?? 0,1494};1495const childMargin = {1496top: resolveLengthPx(childStyle.marginTop, fontSize) ?? 0,1497right: resolveLengthPx(childStyle.marginRight, fontSize) ?? 0,1498bottom: resolveLengthPx(childStyle.marginBottom, fontSize) ?? 0,1499left: resolveLengthPx(childStyle.marginLeft, fontSize) ?? 0,1500};1501if (rect && typeof child.getBoundingClientRect === 'function') {1502try {1503const childRect = child.getBoundingClientRect();1504if (childRect && childRect.width > 0 && childRect.height > 0) {1505if (childRect.top - rect.top >= CHILD_INSULATE_THRESHOLD) childrenInsulate.top = true;1506if (rect.right - childRect.right >= CHILD_INSULATE_THRESHOLD) childrenInsulate.right = true;1507if (rect.bottom - childRect.bottom >= CHILD_INSULATE_THRESHOLD) childrenInsulate.bottom = true;1508if (childRect.left - rect.left >= CHILD_INSULATE_THRESHOLD) childrenInsulate.left = true;1509}1510} catch {}1511}1512for (const s of ['top', 'right', 'bottom', 'left']) {1513if (childPad[s] >= CHILD_INSULATE_THRESHOLD || childMargin[s] >= CHILD_INSULATE_THRESHOLD) {1514childrenInsulate[s] = true;1515}1516}1517}15181519const textFlush = rect ? textDescendantsFlushSides(el, rect) : null;1520const fullBleedBgBand = rect && viewportWidth > 0 && rect.width >= viewportWidth * 0.94 && bgVisible && !outlineVisible;1521const flushSides = [];1522for (const side of ['top', 'right', 'bottom', 'left']) {1523const bgBoundsSide = bgVisible && !(fullBleedBgBand && (side === 'left' || side === 'right'));1524const sideBounded = borderVisible[side] || outlineVisible || bgBoundsSide;1525if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side] && (!textFlush || textFlush[side])) {1526flushSides.push(side);1527}1528}15291530if (flushSides.length > 0) {1531// Confirm at least one direct child has substantial text content1532// (> 4 chars). Without this, the flush is harmless: e.g. an1533// image-only card.1534let hasTextChild = false;1535for (const child of el.children) {1536const childText = (child.textContent || '').trim();1537if (childText.length > 4) { hasTextChild = true; break; }1538}1539if (hasTextChild) {1540const cls = (typeof el.className === 'string' && el.className.trim())1541? el.className.trim().split(/\s+/)[0]1542: '';1543const boundaryParts = [];1544const borderSidesVisible = ['top', 'right', 'bottom', 'left'].filter(s => borderVisible[s]);1545if (borderSidesVisible.length === 4) boundaryParts.push('border');1546else if (borderSidesVisible.length > 0) boundaryParts.push(`border-${borderSidesVisible.join('/')}`);1547if (outlineVisible) boundaryParts.push('outline');1548if (bgVisible) boundaryParts.push('bg');1549const sidesLabel = flushSides.length === 4 ? 'all sides' : flushSides.join('/');1550const ident = cls1551? `<${tag.toLowerCase()}> "${cls}"`1552: `<${tag.toLowerCase()}>`;1553findings.push({1554id: 'cramped-padding',1555snippet: `${ident}: children flush against ${boundaryParts.join('+')} on ${sidesLabel} (no inset)`,1556});1557}1558}1559}1560}1561}15621563// --- Body text touching viewport edge --- (browser-only: needs rect)1564// Catches the failure mode where the agent ships body paragraphs1565// with NO container providing horizontal padding — text bleeds1566// directly to the viewport edge. Different from cramped-padding,1567// which requires a colored/bordered container. Here the failure1568// is the absence of the container entirely.1569//1570// Gate aggressively to avoid false positives:1571// - <p> or <li> only (body content; not headings, not nav, not1572// wrappers)1573// - text > 40 chars (paragraph-like, not a label)1574// - rect.width > 50% of viewport (real body, not a pull-quote)1575// - rect.left < 16 OR rect.right > viewport - 16 (actually1576// touching the edge)1577// - not inside <nav> or <header> (those legitimately bleed)1578// - element itself has no background-color (intentional full-bleed1579// sections set a bg-color and provide their own internal padding)1580if (rect && hasDirectText && textLen > 40 && ['P', 'LI'].includes(tag.toUpperCase()) && viewportWidth > 0) {1581const inNavHeader = el.closest && (el.closest('nav') || el.closest('header'));1582const hasOwnBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)' && style.backgroundColor !== 'transparent';1583const isPositioned = ['fixed', 'absolute'].includes(style.position || '');1584const widthRatio = rect.width / viewportWidth;1585const leftClose = rect.left < 16;1586const rightClose = rect.right > viewportWidth - 16;1587if (!inNavHeader && !hasOwnBg && !isPositioned && widthRatio > 0.5 && (leftClose || rightClose)) {1588const which = leftClose && rightClose1589? `left ${Math.round(rect.left)}px / right ${Math.round(viewportWidth - rect.right)}px`1590: leftClose1591? `left ${Math.round(rect.left)}px`1592: `right ${Math.round(viewportWidth - rect.right)}px`;1593findings.push({ id: 'body-text-viewport-edge', snippet: `<${tag.toLowerCase()}> with ${textLen}-char body bleeds to viewport edge (${which})` });1594}1595}15961597// --- Tight line height ---1598if (hasDirectText && textLen > 50 && !['h1','h2','h3','h4','h5','h6'].includes(tag)) {1599if (lineHeightPx != null && fontSize > 0) {1600const ratio = lineHeightPx / fontSize;1601if (ratio > 0 && ratio < 1.3) {1602findings.push({ id: 'tight-leading', snippet: `line-height ${ratio.toFixed(2)}x (need >=1.3)` });1603}1604}1605}16061607// --- Justified text (without hyphens) ---1608if (hasDirectText && style.textAlign === 'justify') {1609const hyphens = style.hyphens || style.webkitHyphens || '';1610if (hyphens !== 'auto') {1611findings.push({ id: 'justified-text', snippet: 'text-align: justify without hyphens: auto' });1612}1613}16141615// --- Tiny body text ---1616// Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.)1617if (hasDirectText && textLen > 20 && fontSize < 12) {1618const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption'];1619const inUIContext = el.closest && el.closest('button, a, label, summary, pre, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [aria-hidden="true"], [class*="badge" i], [class*="caption" i], [class*="chip" i], [class*="code" i], [class*="console" i], [class*="diff" i], [class*="label" i], [class*="meta" i], [class*="mock" i], [class*="pill" i], [class*="preview" i], [class*="tag" i], [class*="terminal" i], [class*="writes" i]');1620const isUppercase = style.textTransform === 'uppercase';1621if (!skipTags.includes(tag) && !inUIContext && !isUppercase) {1622findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` });1623}1624}16251626// --- All-caps body text ---1627if (hasDirectText && textLen > 30 && style.textTransform === 'uppercase') {1628if (!['h1','h2','h3','h4','h5','h6'].includes(tag)) {1629findings.push({ id: 'all-caps-body', snippet: `text-transform: uppercase on ${textLen} chars of body text` });1630}1631}16321633// --- Wide letter spacing on body text ---1634if (hasDirectText && textLen > 20 && style.textTransform !== 'uppercase') {1635if (letterSpacingPx != null && letterSpacingPx > 0 && fontSize > 0) {1636const trackingEm = letterSpacingPx / fontSize;1637if (trackingEm > 0.05) {1638findings.push({ id: 'wide-tracking', snippet: `letter-spacing: ${trackingEm.toFixed(2)}em on body text` });1639}1640}1641}16421643// --- Crushed letter spacing (mirror of wide-tracking) ---1644// Tracking pulled tighter than ~-0.05em crushes characters into each other.1645// Optical tightening that display type legitimately wants (around -0.02em)1646// stays well above this floor.1647if (hasDirectText && textLen > 20 && fontSize > 0) {1648if (letterSpacingPx != null && letterSpacingPx < 0) {1649const trackingEm = letterSpacingPx / fontSize;1650if (trackingEm <= -0.05) {1651const excerpt = (el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 40);1652findings.push({ id: 'extreme-negative-tracking', snippet: `letter-spacing: ${trackingEm.toFixed(2)}em — "${excerpt}"` });1653}1654}1655}16561657return findings;1658}16591660function checkElementQualityDOM(el) {1661const tag = el.tagName.toLowerCase();1662const style = getComputedStyle(el);1663const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 10);1664const textLen = el.textContent?.trim().length || 0;1665// Browser getComputedStyle resolves everything to px — direct parseFloat1666// works.1667const fontSize = parseFloat(style.fontSize) || 16;1668const lineHeightPx = resolveLengthPx(style.lineHeight, fontSize);1669const letterSpacingPx = resolveLengthPx(style.letterSpacing, fontSize);1670const rect = el.getBoundingClientRect();1671const lineMax = (typeof window !== 'undefined' && window.__IMPECCABLE_CONFIG__?.lineLengthMax) || 80;1672const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0;1673return checkQuality({ el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax, viewportWidth, win: typeof window !== 'undefined' ? window : null });1674}16751676// Pure page-level skipped-heading walk. Takes a Document so it works in both1677// the browser and jsdom.1678function checkPageQualityFromDoc(doc) {1679const findings = [];1680const headings = doc.querySelectorAll('h1, h2, h3, h4, h5, h6');1681let prevLevel = 0;1682let prevText = '';1683for (const h of headings) {1684const level = parseInt(h.tagName[1]);1685const text = (h.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 60);1686if (prevLevel > 0 && level > prevLevel + 1) {1687findings.push({1688id: 'skipped-heading',1689snippet: `<h${prevLevel}> "${prevText}" followed by <h${level}> "${text}" (missing h${prevLevel + 1})`,1690});1691}1692prevLevel = level;1693prevText = text;1694}1695return findings;1696}16971698// Browser adapter (returns the legacy { type, detail } shape used by the overlay loop)1699function checkPageQualityDOM() {1700return checkPageQualityFromDoc(document).map(f => ({ type: f.id, detail: f.snippet }));1701}17021703// Node adapters — take pre-extracted jsdom computed style17041705// jsdom doesn't lay out OR resolve em/rem/% to px — so we pre-resolve every1706// CSS length the rule needs ourselves (walking the parent chain for1707// font-size inheritance), and pass `rect: null` to skip the two rules that1708// genuinely need element rects (line-length, cramped-padding).1709function checkElementQuality(el, style, tag, window) {1710const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 10);1711const textLen = el.textContent?.trim().length || 0;1712const fontSize = resolveFontSizePx(el, window);1713const lineHeightPx = resolveLengthPx(style.lineHeight, fontSize);1714const letterSpacingPx = resolveLengthPx(style.letterSpacing, fontSize);1715return checkQuality({ el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect: null, win: window });1716}17171718function checkElementBorders(tag, style, overrides, resolvedRadius) {1719const sides = ['Top', 'Right', 'Bottom', 'Left'];1720const widths = {}, colors = {};1721for (const s of sides) {1722widths[s] = parseFloat(style[`border${s}Width`]) || 0;1723colors[s] = style[`border${s}Color`] || '';1724// jsdom silently drops any border shorthand containing var(), leaving1725// both width and color empty on the computed style. When the detectHtml1726// pre-pass pulled a resolved value off the rule, use it to fill in the1727// missing side so the side-tab check can run. Real browsers resolve1728// var() natively, so this fallback is a no-op in the browser path.1729if (widths[s] === 0 && overrides && overrides[s]) {1730widths[s] = overrides[s].width;1731colors[s] = overrides[s].color;1732} else if (colors[s] && colors[s].startsWith('var(') && overrides && overrides[s]) {1733// Longhand case: jsdom kept the width but left the color as the1734// literal `var(...)` string. Substitute the resolved color.1735colors[s] = overrides[s].color;1736}1737}1738// resolvedRadius lets the caller pre-resolve the radius via1739// resolveBorderRadiusPx so the value survives jsdom 29.1.0's broken1740// shorthand serialization. Falls back to the computed value for tests1741// and browser callers that don't pre-resolve.1742const radius = resolvedRadius != null1743? resolvedRadius1744: (parseFloat(style.borderRadius) || 0);1745return checkBorders(tag, widths, colors, radius);1746}17471748function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInheritRule) {1749const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');1750const hasDirectText = directText.trim().length > 0;17511752const effectiveBg = resolveBackground(el, window, customPropMap);1753// jsdom returns literal "var(--X)" / "oklch(...)" for color, so plain1754// parseRgb misses Tailwind-tokenized text colors. Resolve through the1755// customPropMap first; fall back to parseRgb for vanilla rgb() pages.1756let textColor = customPropMap ? parseColorResolved(style.color, customPropMap) : null;1757if (!textColor) textColor = parseRgb(style.color);17581759// Anchor-inherit FP workaround: jsdom's UA stylesheet has `:link { color:1760// blue }` at high specificity. The page's `a { color: inherit }` rule1761// (Tailwind v4 preflight) loses to jsdom even though it WINS in real1762// browsers (Chrome's UA wraps :link in :where() — zero specificity).1763// When the page declares the inherit rule AND we see jsdom's default1764// link blue on an anchor, walk to the nearest non-anchor ancestor and1765// use its color instead.1766if (1767hasAnchorInheritRule &&1768textColor &&1769textColor.r === 0 && textColor.g === 0 && textColor.b === 238 &&1770(tag === 'a' || el.closest?.('a'))1771) {1772let cur = el.parentElement;1773while (cur && cur.tagName !== 'HTML') {1774if (cur.tagName !== 'A') {1775const ps = window.getComputedStyle(cur);1776const inh = (customPropMap ? parseColorResolved(ps.color, customPropMap) : null) || parseRgb(ps.color);1777if (inh && !(inh.r === 0 && inh.g === 0 && inh.b === 238)) {1778textColor = inh;1779break;1780}1781}1782cur = cur.parentElement;1783}1784}17851786return checkColors({1787tag,1788textColor,1789bgColor: readOwnBackgroundColor(el, style),1790effectiveBg,1791effectiveBgStops: effectiveBg ? null : resolveGradientStops(el, window),1792fontSize: parseFloat(style.fontSize) || 16,1793fontWeight: parseInt(style.fontWeight) || 400,1794hasDirectText,1795isEmojiOnly: isEmojiOnlyText(directText),1796bgClip: style.webkitBackgroundClip || style.backgroundClip || '',1797bgImage: style.backgroundImage || '',1798classList: el.getAttribute?.('class') || el.className || '',1799});1800}18011802function checkElementIconTile(el, tag, window) {1803if (!HEADING_TAGS.has(tag)) return [];1804const sibling = el.previousElementSibling;1805if (!sibling) return [];18061807const sibStyle = window.getComputedStyle(sibling);1808// jsdom doesn't lay out — read explicit pixel dimensions from CSS instead.1809const sibWidth = parseFloat(sibStyle.width) || 0;1810const sibHeight = parseFloat(sibStyle.height) || 0;18111812const iconChild = sibling.querySelector('svg, i[data-lucide], i[class*="fa-"], i[class*="icon"]');1813let iconWidth = 0;1814if (iconChild) {1815const iconStyle = window.getComputedStyle(iconChild);1816iconWidth = parseFloat(iconStyle.width) || parseFloat(iconChild.getAttribute('width')) || 0;1817}1818// Or: tile contains an emoji/symbol character directly as its only content1819const sibDirectText = [...sibling.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');1820const hasInlineEmojiIcon = sibling.children.length === 0 && isEmojiOnlyText(sibDirectText);18211822return checkIconTile({1823headingTag: tag,1824headingText: el.textContent || '',1825headingTop: 0, // jsdom: no layout, skip vertical-stacking gate1826siblingTag: sibling.tagName.toLowerCase(),1827siblingWidth: sibWidth,1828siblingHeight: sibHeight,1829siblingBottom: 0,1830siblingBgColor: parseRgb(sibStyle.backgroundColor),1831siblingBgImage: sibStyle.backgroundImage || '',1832siblingBorderWidth: parseFloat(sibStyle.borderTopWidth) || 0,1833siblingBorderRadius: resolveBorderRadiusPx(sibling, sibStyle, sibWidth, window),1834hasIconChild: !!iconChild || hasInlineEmojiIcon,1835iconChildWidth: iconWidth,1836});1837}18381839function checkElementItalicSerif(el, style, tag) {1840if (tag !== 'h1' && tag !== 'h2') return [];1841return checkItalicSerif({1842tag,1843fontStyle: style.fontStyle || '',1844fontFamily: style.fontFamily || '',1845fontSize: parseFloat(style.fontSize) || 0,1846headingText: el.textContent || '',1847});1848}18491850function checkElementHeroEyebrow(el, style, tag, window, customPropMap) {1851if (tag !== 'h1') return [];1852const sibling = el.previousElementSibling;1853if (!sibling) return [];1854const sibStyle = window.getComputedStyle(sibling);1855// Resolve Tailwind v4 CSS-variable wrappers (font-weight:var(--font-weight-bold)1856// etc.) before parsing. jsdom returns these verbatim from getComputedStyle;1857// without resolution every style-based gate fails silently on Tailwind v4 builds.1858const fontSizeRaw = customPropMap ? resolveVarRefs(sibStyle.fontSize, customPropMap) : sibStyle.fontSize;1859const fontWeightRaw = customPropMap ? resolveVarRefs(sibStyle.fontWeight, customPropMap) : sibStyle.fontWeight;1860const letterSpacingRaw = customPropMap ? resolveVarRefs(sibStyle.letterSpacing, customPropMap) : sibStyle.letterSpacing;1861const colorRaw = customPropMap ? resolveVarRefs(sibStyle.color, customPropMap) : sibStyle.color;1862const headingFontSizeRaw = customPropMap ? resolveVarRefs(style.fontSize, customPropMap) : style.fontSize;1863const siblingFontSize = parseFloat(fontSizeRaw) || 0;1864// resolveLengthPx returns null for 'normal' / 'auto'; coerce to 0 so the1865// gate falls through cleanly. jsdom returns letter-spacing verbatim1866// (e.g. '0.15em'), unlike real browsers, so this conversion is required.1867return checkHeroEyebrow({1868headingTag: tag,1869headingText: el.textContent || '',1870headingFontSize: parseFloat(headingFontSizeRaw) || 0,1871siblingTag: sibling.tagName.toLowerCase(),1872siblingText: sibling.textContent || '',1873siblingTextTransform: sibStyle.textTransform || '',1874siblingFontSize,1875siblingLetterSpacing: resolveLengthPx(letterSpacingRaw, siblingFontSize) || 0,1876siblingFontWeight: fontWeightRaw || '',1877siblingColor: colorRaw || '',1878});1879}18801881function checkRepeatedSectionKickersFromDoc(doc, win) {1882const candidates = collectRepeatedSectionKickerCandidates(1883doc,1884(el) => win.getComputedStyle(el),1885(value, fontSize) => resolveLengthPx(value, fontSize) || 0,1886);1887return checkRepeatedSectionKickers({ candidates });1888}18891890function checkElementMotion(tag, style) {1891return checkMotion({1892tag,1893transitionProperty: style.transitionProperty || '',1894animationName: style.animationName || '',1895timingFunctions: [style.animationTimingFunction, style.transitionTimingFunction].filter(Boolean).join(' '),1896classList: '',1897});1898}18991900function checkElementGlow(tag, style, effectiveBg) {1901if (!style.boxShadow || style.boxShadow === 'none') return [];1902return checkGlow({ tag, boxShadow: style.boxShadow, effectiveBg });1903}19041905// ─── Section 6: Page-Level Checks ───────────────────────────────────────────19061907// Browser page-level checks — use document/getComputedStyle globals19081909function checkTypography() {1910const findings = [];19111912// Walk actual text-bearing elements and tally font usage by *computed style*.1913// This is much more accurate than scanning CSS rules — it ignores rules that1914// exist in the stylesheet but apply to nothing (e.g. demo classes showing1915// anti-patterns), and counts what the user actually sees.1916const fontUsage = new Map(); // primary font name → count of elements1917let totalTextElements = 0;1918for (const el of document.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, td, th, dd, blockquote, figcaption, a, button, label, span')) {1919// Skip impeccable's own elements1920if (el.closest && el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue;1921// Only count elements that actually have visible direct text1922const hasText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0);1923if (!hasText) continue;1924const style = getComputedStyle(el);1925const ff = style.fontFamily;1926if (!ff) continue;1927const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());1928const primary = stack.find(f => f && !GENERIC_FONTS.has(f));1929if (!primary) continue;1930fontUsage.set(primary, (fontUsage.get(primary) || 0) + 1);1931totalTextElements++;1932}19331934if (totalTextElements >= 20) {1935// A font is "primary" if it's used by at least 15% of text elements1936const PRIMARY_THRESHOLD = 0.15;1937for (const [font, count] of fontUsage) {1938const share = count / totalTextElements;1939if (share < PRIMARY_THRESHOLD) continue;1940if (!OVERUSED_FONTS.has(font)) continue;1941if (isBrandFontOnOwnDomain(font)) continue;1942findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });1943}19441945// Single-font check: only one distinct primary font across all text1946if (fontUsage.size === 1) {1947const only = [...fontUsage.keys()][0];1948findings.push({ type: 'single-font', detail: `only font used is ${only}` });1949}1950}19511952const sizes = new Set();1953for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) {1954const fs = parseFloat(getComputedStyle(el).fontSize);1955if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10);1956}1957if (sizes.size >= 3) {1958const sorted = [...sizes].sort((a, b) => a - b);1959const ratio = sorted[sorted.length - 1] / sorted[0];1960if (ratio < 2.0) {1961findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` });1962}1963}19641965return findings;1966}19671968function isCardLikeDOM(el) {1969const tag = el.tagName.toLowerCase();1970if (SAFE_TAGS.has(tag) || ['input','select','textarea','img','video','canvas','picture'].includes(tag)) return false;1971const style = getComputedStyle(el);1972const cls = el.getAttribute('class') || '';1973const hasShadow = (style.boxShadow && style.boxShadow !== 'none') || /\bshadow(?:-sm|-md|-lg|-xl|-2xl)?\b/.test(cls);1974const hasBorder = /\bborder\b/.test(cls);1975const hasRadius = parseFloat(style.borderRadius) > 0 || /\brounded(?:-sm|-md|-lg|-xl|-2xl|-full)?\b/.test(cls);1976const hasBg = (style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)') || /\bbg-(?:white|gray-\d+|slate-\d+)\b/.test(cls);1977return isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg);1978}19791980function checkLayout() {1981const findings = [];1982const flaggedEls = new Set();19831984for (const el of document.querySelectorAll('*')) {1985if (!isCardLikeDOM(el) || flaggedEls.has(el)) continue;1986const cls = el.getAttribute('class') || '';1987const style = getComputedStyle(el);1988if (style.position === 'absolute' || style.position === 'fixed') continue;1989if (/\b(?:dropdown|popover|tooltip|menu|modal|dialog)\b/i.test(cls)) continue;1990if ((el.textContent?.trim().length || 0) < 10) continue;1991const rect = el.getBoundingClientRect();1992if (rect.width < 50 || rect.height < 30) continue;19931994let parent = el.parentElement;1995while (parent) {1996if (isCardLikeDOM(parent)) { flaggedEls.add(el); break; }1997parent = parent.parentElement;1998}1999}20002001for (const el of flaggedEls) {2002let isAncestor = false;2003for (const other of flaggedEls) {2004if (other !== el && el.contains(other)) { isAncestor = true; break; }2005}2006if (!isAncestor) findings.push({ type: 'nested-cards', detail: 'Card inside card', el });2007}20082009return findings;2010}20112012// Node page-level checks — take document/window as parameters20132014function checkPageTypography(doc, win) {2015const findings = [];20162017const fonts = new Set();2018const overusedFound = new Set();20192020for (const sheet of doc.styleSheets) {2021let rules;2022try { rules = sheet.cssRules || sheet.rules; } catch { continue; }2023if (!rules) continue;2024for (const rule of rules) {2025if (rule.type !== 1) continue;2026const ff = rule.style?.fontFamily;2027if (!ff) continue;2028const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());2029const primary = stack.find(f => f && !GENERIC_FONTS.has(f));2030if (primary) {2031fonts.add(primary);2032if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);2033}2034}2035}20362037// Check Google Fonts links in HTML2038const html = doc.documentElement?.outerHTML || '';2039const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;2040let m;2041while ((m = gfRe.exec(html)) !== null) {2042const families = m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase());2043for (const f of families) {2044fonts.add(f);2045if (OVERUSED_FONTS.has(f)) overusedFound.add(f);2046}2047}20482049// Also parse raw HTML/style content for font-family (jsdom may not expose all via CSSOM)2050const ffRe = /font-family\s*:\s*([^;}]+)/gi;2051let fm;2052while ((fm = ffRe.exec(html)) !== null) {2053for (const f of fm[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) {2054if (f && !GENERIC_FONTS.has(f)) {2055fonts.add(f);2056if (OVERUSED_FONTS.has(f)) overusedFound.add(f);2057}2058}2059}20602061for (const font of overusedFound) {2062findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` });2063}20642065// Single font2066if (fonts.size === 1) {2067const els = doc.querySelectorAll('*');2068if (els.length >= 20) {2069findings.push({ id: 'single-font', snippet: `only font used is ${[...fonts][0]}` });2070}2071}20722073// Flat type hierarchy2074const sizes = new Set();2075const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div');2076for (const el of textEls) {2077const fontSize = parseFloat(win.getComputedStyle(el).fontSize);2078// Filter out sub-8px values (jsdom doesn't resolve relative units properly)2079if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10);2080}2081if (sizes.size >= 3) {2082const sorted = [...sizes].sort((a, b) => a - b);2083const ratio = sorted[sorted.length - 1] / sorted[0];2084if (ratio < 2.0) {2085findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` });2086}2087}20882089return findings;2090}20912092function isCardLike(el, win) {2093const tag = el.tagName.toLowerCase();2094if (SAFE_TAGS.has(tag) || ['input', 'select', 'textarea', 'img', 'video', 'canvas', 'picture'].includes(tag)) return false;20952096const style = win.getComputedStyle(el);2097const rawStyle = el.getAttribute?.('style') || '';2098const cls = el.getAttribute?.('class') || '';20992100const hasShadow = (style.boxShadow && style.boxShadow !== 'none') ||2101/\bshadow(?:-sm|-md|-lg|-xl|-2xl)?\b/.test(cls) || /box-shadow/i.test(rawStyle);2102const hasBorder = /\bborder\b/.test(cls);2103const widthPx = parseFloat(style.width) || 0;2104const hasRadius = resolveBorderRadiusPx(el, style, widthPx, win) > 0 ||2105/\brounded(?:-sm|-md|-lg|-xl|-2xl|-full)?\b/.test(cls) || /border-radius/i.test(rawStyle);2106const hasBg = /\bbg-(?:white|gray-\d+|slate-\d+)\b/.test(cls) ||2107/background(?:-color)?\s*:\s*(?!transparent)/i.test(rawStyle);21082109return isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg);2110}21112112function checkPageLayout(doc, win) {2113const findings = [];21142115// Nested cards2116const allEls = doc.querySelectorAll('*');2117const flaggedEls = new Set();2118for (const el of allEls) {2119if (!isCardLike(el, win)) continue;2120if (flaggedEls.has(el)) continue;21212122const tag = el.tagName.toLowerCase();2123const cls = el.getAttribute?.('class') || '';2124const rawStyle = el.getAttribute?.('style') || '';21252126if (['pre', 'code'].includes(tag)) continue;2127if (/\b(?:absolute|fixed)\b/.test(cls) || /position\s*:\s*(?:absolute|fixed)/i.test(rawStyle)) continue;2128if ((el.textContent?.trim().length || 0) < 10) continue;2129if (/\b(?:dropdown|popover|tooltip|menu|modal|dialog)\b/i.test(cls)) continue;21302131// Walk up to find card-like ancestor2132let parent = el.parentElement;2133while (parent) {2134if (isCardLike(parent, win)) {2135flaggedEls.add(el);2136break;2137}2138parent = parent.parentElement;2139}2140}21412142// Only report innermost nested cards2143for (const el of flaggedEls) {2144let isAncestorOfFlagged = false;2145for (const other of flaggedEls) {2146if (other !== el && el.contains(other)) {2147isAncestorOfFlagged = true;2148break;2149}2150}2151if (!isAncestorOfFlagged) {2152findings.push({ id: 'nested-cards', snippet: `Card inside card (${el.tagName.toLowerCase()})` });2153}2154}21552156return findings;2157}21582159// ─── Cream / beige palette (the default "tasteful" AI surface) ────────────────2160// A warm, lightly-tinted off-white page background — light, with R≥G≥B and a2161// small warm tint (not white, not a strong color). The current reflex surface.2162function isCreamColor(rgb) {2163if (!rgb) return false;2164const { r, g, b } = rgb;2165if (Math.min(r, g, b) < 209) return false; // must be light2166if (!(r >= g && g >= b)) return false; // warm ordering2167const warmth = r - b;2168return warmth >= 6 && warmth <= 48; // tinted, not white, not strong2169}21702171// Tailwind background utilities that render as a warm off-white surface. The2172// static engine doesn't fetch Tailwind's CSS, so a `bg-amber-50` on <body>2173// resolves to nothing in computed style — catch it from the class list2174// instead. Candidate tokens map to their actual Tailwind hex and are still2175// filtered through isCreamColor, so neutral grays (stone) and over-saturated2176// shades drop out on their own.2177const TAILWIND_BG_HEX = {2178'bg-amber-50': '#fffbeb', 'bg-amber-100': '#fef3c7',2179'bg-orange-50': '#fff7ed', 'bg-orange-100': '#ffedd5',2180'bg-yellow-50': '#fefce8',2181'bg-stone-50': '#fafaf9', 'bg-stone-100': '#f5f5f4', 'bg-stone-200': '#e7e5e4',2182};21832184function creamFromClassList(cls) {2185if (!cls) return null;2186// Arbitrary value: bg-[#f5f0e6] / bg-[rgb(245_240_230)] (underscores = spaces).2187const arb = cls.match(/\bbg-\[([^\]]+)\]/);2188if (arb && isCreamColor(parseAnyColor(arb[1].replace(/_/g, ' ')))) return `bg-[${arb[1]}]`;2189// Named warm-light utilities.2190for (const [tok, hex] of Object.entries(TAILWIND_BG_HEX)) {2191if (new RegExp(`(^|\\s)${tok}($|\\s)`).test(cls) && isCreamColor(parseAnyColor(hex))) return tok;2192}2193return null;2194}21952196function checkCreamPalette(doc, win) {2197const findings = [];2198const body = doc.body || (doc.querySelector ? doc.querySelector('body') : null);2199if (!body) return findings;2200const html = doc.documentElement;2201const getCS = (el) => (win ? win.getComputedStyle(el) : getComputedStyle(el));22022203// 1. Computed background — covers inline / <style> / linked CSS, and Tailwind2204// once it's actually rendered (browser path).2205let bg = readOwnBackgroundColor(body, getCS(body));2206if (!bg || bg.a === 0) {2207if (html) bg = readOwnBackgroundColor(html, getCS(html));2208}2209if (isCreamColor(bg)) {2210findings.push({ id: 'cream-palette', snippet: `cream/beige page background rgb(${bg.r}, ${bg.g}, ${bg.b})` });2211return findings;2212}22132214// 2. Tailwind class fallback — for the static path, where utility classes2215// never resolve to computed CSS.2216for (const el of [body, html]) {2217const tok = creamFromClassList(el && el.getAttribute ? el.getAttribute('class') : '');2218if (tok) {2219findings.push({ id: 'cream-palette', snippet: `cream/beige page background (Tailwind ${tok})` });2220break;2221}2222}2223return findings;2224}22252226// ─── Oversized hero headline ────────────────────────────────────────────────2227// Fires when a *long* headline is set at display size and actually dominates2228// the viewport. A punchy one- or two-word headline at the same size is a2229// legitimate stylistic choice, and a large-but-contained two-line hero should2230// pass too — length and viewport share together are the tell.2231const OVERSIZED_H1_FONT_PX = 72;2232const OVERSIZED_H1_MIN_CHARS = 40;2233const OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO = 0.28;2234const OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO = 0.25;2235function checkOversizedH1({ tag, fontSize, headingText, rect = null, viewportWidth = 0, viewportHeight = 0 }) {2236if (tag !== 'h1') return [];2237const textLen = headingText.length;2238if (fontSize >= OVERSIZED_H1_FONT_PX && textLen >= OVERSIZED_H1_MIN_CHARS) {2239let viewportDetail = '';2240if (rect && viewportWidth > 0 && viewportHeight > 0) {2241const heightRatio = rect.height / viewportHeight;2242const areaRatio = (rect.width * rect.height) / (viewportWidth * viewportHeight);2243const dominatesViewport = heightRatio >= OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO2244|| areaRatio >= OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO;2245if (!dominatesViewport) return [];2246viewportDetail = `, ${Math.round(heightRatio * 100)}vh`;2247}2248return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars${viewportDetail} "${headingText.slice(0, 60)}"` }];2249}2250return [];2251}22522253function checkElementOversizedH1(el, style, tag, window) {2254if (tag !== 'h1') return [];2255const fontSize = resolveFontSizePx(el, window);2256const headingText = (el.textContent || '').trim().replace(/\s+/g, ' ');2257return checkOversizedH1({ tag, fontSize, headingText });2258}22592260function checkElementOversizedH1DOM(el) {2261const tag = el.tagName.toLowerCase();2262if (tag !== 'h1') return [];2263const style = getComputedStyle(el);2264const fontSize = parseFloat(style.fontSize) || 0;2265const headingText = (el.textContent || '').trim().replace(/\s+/g, ' ');2266const rect = el.getBoundingClientRect();2267const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0;2268const viewportHeight = (typeof window !== 'undefined' ? window.innerHeight : 0) || 0;2269return checkOversizedH1({ tag, fontSize, headingText, rect, viewportWidth, viewportHeight });2270}22712272// ─── GPT tell: hairline border + wide diffuse shadow (gated --gpt) ────────────2273const CSS_COLOR_TOKEN_RE = /(?:rgba?|hsla?|oklch|oklab|lab|lch|color)\([^)]*\)|#[0-9a-fA-F]{3,8}\b|\b(?:black|white|transparent|currentcolor)\b/gi;22742275function shadowLayerAlpha(layer) {2276CSS_COLOR_TOKEN_RE.lastIndex = 0;2277const match = CSS_COLOR_TOKEN_RE.exec(layer);2278if (!match) return 1;2279if (match[0].toLowerCase() === 'transparent') return 0;2280const parsed = parseAnyColor(match[0]);2281return parsed ? (parsed.a ?? 1) : 1;2282}22832284function shadowMaxBlurPx(boxShadow, { minAlpha = 0 } = {}) {2285if (!boxShadow || boxShadow === 'none') return 0;2286let maxBlur = 0;2287// Split into layers on commas not inside parentheses (rgba(...) etc.).2288for (const layer of boxShadow.split(/,(?![^()]*\))/)) {2289if (shadowLayerAlpha(layer) < minAlpha) continue;2290// Strip colors and keywords (rgba()/hsl()/hex/named/inset/px), leaving the2291// ordered length tokens: offsetX offsetY blur [spread]. Static jsdom keeps2292// unitless zeros ("0 0 24px"); browsers normalize to px ("0px 0px 24px") —2293// both reduce to the same numbers here.2294const cleaned = layer.replace(CSS_COLOR_TOKEN_RE, ' ').replace(/\b[a-z]+\b/gi, ' ');2295const nums = [...cleaned.matchAll(/-?\d*\.?\d+/g)].map(m => parseFloat(m[0]));2296if (nums.length >= 3) maxBlur = Math.max(maxBlur, nums[2]);2297}2298return maxBlur;2299}23002301function cssColorAlpha(value) {2302if (cssColorIsTransparent(value)) return 0;2303const parsed = parseAnyColor(value);2304return parsed ? (parsed.a ?? 1) : 1;2305}23062307function checkGptThinBorderWideShadow({ borderWidths, borderColors, boxShadow }) {2308const visibleThinBorders = borderWidths2309.map((width, index) => ({ width, alpha: cssColorAlpha(borderColors?.[index] || '') }))2310.filter(({ width, alpha }) => width > 0 && width <= 1.5 && alpha >= 0.28);2311const maxBorder = Math.max(0, ...visibleThinBorders.map(({ width }) => width));2312const blur = shadowMaxBlurPx(boxShadow, { minAlpha: 0.12 });2313if (visibleThinBorders.length >= 2 && blur >= 16) {2314return [{ id: 'gpt-thin-border-wide-shadow', snippet: `${maxBorder}px border + ${Math.round(blur)}px shadow blur` }];2315}2316return [];2317}23182319function borderWidthsFromStyle(style) {2320return [2321parseFloat(style.borderTopWidth) || 0,2322parseFloat(style.borderRightWidth) || 0,2323parseFloat(style.borderBottomWidth) || 0,2324parseFloat(style.borderLeftWidth) || 0,2325];2326}23272328function borderColorsFromStyle(style) {2329return [2330style.borderTopColor || '',2331style.borderRightColor || '',2332style.borderBottomColor || '',2333style.borderLeftColor || '',2334];2335}23362337function checkElementGptBorderShadow(el, style) {2338return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' });2339}23402341function checkElementGptBorderShadowDOM(el) {2342const style = getComputedStyle(el);2343return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' });2344}23452346// ─── Clipped overflow container ───────────────────────────────────────────────2347// A clipping container (overflow hidden/clip, not a scroll region) wrapping an2348// absolutely/fixed-positioned descendant clips popovers/menus that must escape.2349function classSelector(el) {2350const cls = (el.getAttribute ? el.getAttribute('class') : el.className) || '';2351const tokens = String(cls).trim().split(/\s+/).filter(Boolean);2352const tag = el.tagName ? el.tagName.toLowerCase() : 'el';2353return tokens.length ? `${tag}.${tokens.join('.')}` : tag;2354}23552356function positionedChildIsDecorative(child) {2357if (!child || typeof child.getAttribute !== 'function') return false;2358if (child.closest?.('[aria-hidden="true"]')) return true;2359const role = (child.getAttribute('role') || '').toLowerCase();2360if (role === 'none' || role === 'presentation') return true;2361const tag = child.tagName ? child.tagName.toLowerCase() : '';2362if (['img', 'svg', 'canvas', 'video'].includes(tag)) return true;2363const ident = `${child.getAttribute('class') || ''} ${child.getAttribute('id') || ''}`;2364if (2365/\b(art|bg|background|badge|blob|crop|decor|dot|glow|grain|image|mask|ornament|overlay|photo|scrim|shadow|shine|texture)\b/i.test(ident) &&2366!positionedChildHasSubstantiveContent(child)2367) {2368return true;2369}2370return false;2371}23722373const POSITIONED_CHILD_INTERACTIVE_SELECTOR = [2374'a[href]',2375'button',2376'input',2377'select',2378'summary',2379'textarea',2380'[tabindex]:not([tabindex="-1"])',2381'[role="button"]',2382'[role="dialog"]',2383'[role="link"]',2384'[role="listbox"]',2385'[role="menu"]',2386'[role="menuitem"]',2387'[role="option"]',2388'[role="tooltip"]',2389].join(',');23902391function positionedChildHasSubstantiveContent(child) {2392const text = (child.textContent || '').replace(/\s+/g, ' ').trim();2393if (text.length > 0) return true;2394if (typeof child.matches === 'function') {2395try {2396if (child.matches(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true;2397} catch {}2398}2399if (typeof child.querySelector === 'function') {2400try {2401if (child.querySelector(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true;2402} catch {}2403}2404return false;2405}24062407function clippingContainerIsIntentionalViewport(el) {2408if (!el || typeof el.getAttribute !== 'function') return false;2409const roleDescription = (el.getAttribute('aria-roledescription') || '').toLowerCase();2410if (/\b(carousel|slider)\b/.test(roleDescription)) return true;2411const ident = `${el.getAttribute('class') || ''} ${el.getAttribute('id') || ''}`.toLowerCase();2412return /\b(carousel|comparison|compare|fisheye|marquee|preview|scroller|slider|slideshow|split|viewport)\b/.test(ident) ||2413/\b(demo-area|demo-stage|demo-viewport)\b/.test(ident);2414}24152416function elementRect(el) {2417if (!el || typeof el.getBoundingClientRect !== 'function') return null;2418try {2419const rect = el.getBoundingClientRect();2420if (!rect) return null;2421const values = [rect.top, rect.right, rect.bottom, rect.left, rect.width, rect.height];2422if (!values.every(Number.isFinite)) return null;2423if (rect.width <= 0 && rect.height <= 0) return null;2424return rect;2425} catch {2426return null;2427}2428}24292430function positionedStyleImpliesEscape(style) {2431const values = [2432style.top,2433style.right,2434style.bottom,2435style.left,2436style.inset,2437style.insetBlock,2438style.insetInline,2439style.insetBlockStart,2440style.insetBlockEnd,2441style.insetInlineStart,2442style.insetInlineEnd,2443].filter(Boolean).map(value => String(value).trim().toLowerCase());2444for (const value of values) {2445if (/(^|[\s(])-+(?:\d|\.)/.test(value)) return true;2446if (/(^|[\s(])100(?:\.0+)?%/.test(value)) return true;2447}2448return false;2449}24502451function positionedChildEscapesClip(el, child, clipX, clipY) {2452const parentRect = elementRect(el);2453const childRect = elementRect(child);2454if (!parentRect || !childRect) return null;2455const threshold = 2;2456return Boolean(2457(clipX && (childRect.left < parentRect.left - threshold || childRect.right > parentRect.right + threshold)) ||2458(clipY && (childRect.top < parentRect.top - threshold || childRect.bottom > parentRect.bottom + threshold))2459);2460}24612462function checkClippedOverflow(el, style, getStyle) {2463const clips = (v) => v === 'hidden' || v === 'clip';2464const scrolls = (v) => v === 'auto' || v === 'scroll';2465const ox = style.overflowX || '', oy = style.overflowY || '', ov = style.overflow || '';2466const clipX = clips(ox) || clips(ov);2467const clipY = clips(oy) || clips(ov);2468const anyClip = clipX || clipY;2469const anyScroll = scrolls(ox) || scrolls(oy) || scrolls(ov);2470if (!anyClip || anyScroll) return [];2471if (clippingContainerIsIntentionalViewport(el)) return [];2472if (!el.querySelectorAll) return [];2473for (const child of el.querySelectorAll('*')) {2474const childStyle = getStyle(child);2475const pos = childStyle.position || '';2476if (pos === 'absolute' || pos === 'fixed') {2477if (positionedChildIsDecorative(child)) continue;2478const escapes = positionedChildEscapesClip(el, child, clipX, clipY);2479if (escapes === false) continue;2480if (escapes === null && !positionedStyleImpliesEscape(childStyle)) continue;2481return [{ id: 'clipped-overflow-container', snippet: `${classSelector(el)} clips a positioned child` }];2482}2483}2484return [];2485}24862487function checkElementClippedOverflow(el, style, tag, window) {2488return checkClippedOverflow(el, style, (n) => window.getComputedStyle(n));2489}24902491function checkElementClippedOverflowDOM(el) {2492const style = getComputedStyle(el);2493return checkClippedOverflow(el, style, (n) => getComputedStyle(n));2494}24952496// ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ──────────────2497const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']);24982499function metricLengthPx(value, fontSizePx = 16) {2500if (typeof value === 'number' && Number.isFinite(value)) return value;2501if (typeof value !== 'string') return null;2502return resolveLengthPx(value, fontSizePx);2503}25042505function firstMetricLengthPx(fontSizePx, ...values) {2506for (const value of values) {2507const parsed = metricLengthPx(value, fontSizePx);2508if (parsed !== null) return parsed;2509}2510return null;2511}25122513function expandBoxShorthand(parts) {2514if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]];2515if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]];2516if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]];2517return [parts[0], parts[1], parts[2], parts[3]];2518}25192520function clippedByInset(clipPath) {2521const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/);2522if (!match) return false;2523const beforeRound = match[1].split(/\s+round\s+/)[0].trim();2524if (!beforeRound) return false;2525const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4));2526const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/));2527if (percents.some(match => !match)) return false;2528const [top, right, bottom, left] = percents.map(match => parseFloat(match[1]));2529return top + bottom >= 100 || left + right >= 100;2530}25312532function clippedByRect(clip) {2533const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/);2534if (!match) return false;2535const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean);2536if (values.length !== 4) return false;2537const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16));2538if ([top, right, bottom, left].some(value => value === null)) return false;2539return bottom <= top || right <= left;2540}25412542function isScreenReaderOnlyTextStyle(style, metrics = {}) {2543if (!style) return false;2544const overflowValues = [style.overflow, style.overflowX, style.overflowY]2545.map(value => String(value || '').toLowerCase());2546const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip');25472548const fontSize = metricLengthPx(style.fontSize, 16) || 16;2549const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize);2550const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize);2551const isTiny = width !== null && height !== null && width <= 2 && height <= 2;2552const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow;25532554const clipPath = String(style.clipPath || style.webkitClipPath || '').trim();2555const clip = String(style.clip || '').trim();2556return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip);2557}25582559function isRenderedForBrowserRule(el) {2560for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) {2561if (cur.getAttribute?.('aria-hidden') === 'true') return false;2562const style = getComputedStyle(cur);2563const visibility = String(style.visibility || '').toLowerCase();2564if (style.display === 'none' || visibility === 'hidden' || visibility === 'collapse') return false;2565if ((parseFloat(style.opacity) || 0) <= 0.01) return false;2566if (String(style.contentVisibility || '').toLowerCase() === 'hidden') return false;2567}2568return true;2569}25702571function checkElementTextOverflowDOM(el) {2572const tag = el.tagName.toLowerCase();2573if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];2574if (!isRenderedForBrowserRule(el)) return [];2575// Only the element that actually owns overflowing text — not its ancestors,2576// which inherit a wider scrollWidth from the spilling descendant.2577const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0);2578if (!hasDirectText) return [];2579const style = getComputedStyle(el);2580const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;2581if (isScreenReaderOnlyTextStyle(style, {2582width: rect?.width,2583height: rect?.height,2584clientWidth: el.clientWidth,2585clientHeight: el.clientHeight,2586})) return [];2587const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || '');2588if (isScrollRegion(style)) return [];2589// A scrollable ancestor means this overflow is intentional and scrollable.2590for (let p = el.parentElement; p; p = p.parentElement) {2591if (isScrollRegion(getComputedStyle(p))) return [];2592}2593const delta = el.scrollWidth - el.clientWidth;2594if (el.clientWidth > 0 && delta >= 16) {2595return [{ id: 'text-overflow', snippet: `${classSelector(el)} overflows its box by ${Math.round(delta)}px` }];2596}2597return [];2598}25992600export {2601checkBorders,2602isEmojiOnlyText,2603checkColors,2604isCardLikeFromProps,2605checkIconTile,2606resolveSerif,2607checkItalicSerif,2608isAccentColor,2609checkHeroEyebrow,2610checkRepeatedSectionKickers,2611checkMotion,2612checkGlow,2613checkHtmlPatterns,2614readOwnBackgroundColor,2615resolveBackground,2616resolveGradientStops,2617parseRadiusToPx,2618resolveBorderRadiusPx,2619checkElementBordersDOM,2620checkElementColorsDOM,2621checkElementIconTileDOM,2622checkElementItalicSerifDOM,2623checkElementHeroEyebrowDOM,2624buildCustomPropMap,2625resolveVarRefs,2626oklchToRgb,2627parseAnyColor,2628parseColorResolved,2629cleanInlineText,2630isRepeatedKickerCandidate,2631collectRepeatedSectionKickerCandidates,2632checkRepeatedSectionKickersDOM,2633checkElementMotionDOM,2634checkElementGlowDOM,2635checkElementAIPaletteDOM,2636resolveFontSizePx,2637resolveLengthPx,2638checkQuality,2639checkElementQualityDOM,2640checkPageQualityFromDoc,2641checkPageQualityDOM,2642checkElementQuality,2643checkElementBorders,2644checkElementColors,2645checkElementIconTile,2646checkElementItalicSerif,2647checkElementHeroEyebrow,2648checkRepeatedSectionKickersFromDoc,2649checkElementMotion,2650checkElementGlow,2651checkTypography,2652isCardLikeDOM,2653checkLayout,2654checkPageTypography,2655isCardLike,2656checkPageLayout,2657isCreamColor,2658checkCreamPalette,2659checkOversizedH1,2660checkElementOversizedH1,2661checkElementOversizedH1DOM,2662shadowMaxBlurPx,2663checkGptThinBorderWideShadow,2664checkElementGptBorderShadow,2665checkElementGptBorderShadowDOM,2666checkClippedOverflow,2667checkElementClippedOverflow,2668checkElementClippedOverflowDOM,2669isScreenReaderOnlyTextStyle,2670checkElementTextOverflowDOM,2671};2672