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/detect-antipatterns-browser.js
1/**2* Anti-Pattern Browser Detector for Impeccable3* Copyright (c) 2026 Paul Bakaus4* SPDX-License-Identifier: Apache-2.05*6* GENERATED -- do not edit. Source: cli/engine/browser/injected/index.mjs7* Rebuild: node scripts/build-browser-detector.js8*9* Usage: <script src="detect-antipatterns-browser.js"></script>10* Re-scan: window.impeccableScan()11*/12(function () {13if (typeof window === 'undefined') return;14// --- cli/engine/shared/constants.mjs ---15// ─── Section 1: Constants ───────────────────────────────────────────────────1617const SAFE_TAGS = new Set([18'blockquote', 'nav', 'a', 'input', 'textarea', 'select',19'pre', 'code', 'span', 'th', 'td', 'tr', 'li', 'label',20'button', 'hr', 'html', 'head', 'body', 'script', 'style',21'link', 'meta', 'title', 'br', 'img', 'svg', 'path', 'circle',22'rect', 'line', 'polyline', 'polygon', 'g', 'defs', 'use',23]);2425// Per-check safe-tags override for the border (side-tab / border-accent)26// rule. We intentionally re-allow <label> here because card-shaped clickable27// labels (e.g. .checklist-item wrapping a checkbox + content) are one of the28// canonical side-tab anti-pattern shapes and must be detected. The rule's29// other preconditions (non-neutral color, width >= 2px on a single side,30// radius > 0 or width >= 3, element size >= 20x20 in the browser path)31// already filter out plain inline form labels so this does not introduce32// false positives. See modern-color-borders.html for the test matrix.33const BORDER_SAFE_TAGS = new Set(34[...SAFE_TAGS].filter(t => t !== 'label')35);3637const OVERUSED_FONTS = new Set([38// Older monoculture (still ubiquitous):39'inter', 'roboto', 'open sans', 'lato', 'montserrat', 'arial', 'helvetica',40// Newer monoculture (the Anthropic-skill / Vercel / GitHub default wave):41'fraunces', 'instrument sans', 'instrument serif',42'geist', 'geist sans', 'geist mono',43'mona sans',44'plus jakarta sans', 'space grotesk', 'recoleta',45]);4647// Brand-associated fonts: don't flag these as "overused" on the brand's own domains.48// Keys are font names, values are arrays of hostname suffixes where the font is allowed.49const GOOGLE_DOMAINS = [50'google.com', 'youtube.com', 'android.com', 'chromium.org',51'chrome.com', 'web.dev', 'gstatic.com', 'firebase.google.com',52];53const VERCEL_DOMAINS = ['vercel.com', 'nextjs.org', 'v0.app'];54const GITHUB_DOMAINS = ['github.com', 'githubnext.com'];55const BRAND_FONT_DOMAINS = {56'roboto': GOOGLE_DOMAINS,57'google sans': GOOGLE_DOMAINS,58'product sans': GOOGLE_DOMAINS,59'geist': VERCEL_DOMAINS,60'geist sans': VERCEL_DOMAINS,61'geist mono': VERCEL_DOMAINS,62'mona sans': GITHUB_DOMAINS,63};6465function isBrandFontOnOwnDomain(font) {66if (typeof location === 'undefined') return false;67const allowed = BRAND_FONT_DOMAINS[font];68if (!allowed) return false;69const host = location.hostname.toLowerCase();70return allowed.some(suffix => host === suffix || host.endsWith('.' + suffix));71}7273const GENERIC_FONTS = new Set([74'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy',75'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',76'-apple-system', 'blinkmacsystemfont', 'segoe ui',77'inherit', 'initial', 'unset', 'revert',78]);7980// WCAG large text thresholds are defined in points: 18pt normal text and81// 14pt bold text. Browsers expose font-size in CSS pixels at 96px per inch.82const WCAG_LARGE_TEXT_PX = 18 * (96 / 72);83const WCAG_LARGE_BOLD_TEXT_PX = 14 * (96 / 72);8485// Serif faces that show up in italic-display heroes. The rule also fires when86// the primary face is unknown but the stack ends in the generic `serif` token,87// which catches custom/private faces with a serif fallback.88const KNOWN_SERIF_FONTS = new Set([89'fraunces', 'recoleta', 'newsreader', 'playfair display', 'playfair',90'cormorant', 'cormorant garamond', 'garamond', 'eb garamond',91'tiempos', 'tiempos headline', 'tiempos text',92'lora', 'vollkorn', 'spectral',93'source serif pro', 'source serif 4', 'source serif',94'ibm plex serif', 'merriweather',95'libre caslon', 'libre baskerville', 'baskerville',96'georgia', 'times new roman', 'times',97'dm serif display', 'dm serif text',98'instrument serif', 'gt sectra', 'ogg', 'canela',99'freight display', 'freight text',100]);101102// --- cli/engine/registry/antipatterns.mjs ---103const ANTIPATTERNS = [104// ── AI slop: tells that something was AI-generated ──105{106id: 'side-tab',107category: 'slop',108name: 'Side-tab accent border',109description:110'Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.',111skillSection: 'Visual Details',112skillGuideline: 'colored accent stripe',113},114{115id: 'border-accent-on-rounded',116category: 'slop',117name: 'Border accent on rounded element',118description:119'Thick accent border on a rounded card — the border clashes with the rounded corners. Remove the border or the border-radius.',120skillSection: 'Visual Details',121skillGuideline: 'colored accent stripe',122},123{124id: 'overused-font',125category: 'slop',126name: 'Overused font',127description:128'Inter, Roboto, Fraunces, Geist, Plus Jakarta Sans, and Space Grotesk are used on so many sites they no longer feel distinctive. Each new wave of AI-generated UIs converges on the same handful of faces. Choose a face that gives your interface personality.',129skillSection: 'Typography',130skillGuideline: 'overused fonts like Inter',131},132{133id: 'single-font',134category: 'slop',135name: 'Single font for everything',136description:137'Only one font family is used for the entire page. Pair a distinctive display font with a refined body font to create typographic hierarchy.',138skillSection: 'Typography',139skillGuideline: 'only one font family for the entire page',140},141{142id: 'flat-type-hierarchy',143category: 'slop',144name: 'Flat type hierarchy',145description:146'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).',147skillSection: 'Typography',148skillGuideline: 'flat type hierarchy',149},150{151id: 'gradient-text',152category: 'slop',153name: 'Gradient text',154description:155'Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.',156skillSection: 'Color & Contrast',157skillGuideline: 'gradient text for',158},159{160id: 'ai-color-palette',161category: 'slop',162name: 'AI color palette',163description:164'Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.',165skillSection: 'Color & Contrast',166skillGuideline: 'AI color palette',167},168{169id: 'cream-palette',170category: 'slop',171name: 'Cream / beige palette',172description:173'A warm cream or beige page background has become the default "tasteful" AI surface, reached for by reflex. Choose a background that comes from a deliberate palette, not the safe warm off-white.',174skillSection: 'Color & Contrast',175skillGuideline: 'cream and beige as the default surface',176},177{178id: 'nested-cards',179category: 'slop',180name: 'Nested cards',181description:182'Cards inside cards create visual noise and excessive depth. Flatten the hierarchy — use spacing, typography, and dividers instead of nesting containers.',183skillSection: 'Layout & Space',184skillGuideline: 'Nest cards inside cards',185},186{187id: 'monotonous-spacing',188category: 'slop',189name: 'Monotonous spacing',190description:191'The same spacing value used everywhere — no rhythm, no variation. Use tight groupings for related items and generous separations between sections.',192skillSection: 'Layout & Space',193skillGuideline: 'same spacing everywhere',194},195{196id: 'bounce-easing',197category: 'slop',198name: 'Bounce or elastic easing',199description:200'Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.',201skillSection: 'Motion',202skillGuideline: 'bounce or elastic easing',203},204{205id: 'dark-glow',206category: 'slop',207name: 'Dark mode with glowing accents',208description:209'Dark backgrounds with colored box-shadow glows are the default "cool" look of AI-generated UIs. Use subtle, purposeful lighting instead — or skip the dark theme entirely.',210skillSection: 'Color & Contrast',211skillGuideline: 'dark mode with glowing accents',212},213{214id: 'icon-tile-stack',215category: 'slop',216name: 'Icon tile stacked above heading',217description:218'A small rounded-square icon container above a heading is the universal AI feature-card template — every generator outputs this exact shape. Try a side-by-side icon and heading, or let the icon sit in flow without its own container.',219skillSection: 'Typography',220skillGuideline: 'large icons with rounded corners above every heading',221},222{223id: 'italic-serif-display',224category: 'slop',225name: 'Italic serif display headline',226description:227'Oversized italic serif (Fraunces, Recoleta, Playfair, Newsreader-italic) as the primary hero headline reads as taste in isolation but has become the universal AI-startup landing page hero. Set roman, or move to a non-serif display face. Editorial / magazine register may legitimately want this — judge by context.',228skillSection: 'Typography',229skillGuideline: 'oversized italic serif as the hero headline',230},231{232id: 'hero-eyebrow-chip',233category: 'slop',234name: 'Hero eyebrow / pill chip',235description:236'A tiny uppercase letter-spaced label sitting immediately above an oversized hero headline — or the same shape rendered as a pill chip — is now the default AI SaaS hero. Drop the eyebrow, integrate the kicker into the headline, or run it as a navigation breadcrumb instead.',237skillSection: 'Typography',238skillGuideline: 'tiny uppercase tracked label above the hero headline',239},240{241id: 'repeated-section-kickers',242category: 'slop',243severity: 'advisory',244name: 'Repeated section kicker labels',245description:246'Repeating tiny uppercase tracked labels above section headings turns a brand page into AI editorial scaffolding. Replace them with stronger structure, artifacts, imagery, or a deliberate brand system.',247skillSection: 'Typography',248skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding',249},250{251id: 'numbered-section-markers',252category: 'slop',253severity: 'advisory',254name: 'Numbered section markers (01 / 02 / 03)',255description:256'Numbered display markers as section labels (01, 02, 03) are the AI editorial scaffold one tier deeper than tracked eyebrow chips. If you find yourself reaching for them, choose a different section cadence.',257skillSection: 'Layout & Space',258skillGuideline: 'numbered section markers',259},260{261id: 'em-dash-overuse',262category: 'slop',263name: 'Em-dash overuse',264description:265'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.',266skillSection: 'Copy',267skillGuideline: 'no em dashes',268},269{270id: 'marketing-buzzword',271category: 'slop',272name: 'Marketing buzzword',273description:274'Generic SaaS phrases (streamline / empower / supercharge / world-class / enterprise-grade / next-generation / cutting-edge / etc) are instant AI tells. Pick a specific verb and noun that says what the product literally does.',275skillSection: 'Copy',276skillGuideline: 'marketing buzzwords',277},278{279id: 'aphoristic-cadence',280category: 'slop',281name: 'Aphoristic-cadence copy',282description:283'Three or more sections landing on a short rebuttal sentence ("X. No Y." / "X. Just Y.") or a manufactured-contrast aphorism ("Not a feature. A platform.") reads as AI cadence, not voice. Once is fine; the pattern is the tell.',284skillSection: 'Copy',285skillGuideline: 'aphoristic cadence',286},287{288id: 'oversized-h1',289category: 'slop',290name: 'Oversized hero headline',291description:292'A full-sentence headline set at display size ends up dominating the viewport, leaving no room for anything else above the fold. A punchy one- or two-word headline at that size is fine — the problem is a long headline blown up too large. Set long headlines smaller, or tighten the copy.',293skillSection: 'Typography',294skillGuideline: 'long headline set at display size',295},296{297id: 'extreme-negative-tracking',298category: 'slop',299name: 'Crushed letter spacing',300description:301'Letter-spacing pulled tighter than the point where characters keep their own shapes costs legibility. Tighten display type optically, not destructively.',302skillSection: 'Typography',303skillGuideline: 'letter spacing crushed past legibility',304},305{306id: 'broken-image',307category: 'quality',308name: 'Broken or placeholder image',309description:310'<img> tags with empty src, missing src, or placeholder values ship as broken-image boxes. Use real images, generated assets, or remove the tag.',311skillSection: 'Imagery',312skillGuideline: 'broken image references',313},314315// ── Quality: general design and accessibility issues ──316{317id: 'gray-on-color',318category: 'quality',319name: 'Gray text on colored background',320description:321'Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.',322skillSection: 'Color & Contrast',323skillGuideline: 'gray text on colored backgrounds',324},325{326id: 'low-contrast',327category: 'quality',328name: 'Low contrast text',329description:330'Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.',331},332{333id: 'layout-transition',334category: 'quality',335name: 'Layout property animation',336description:337'Animating width, height, padding, or margin causes layout thrash and janky performance. Use transform and opacity instead, or grid-template-rows for height animations.',338skillSection: 'Motion',339skillGuideline: 'Animate layout properties',340},341{342id: 'line-length',343category: 'quality',344name: 'Line length too long',345description:346'Text lines wider than ~80 characters are hard to read. The eye loses its place tracking back to the start of the next line. Add a max-width (65ch to 75ch) to text containers.',347skillSection: 'Layout & Space',348skillGuideline: 'wrap beyond ~80 characters',349},350{351id: 'cramped-padding',352category: 'quality',353name: 'Cramped padding',354description:355'Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the padding is too low for the font size, and (2) a wrapper with text-bearing children and near-zero padding against a visible boundary (border, outline, or non-transparent background) — children land flush against the boundary line. Add at least 8px (ideally 12–16px) of padding inside bordered, outlined, or colored containers.',356skillSection: 'Layout & Space',357skillGuideline: 'inside bordered or colored containers',358},359{360id: 'body-text-viewport-edge',361category: 'quality',362name: 'Body text touching viewport edge',363description:364'Body paragraphs render flush against the left or right viewport edge with no container providing horizontal padding. Wrap content in a container with at least 16px (ideally 24-32px) of horizontal padding, or apply max-width with mx-auto.',365},366{367id: 'tight-leading',368category: 'quality',369name: 'Tight line height',370description:371'Line height below 1.3x the font size makes multi-line text hard to read. Use 1.5 to 1.7 for body text so lines have room to breathe.',372},373{374id: 'skipped-heading',375category: 'quality',376name: 'Skipped heading level',377description:378'Heading levels should not skip (e.g. h1 then h3 with no h2). Screen readers use heading hierarchy for navigation. Skipping levels breaks the document outline.',379},380{381id: 'justified-text',382category: 'quality',383name: 'Justified text',384description:385'Justified text without hyphenation creates uneven word spacing ("rivers of white"). Use text-align: left for body text, or enable hyphens: auto if you must justify.',386},387{388id: 'tiny-text',389category: 'quality',390name: 'Tiny body text',391description:392'Body text below 12px is hard to read, especially on high-DPI screens. Use at least 14px for body content, 16px is ideal.',393},394{395id: 'all-caps-body',396category: 'quality',397name: 'All-caps body text',398description:399'Long passages in uppercase are hard to read. We recognize words by shape (ascenders and descenders), which all-caps removes. Reserve uppercase for short labels and headings.',400skillSection: 'Typography',401skillGuideline: 'long body passages in uppercase',402},403{404id: 'wide-tracking',405category: 'quality',406name: 'Wide letter spacing on body text',407description:408'Letter spacing above 0.05em on body text disrupts natural character groupings and slows reading. Reserve wide tracking for short uppercase labels only.',409},410{411id: 'text-overflow',412category: 'quality',413name: 'Content overflowing its container',414description:415'Content renders wider than its container, spilling out or forcing a horizontal scrollbar. Let text wrap, constrain widths, or give the region a deliberate scroll affordance.',416skillSection: 'Layout & Space',417skillGuideline: 'content wider than its container',418},419{420id: 'clipped-overflow-container',421category: 'quality',422name: 'Positioned child clipped by overflow container',423description:424'A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.',425skillSection: 'Layout & Space',426skillGuideline: 'overflow container clipping positioned children',427},428{429id: 'design-system-font',430category: 'quality',431name: 'Font outside DESIGN.md',432description:433'A font is used that is not declared in DESIGN.md typography. Use the documented type system or update DESIGN.md if this is an intentional brand addition.',434skillSection: 'Typography',435skillGuideline: 'font family outside the project design system',436},437{438id: 'design-system-color',439category: 'quality',440severity: 'advisory',441name: 'Color outside DESIGN.md',442description:443'A literal color is outside the DESIGN.md palette and sidecar tonal ramps. This may be legitimate, but it should be an intentional design-system addition rather than drift.',444skillSection: 'Color & Contrast',445skillGuideline: 'literal color outside the project design system',446},447{448id: 'design-system-radius',449category: 'quality',450severity: 'advisory',451name: 'Radius outside DESIGN.md',452description:453'A border-radius value is outside the DESIGN.md rounded scale. Use a documented radius token or update the design system if the new shape is intentional.',454skillSection: 'Visual Details',455skillGuideline: 'border radius outside the project design system',456},457458// ── Provider tells: opt-in via --gpt / --gemini (gated off by default) ──459{460id: 'gpt-thin-border-wide-shadow',461category: 'slop',462severity: 'advisory',463gated: 'gpt',464name: 'Hairline border with wide shadow',465description:466'A hairline border paired with a wide, diffuse shadow is a recurring generated-UI signature. Commit to one — a defined edge or a soft elevation — rather than both at once.',467skillSection: 'Visual Details',468skillGuideline: 'hairline border plus wide diffuse shadow',469},470{471id: 'repeating-stripes-gradient',472category: 'slop',473severity: 'advisory',474gated: 'gpt',475name: 'Repeating-gradient stripes',476description:477'Repeating-gradient stripes used as surface decoration are a recurring generated-UI signature. Reach for a deliberate texture or leave the surface plain.',478skillSection: 'Visual Details',479skillGuideline: 'repeating-gradient decorative stripes',480},481{482id: 'theater-slop-phrase',483category: 'slop',484severity: 'advisory',485gated: 'gpt',486name: 'Theater framing copy',487description:488'Dismissing something as "theater" is a recurring generated-copy tic. Say plainly what the thing does or does not do.',489skillSection: 'Copy',490skillGuideline: 'theater framing copy',491},492{493id: 'image-hover-transform',494category: 'slop',495severity: 'advisory',496gated: 'gemini',497name: 'Image hover transform',498description:499'Scaling or rotating an image on hover is a recurring generated-UI signature. Let imagery sit still, or use a subtler, purposeful interaction.',500skillSection: 'Motion',501skillGuideline: 'image scale or rotate on hover',502},503];504505// --- cli/engine/shared/color.mjs ---506// ─── Section 2: Color Utilities ─────────────────────────────────────────────507508function isNeutralColor(color) {509if (!color || color === 'transparent') return true;510511// rgb/rgba — use channel spread. Threshold 30 ≈ 11.7% of the 0–255 range.512const rgb = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);513if (rgb) {514return (Math.max(+rgb[1], +rgb[2], +rgb[3]) - Math.min(+rgb[1], +rgb[2], +rgb[3])) < 30;515}516517// oklch()/lch() — chroma is the second numeric component.518// oklch chroma is ~0–0.4 in sRGB gamut; >= 0.02 reads as tinted, not gray.519// lch chroma is ~0–150; >= 3 reads as tinted. jsdom emits both formats520// literally (it does NOT convert them to rgb).521const oklch = color.match(/oklch\(\s*[\d.]+%?\s*([\d.-]+)/i);522if (oklch) return parseFloat(oklch[1]) < 0.02;523const lch = color.match(/lch\(\s*[\d.]+%?\s*([\d.-]+)/i);524if (lch) return parseFloat(lch[1]) < 3;525526// oklab()/lab() — a and b are signed axes; chroma = sqrt(a² + b²).527// oklab a/b are ~-0.4..0.4, threshold 0.02. lab a/b are ~-128..127, threshold 3.528const oklab = color.match(/oklab\(\s*[\d.]+%?\s*([\d.-]+)\s+([\d.-]+)/i);529if (oklab) {530const a = parseFloat(oklab[1]), b = parseFloat(oklab[2]);531return Math.hypot(a, b) < 0.02;532}533const lab = color.match(/lab\(\s*[\d.]+%?\s*([\d.-]+)\s+([\d.-]+)/i);534if (lab) {535const a = parseFloat(lab[1]), b = parseFloat(lab[2]);536return Math.hypot(a, b) < 3;537}538539// hsl/hsla — saturation is the second numeric component (percent).540// Modern jsdom usually converts hsl() to rgb, but handle it directly for541// safety across versions and for any engine that preserves the format.542const hsl = color.match(/hsla?\(\s*[\d.-]+\s*,?\s*([\d.]+)%/i);543if (hsl) return parseFloat(hsl[1]) < 10;544545// hwb(hue whiteness% blackness%) — a pixel is fully gray when546// whiteness + blackness >= 100; chroma-like saturation = 1 - (w+b)/100.547const hwb = color.match(/hwb\(\s*[\d.-]+\s+([\d.]+)%\s+([\d.]+)%/i);548if (hwb) {549const w = parseFloat(hwb[1]), b = parseFloat(hwb[2]);550return (1 - Math.min(100, w + b) / 100) < 0.1;551}552553// Unknown / unrecognized format — err on the side of DETECTING rather554// than silently skipping. This is the opposite of the previous default,555// which was the root cause of the oklch bug.556return false;557}558559function parseRgb(color) {560if (!color || color === 'transparent') return null;561const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);562if (!m) return null;563return { r: +m[1], g: +m[2], b: +m[3], a: m[4] !== undefined ? +m[4] : 1 };564}565566function relativeLuminance({ r, g, b }) {567const [rs, gs, bs] = [r / 255, g / 255, b / 255].map(c =>568c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4569);570return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;571}572573function contrastRatio(c1, c2) {574const l1 = relativeLuminance(c1);575const l2 = relativeLuminance(c2);576return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);577}578579function parseGradientColors(bgImage) {580if (!bgImage || !bgImage.includes('gradient')) return [];581const colors = [];582for (const m of bgImage.matchAll(/rgba?\([^)]+\)/g)) {583const c = parseRgb(m[0]);584if (c) colors.push(c);585}586for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {587const h = m[1];588if (h.length === 6) {589colors.push({ r: parseInt(h.slice(0,2),16), g: parseInt(h.slice(2,4),16), b: parseInt(h.slice(4,6),16), a: 1 });590} else {591colors.push({ r: parseInt(h[0]+h[0],16), g: parseInt(h[1]+h[1],16), b: parseInt(h[2]+h[2],16), a: 1 });592}593}594return colors;595}596597function hasChroma(c, threshold = 30) {598if (!c) return false;599return (Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b)) >= threshold;600}601602function getHue(c) {603if (!c) return 0;604const r = c.r / 255, g = c.g / 255, b = c.b / 255;605const max = Math.max(r, g, b), min = Math.min(r, g, b);606if (max === min) return 0;607const d = max - min;608let h;609if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;610else if (max === g) h = ((b - r) / d + 2) / 6;611else h = ((r - g) / d + 4) / 6;612return Math.round(h * 360);613}614615function colorToHex(c) {616if (!c) return '?';617return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');618}619620// --- cli/engine/rules/checks.mjs ---621const DETECTOR_IS_BROWSER = typeof window !== 'undefined';622623// ─── Section 3: Pure Detection ──────────────────────────────────────────────624625function checkBorders(tag, widths, colors, radius) {626if (BORDER_SAFE_TAGS.has(tag)) return [];627const findings = [];628const sides = ['Top', 'Right', 'Bottom', 'Left'];629630for (const side of sides) {631const w = widths[side];632if (w < 1 || isNeutralColor(colors[side])) continue;633634const otherSides = sides.filter(s => s !== side);635const maxOther = Math.max(...otherSides.map(s => widths[s]));636if (!(w >= 2 && (maxOther <= 1 || w >= maxOther * 2))) continue;637638const sn = side.toLowerCase();639const isSide = side === 'Left' || side === 'Right';640641if (isSide) {642if (radius > 0) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` });643else if (w >= 3) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px` });644} else {645if (radius > 0 && w >= 2) findings.push({ id: 'border-accent-on-rounded', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` });646}647}648649return findings;650}651652// Returns true if the given text is composed entirely of emoji characters653// (plus whitespace / variation selectors). Emojis render as multicolor glyphs654// regardless of CSS `color`, so contrast checks against the element's text655// color are meaningless for these nodes.656const 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;657const 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;658function isEmojiOnlyText(text) {659if (!text) return false;660if (!EMOJI_CHAR_RE.test(text)) return false;661return text.replace(EMOJI_CHARS_GLOBAL, '').trim() === '';662}663664function checkColors(opts) {665const { tag, textColor, bgColor, effectiveBg, effectiveBgStops, fontSize, fontWeight, hasDirectText, isEmojiOnly, bgClip, bgImage, classList } = opts;666if (SAFE_TAGS.has(tag)) {667// Exception for <a> and <button> elements styled as buttons. SAFE_TAGS668// exists to suppress contrast noise on inline links and unstyled controls,669// where the element has no own background and the contrast against the670// ancestor surface is already the intended visual. When the element has671// its own opaque background and direct text, it is a styled button — and672// contrast on its own surface is a real, frequent bug worth flagging.673const isStyledButton = (tag === 'a' || tag === 'button')674&& hasDirectText675&& bgColor && bgColor.a > 0.5;676if (!isStyledButton) return [];677}678const findings = [];679680if (hasDirectText && textColor && !isEmojiOnly) {681// Run background-dependent checks against either a solid bg or, if the682// ancestor is a gradient, against every gradient stop (use the worst case).683const bgs = effectiveBg ? [effectiveBg] : (effectiveBgStops && effectiveBgStops.length ? effectiveBgStops : null);684if (bgs) {685// Gray on colored background — flag if every stop is chromatic686const textLum = relativeLuminance(textColor);687const isGray = !hasChroma(textColor, 20) && textLum > 0.05 && textLum < 0.85;688if (isGray && bgs.every(b => hasChroma(b, 40))) {689const bgLabel = effectiveBg ? colorToHex(effectiveBg) : `gradient(${bgs.map(colorToHex).join(', ')})`;690findings.push({ id: 'gray-on-color', snippet: `text ${colorToHex(textColor)} on bg ${bgLabel}` });691}692693// Low contrast (WCAG AA) — worst case across all bg stops694const ratios = bgs.map(b => contrastRatio(textColor, b));695let worstIdx = 0;696for (let i = 1; i < ratios.length; i++) if (ratios[i] < ratios[worstIdx]) worstIdx = i;697const ratio = ratios[worstIdx];698const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700);699const threshold = isLargeText ? 3.0 : 4.5;700if (ratio < threshold) {701// Skip the false-positive class where text has alpha < 1 AND we702// couldn't find an opaque ancestor (effectiveBg is null, we're703// comparing against gradient-stop fallback). In jsdom mode the704// detector can't resolve `var(--X)` color tokens, so a dark705// section sitting between the text and the body's decorative706// gradient is invisible to us — we end up measuring contrast707// against the body's paper-grain noise instead of the real708// local bg. Real low-contrast bugs use alpha=1 and have a709// resolvable opaque ancestor; semi-transparent Tailwind tokens710// like `text-paper/60` on `bg-ink` sections are the FP pattern.711const isAlphaFallbackFP = !DETECTOR_IS_BROWSER && !effectiveBg && (textColor.a != null && textColor.a < 1);712if (!isAlphaFallbackFP) {713findings.push({ id: 'low-contrast', snippet: `${ratio.toFixed(1)}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(bgs[worstIdx])}` });714}715}716}717718// AI palette: purple/violet on headings719if (hasChroma(textColor, 50)) {720const hue = getHue(textColor);721if (hue >= 260 && hue <= 310 && (['h1', 'h2', 'h3'].includes(tag) || fontSize >= 20)) {722findings.push({ id: 'ai-color-palette', snippet: `Purple/violet text (${colorToHex(textColor)}) on heading` });723}724}725}726727// Gradient text728if (bgClip === 'text' && bgImage && bgImage.includes('gradient')) {729findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });730}731732// Tailwind class checks733if (classList) {734const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');735736const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);737const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);738if (grayMatch && colorBgMatch) {739findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });740}741742if (/\bbg-clip-text\b/.test(classStr) && /\bbg-gradient-to-/.test(classStr)) {743findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' });744}745746const purpleText = classStr.match(/\btext-(?:purple|violet|indigo)-\d+\b/);747if (purpleText && (['h1', 'h2', 'h3'].includes(tag) || /\btext-(?:[2-9]xl)\b/.test(classStr))) {748findings.push({ id: 'ai-color-palette', snippet: `${purpleText[0]} on heading` });749}750751if (/\bfrom-(?:purple|violet|indigo)-\d+\b/.test(classStr) && /\bto-(?:purple|violet|indigo|blue|cyan|pink|fuchsia)-\d+\b/.test(classStr)) {752findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet gradient (Tailwind)' });753}754}755756return findings;757}758759function isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg) {760if (!hasShadow && !hasBorder) return false;761return hasRadius || hasBg;762}763764const HEADING_TAGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);765766// Pure check: given a heading and metrics about its previousElementSibling,767// decide if the sibling is the canonical "icon-tile-stacked-above-heading" shape.768//769// Triggers when ALL of the following hold for the sibling:770// • size 32–128px on both axes (not too small, not a hero image)771// • aspect ratio 0.7–1.4 (squarish — excludes wide thumbnails / pill badges)772// • has a non-transparent background-color, background-image, OR a visible border773// (covers solid colors, white-with-border, gradients — anything that visually774// defines a tile)775// • border-radius < width/2 (excludes round avatars; rounded squares pass)776// • contains an <svg> or icon-class <i> element that's smaller than the tile777// • the tile sits above the heading (its bottom is above the heading's top)778function checkIconTile(opts) {779const { headingTag, headingText, headingTop,780siblingTag, siblingWidth, siblingHeight, siblingBottom,781siblingBgColor, siblingBgImage, siblingBorderWidth, siblingBorderRadius,782hasIconChild, iconChildWidth } = opts;783if (!HEADING_TAGS.has(headingTag)) return [];784if (!siblingTag) return [];785// Don't recurse into nested headings (e.g. h2 above h3 in a section header)786if (HEADING_TAGS.has(siblingTag)) return [];787788// Size window: 32–128px on each axis789if (!(siblingWidth >= 32 && siblingWidth <= 128)) return [];790if (!(siblingHeight >= 32 && siblingHeight <= 128)) return [];791792// Squarish aspect ratio793const ratio = siblingWidth / siblingHeight;794if (ratio < 0.7 || ratio > 1.4) return [];795796// Must have something that visually defines the tile797const bgVisible = (siblingBgColor && siblingBgColor.a > 0.1)798|| (siblingBgImage && siblingBgImage !== 'none' && siblingBgImage !== '');799const borderVisible = siblingBorderWidth > 0;800if (!bgVisible && !borderVisible) return [];801802// Exclude circles (avatars). Rounded squares pass.803if (siblingBorderRadius >= siblingWidth / 2) return [];804805// Must contain an icon element smaller than the tile806if (!hasIconChild) return [];807if (iconChildWidth && iconChildWidth >= siblingWidth * 0.95) return [];808809// Vertical stacking: tile must end above where the heading starts.810// (Allow the check to skip when both top/bottom are 0 — jsdom layout case.)811if (headingTop && siblingBottom && siblingBottom > headingTop + 4) return [];812813const text = (headingText || '').trim().slice(0, 60);814return [{815id: 'icon-tile-stack',816snippet: `${Math.round(siblingWidth)}x${Math.round(siblingHeight)}px icon tile above ${headingTag} "${text}"`,817}];818}819820// Resolve the primary (non-generic) face from a font-family string and return821// whether the resolved primary is serif. Two paths:822// 1. Primary face is in KNOWN_SERIF_FONTS → serif.823// 2. Primary face is unknown but the stack ends in the generic `serif`824// token → treat as serif. Authors who declare `font-family: 'X', serif`825// almost always have a serif primary; a sans declared with a serif826// fallback is a code smell, not the common case.827// Returns { primary, isSerif } so the snippet can name the face.828function resolveSerif(fontFamily) {829if (!fontFamily) return { primary: null, isSerif: false };830const tokens = fontFamily.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());831const primary = tokens.find(f => f && !GENERIC_FONTS.has(f)) || null;832if (!primary) return { primary: null, isSerif: false };833if (KNOWN_SERIF_FONTS.has(primary)) return { primary, isSerif: true };834if (tokens.includes('serif')) return { primary, isSerif: true };835return { primary, isSerif: false };836}837838function checkItalicSerif(opts) {839const { tag, fontStyle, fontFamily, fontSize, headingText } = opts;840if (fontStyle !== 'italic') return [];841// Anchor the rule on hero-scale text. h1 is the canonical hero element;842// h2 ≥ 48px catches the cases where the design demotes the visual hero843// to an h2 but keeps the size.844if (tag !== 'h1' && !(tag === 'h2' && fontSize >= 48)) return [];845if (fontSize < 48) return [];846const { primary, isSerif } = resolveSerif(fontFamily);847if (!isSerif) return [];848849const text = (headingText || '').trim().slice(0, 60);850return [{851id: 'italic-serif-display',852snippet: `italic serif ${tag} (${primary || 'serif'}) at ${Math.round(fontSize)}px "${text}"`,853}];854}855856// Color saturation check. Returns true when the color has visible857// chroma — i.e., it's an "accent color" rather than near-neutral.858// Handles rgb()/rgba(), #hex, oklch(), and hsl(). var() refs are859// expected to be pre-resolved by the caller.860function isAccentColor(cssColor) {861if (!cssColor) return false;862const s = String(cssColor).trim();863// rgb / rgba — direct channel-distance check.864const rgbM = /rgba?\(\s*(\d+)\s*,?\s+|\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(s.replace(/rgba?\(\s*/, 'rgb(').replace(/,/g, ', '));865const rgbStrict = /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(s);866if (rgbStrict) {867const r = +rgbStrict[1], g = +rgbStrict[2], b = +rgbStrict[3];868return (Math.max(r, g, b) - Math.min(r, g, b)) >= 40;869}870// #hex — 3, 4, 6, or 8 digit.871const hexM = /^#([0-9a-f]{3,8})\b/i.exec(s);872if (hexM) {873let h = hexM[1];874if (h.length === 3 || h.length === 4) h = h.split('').map((c) => c + c).join('').slice(0, 6);875else h = h.slice(0, 6);876if (h.length === 6) {877const r = parseInt(h.slice(0, 2), 16);878const g = parseInt(h.slice(2, 4), 16);879const b = parseInt(h.slice(4, 6), 16);880return (Math.max(r, g, b) - Math.min(r, g, b)) >= 40;881}882}883// oklch(L C H) — chroma C is what matters. Typical neutral grays884// have C < 0.02; visible accents are 0.05+. CSS minification can885// collapse spaces between L% and C ("oklch(43%.15 34)"), so we886// extract all numbers and take the second rather than matching a887// strict L-then-whitespace-then-C pattern.888if (/^oklch\(/i.test(s)) {889const nums = s.match(/\d*\.\d+|\d+/g);890if (nums && nums.length >= 2) {891const c = parseFloat(nums[1]);892return !Number.isNaN(c) && c >= 0.05;893}894}895// hsl(H, S%, L%) — saturation > 20% reads as accent.896const hslM = /hsla?\(\s*[\d.]+\s*,\s*([\d.]+)%/i.exec(s);897if (hslM) {898const sat = parseFloat(hslM[1]);899return !Number.isNaN(sat) && sat >= 20;900}901return false;902}903904// Sibling-relationship rule. Anchor on a hero-scale h1, look at the905// previousElementSibling, and gate on EITHER the classic tracked-906// uppercase eyebrow OR the modern accent-colored bold eyebrow.907function checkHeroEyebrow(opts) {908const {909headingTag, headingText, headingFontSize,910siblingTag, siblingText, siblingTextTransform,911siblingFontSize, siblingLetterSpacing,912siblingFontWeight, siblingColor,913} = opts;914if (headingTag !== 'h1') return [];915// We previously gated on headingFontSize >= 48 to anchor "hero scale".916// But modern hero h1s use clamp() / vw / var(--text-*), none of which917// jsdom can resolve — the computed value comes back as "2em" or918// "var(--text-9xl)" and parseFloat returns 2 or NaN. The gate fails919// on virtually every Tailwind v4 / framework build. The other gates920// (sibling text 2-60 chars, font-size ≤ 14px, accent-bold OR921// tracked-caps) are tight enough to avoid false positives on non-922// hero h1s — a tiny tan label directly above any h1 is the923// antipattern regardless of how big the h1 ends up.924if (!siblingTag) return [];925// An h2 above an h1 is a different anti-pattern (heading hierarchy / dual926// headings) — never an eyebrow.927if (HEADING_TAGS.has(siblingTag)) return [];928929const text = (siblingText || '').trim();930if (text.length < 2 || text.length > 60) return [];931if (!(siblingFontSize > 0 && siblingFontSize <= 14)) return [];932933// Branch A: classic tracked-uppercase eyebrow.934const isUppercased = siblingTextTransform === 'uppercase'935|| (/[A-Z]/.test(text) && !/[a-z]/.test(text));936const isClassicTracked = isUppercased && siblingLetterSpacing >= 1.6;937938// Branch B: modern accent-bold eyebrow — sentence case, low939// tracking, but bold + accent-colored. The style choices changed;940// the pattern is the same kicker-above-headline anti-pattern.941const weight = Number(siblingFontWeight) || 400;942const isAccentBold = weight >= 700 && isAccentColor(siblingColor || '');943944if (!isClassicTracked && !isAccentBold) return [];945946const headingTextSnippet = (headingText || '').trim().slice(0, 60);947const eyebrowSnippet = text.slice(0, 40);948const style = isClassicTracked ? 'tracked-caps' : 'accent-bold';949return [{950id: 'hero-eyebrow-chip',951snippet: `eyebrow chip (${style}) "${eyebrowSnippet}" above ${headingTag} "${headingTextSnippet}"`,952}];953}954955function checkRepeatedSectionKickers(opts) {956const { candidates, minCount = 3 } = opts;957if (!Array.isArray(candidates) || candidates.length < minCount) return [];958return candidates.map(candidate => ({959id: 'repeated-section-kickers',960snippet: `repeated section kicker "${candidate.kickerText}" before ${candidate.headingTag} "${candidate.headingText}" (${candidates.length} on page)`,961}));962}963964const LAYOUT_TRANSITION_PROPS = new Set([965'width', 'height', 'padding', 'margin',966'max-height', 'max-width', 'min-height', 'min-width',967'padding-top', 'padding-right', 'padding-bottom', 'padding-left',968'margin-top', 'margin-right', 'margin-bottom', 'margin-left',969]);970971function checkMotion(opts) {972const { tag, transitionProperty, animationName, timingFunctions, classList } = opts;973if (SAFE_TAGS.has(tag)) return [];974const findings = [];975976// --- Bounce/elastic easing ---977if (animationName && animationName !== 'none' && /bounce|elastic|wobble|jiggle|spring/i.test(animationName)) {978findings.push({ id: 'bounce-easing', snippet: `animation: ${animationName}` });979}980if (classList && /\banimate-bounce\b/.test(classList)) {981findings.push({ id: 'bounce-easing', snippet: 'animate-bounce (Tailwind)' });982}983984// Check timing functions for overshoot cubic-bezier (y values outside [0, 1])985if (timingFunctions) {986const bezierRe = /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g;987let m;988while ((m = bezierRe.exec(timingFunctions)) !== null) {989const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]);990if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) {991findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${m[1]}, ${m[2]}, ${m[3]}, ${m[4]})` });992break;993}994}995}996997// --- Layout property transition ---998if (transitionProperty && transitionProperty !== 'all' && transitionProperty !== 'none') {999const props = transitionProperty.split(',').map(p => p.trim().toLowerCase());1000const layoutFound = props.filter(p => LAYOUT_TRANSITION_PROPS.has(p));1001if (layoutFound.length > 0) {1002findings.push({ id: 'layout-transition', snippet: `transition: ${layoutFound.join(', ')}` });1003}1004}10051006return findings;1007}10081009function checkGlow(opts) {1010const { boxShadow, effectiveBg } = opts;1011if (!boxShadow || boxShadow === 'none') return [];1012if (!effectiveBg) return [];10131014// Only flag on dark backgrounds (luminance < 0.1)1015const bgLum = relativeLuminance(effectiveBg);1016if (bgLum >= 0.1) return [];10171018// Split multiple shadows (commas not inside parentheses)1019const parts = boxShadow.split(/,(?![^(]*\))/);1020for (const shadow of parts) {1021const colorMatch = shadow.match(/rgba?\([^)]+\)/);1022if (!colorMatch) continue;1023const color = parseRgb(colorMatch[0]);1024if (!color || !hasChroma(color, 30)) continue;10251026// Extract px values — in computed style: "color Xpx Ypx BLURpx [SPREADpx]"1027const afterColor = shadow.substring(shadow.indexOf(colorMatch[0]) + colorMatch[0].length);1028const beforeColor = shadow.substring(0, shadow.indexOf(colorMatch[0]));1029const pxVals = [...beforeColor.matchAll(/([\d.]+)px/g), ...afterColor.matchAll(/([\d.]+)px/g)]1030.map(m => parseFloat(m[1]));10311032// Third value is blur (offset-x, offset-y, blur, [spread])1033if (pxVals.length >= 3 && pxVals[2] > 4) {1034return [{ id: 'dark-glow', snippet: `Colored glow (${colorToHex(color)}) on dark background` }];1035}1036}10371038return [];1039}10401041/**1042* Regex-on-HTML checks shared between browser and Node page-level detection.1043* These don't need DOM access, just the raw HTML string.1044*/1045function checkHtmlPatterns(html) {1046const findings = [];10471048// --- Color ---10491050// AI color palette: purple/violet1051const purpleHexRe = /#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9|6366f1|764ba2|667eea)\b/gi;1052if (purpleHexRe.test(html)) {1053const purpleTextRe = /(?:(?:^|;)\s*color\s*:\s*(?:.*?)(?:#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9))|gradient.*?#(?:7c3aed|8b5cf6|a855f7|764ba2|667eea))/gi;1054if (purpleTextRe.test(html)) {1055findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected' });1056}1057}10581059// Gradient text (background-clip: text + gradient)1060const gradientRe = /(?:-webkit-)?background-clip\s*:\s*text/gi;1061let gm;1062while ((gm = gradientRe.exec(html)) !== null) {1063const start = Math.max(0, gm.index - 200);1064const context = html.substring(start, gm.index + gm[0].length + 200);1065if (/gradient/i.test(context)) {1066findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });1067break;1068}1069}1070if (/\bbg-clip-text\b/.test(html) && /\bbg-gradient-to-/.test(html)) {1071findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' });1072}10731074// --- Layout ---10751076// Monotonous spacing1077const spacingValues = [];1078const spacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*(\d+)px/gi;1079let sm;1080while ((sm = spacingRe.exec(html)) !== null) {1081const v = parseInt(sm[1], 10);1082if (v > 0 && v < 200) spacingValues.push(v);1083}1084const gapRe = /gap\s*:\s*(\d+)px/gi;1085while ((sm = gapRe.exec(html)) !== null) {1086spacingValues.push(parseInt(sm[1], 10));1087}1088const twSpaceRe = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+)\b/g;1089while ((sm = twSpaceRe.exec(html)) !== null) {1090spacingValues.push(parseInt(sm[1], 10) * 4);1091}1092const remSpacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*([\d.]+)rem/gi;1093while ((sm = remSpacingRe.exec(html)) !== null) {1094const v = Math.round(parseFloat(sm[1]) * 16);1095if (v > 0 && v < 200) spacingValues.push(v);1096}1097const roundedSpacing = spacingValues.map(v => Math.round(v / 4) * 4);1098if (roundedSpacing.length >= 10) {1099const counts = {};1100for (const v of roundedSpacing) counts[v] = (counts[v] || 0) + 1;1101const maxCount = Math.max(...Object.values(counts));1102const dominantPct = maxCount / roundedSpacing.length;1103const unique = [...new Set(roundedSpacing)].filter(v => v > 0);1104if (dominantPct > 0.6 && unique.length <= 3) {1105const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];1106findings.push({1107id: 'monotonous-spacing',1108snippet: `~${dominant}px used ${maxCount}/${roundedSpacing.length} times (${Math.round(dominantPct * 100)}%)`,1109});1110}1111}11121113// --- Motion ---11141115// Bounce/elastic animation names1116const bounceRe = /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi;1117const bounceMatch = bounceRe.exec(html);1118if (bounceMatch) {1119const animationToken = bounceMatch[1]1120.split(/[,\s]+/)1121.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));1122findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}` });1123}11241125// Overshoot cubic-bezier1126const bezierRe = /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g;1127let bm;1128while ((bm = bezierRe.exec(html)) !== null) {1129const y1 = parseFloat(bm[2]), y2 = parseFloat(bm[4]);1130if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) {1131findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})` });1132break;1133}1134}11351136// Layout property transitions1137const transRe = /transition(?:-property)?\s*:\s*([^;{}]+)/gi;1138let tm;1139while ((tm = transRe.exec(html)) !== null) {1140const val = tm[1].toLowerCase();1141if (/\ball\b/.test(val)) continue;1142const found = val.match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);1143if (found) {1144findings.push({ id: 'layout-transition', snippet: `transition: ${found.join(', ')}` });1145break;1146}1147}11481149// --- Dark glow ---11501151const 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;1152const twDarkBg = /\bbg-(?:gray|slate|zinc|neutral|stone)-(?:9\d{2}|800)\b/;1153if (darkBgRe.test(html) || twDarkBg.test(html)) {1154const shadowRe = /box-shadow\s*:\s*([^;{}]+)/gi;1155let shm;1156while ((shm = shadowRe.exec(html)) !== null) {1157const val = shm[1];1158const colorMatch = val.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);1159if (!colorMatch) continue;1160const [r, g, b] = [+colorMatch[1], +colorMatch[2], +colorMatch[3]];1161if ((Math.max(r, g, b) - Math.min(r, g, b)) < 30) continue;1162const pxVals = [...val.matchAll(/(\d+)px|(?<![.\d])\b(0)\b(?![.\d])/g)].map(p => +(p[1] || p[2]));1163if (pxVals.length >= 3 && pxVals[2] > 4) {1164findings.push({ id: 'dark-glow', snippet: `Colored glow (rgb(${r},${g},${b})) on dark page` });1165break;1166}1167}1168}11691170// --- Provider tells (gated): repeating-gradient stripes (GPT) ---1171if (/repeating-(?:linear|radial|conic)-gradient\s*\(/i.test(html)) {1172findings.push({ id: 'repeating-stripes-gradient', snippet: 'repeating-gradient decorative stripes' });1173}11741175// --- Provider tells (gated): "X theater" framing copy (GPT) ---1176// Lives here (regex-on-HTML) rather than in the text-content analyzers so it1177// runs in the bundled browser path too, not just the CLI/static path.1178{1179const bodyText = html1180.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ')1181.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ')1182.replace(/<[^>]+>/g, ' ');1183const tm = /\b(\w+)\s+theater\b/i.exec(bodyText);1184if (tm) findings.push({ id: 'theater-slop-phrase', snippet: `"${tm[0].trim()}"` });1185}11861187// --- Provider tells (gated): image hover transform (Gemini) ---1188// A CSS `img...:hover { transform: ... }` rule, or a Tailwind hover:scale /1189// hover:rotate / hover:translate utility on an <img>. Each distinct1190// mechanism is its own finding.1191const imgHoverCss = /\bimg\b[^,{}]*:hover\b[^{}]*\{[^}]*\btransform\s*:\s*(?:scale|rotate|translate|matrix|skew)/i;1192if (imgHoverCss.test(html)) {1193findings.push({ id: 'image-hover-transform', snippet: 'img:hover { transform } rule' });1194}1195const imgTagRe = /<img\b[^>]*\bclass\s*=\s*"([^"]*)"/gi;1196let im;1197while ((im = imgTagRe.exec(html)) !== null) {1198if (/\bhover:(?:scale|rotate|translate|skew)-/.test(im[1])) {1199findings.push({ id: 'image-hover-transform', snippet: 'Tailwind hover transform on <img>' });1200}1201}12021203return findings;1204}12051206// ─── Section 4: resolveBackground (unified) ─────────────────────────────────12071208// Read the element's own background color, computed-style first, with a1209// jsdom-friendly fallback that parses the inline `background:` shorthand1210// from the raw style attribute. jsdom (~v29) does not decompose the1211// shorthand into `backgroundColor`, so without this fallback the CLI silently1212// returns null for any element styled via `background: rgb(...)` or1213// `background: #abc`. Real browsers always decompose, so the fallback is1214// a no-op there.1215function readOwnBackgroundColor(el, computedStyle) {1216const bg = parseRgb(computedStyle.backgroundColor);1217if (DETECTOR_IS_BROWSER || (bg && bg.a >= 0.1)) return bg;1218const rawStyle = el.getAttribute?.('style') || '';1219const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);1220const inlineBg = bgMatch ? bgMatch[1].trim() : '';1221if (!inlineBg) return bg;1222if (/gradient/i.test(inlineBg) || /url\s*\(/i.test(inlineBg)) return bg;1223const fromRgb = parseRgb(inlineBg);1224if (fromRgb) return fromRgb;1225const hexMatch = inlineBg.match(/#([0-9a-f]{6}|[0-9a-f]{3})\b/i);1226if (hexMatch) {1227const h = hexMatch[1];1228if (h.length === 6) {1229return { r: parseInt(h.slice(0, 2), 16), g: parseInt(h.slice(2, 4), 16), b: parseInt(h.slice(4, 6), 16), a: 1 };1230}1231return { r: parseInt(h[0] + h[0], 16), g: parseInt(h[1] + h[1], 16), b: parseInt(h[2] + h[2], 16), a: 1 };1232}1233return bg;1234}12351236function resolveBackground(el, win, customPropMap) {1237let current = el;1238while (current && current.nodeType === 1) {1239const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);1240const bgImage = style.backgroundImage || '';1241const hasGradientOrUrl = bgImage && bgImage !== 'none' && (/gradient/i.test(bgImage) || /url\s*\(/i.test(bgImage));12421243// Try the solid bg-color FIRST. If the element has both a solid color1244// and a gradient/url overlay (a common pattern: `background: var(--paper)1245// radial-gradient(...)` for paper-grain texture), the solid color is the1246// dominant visible surface for contrast purposes; the overlay is1247// decorative. The old behavior bailed on any gradient ancestor, which1248// caused massive false-positive contrast findings on grain-textured1249// body backgrounds.1250let bg = parseRgb(style.backgroundColor);1251if (!DETECTOR_IS_BROWSER && (!bg || bg.a < 0.1)) {1252// jsdom returns literal "var(--X)" / "oklch(...)" strings. Resolve1253// through customPropMap so Tailwind v4 color tokens become RGB.1254if (customPropMap) {1255bg = parseColorResolved(style.backgroundColor, customPropMap);1256}1257if (!bg || bg.a < 0.1) {1258// Inline-style fallback. jsdom doesn't decompose background1259// shorthand, so colors set via inline style are otherwise invisible.1260const rawStyle = current.getAttribute?.('style') || '';1261const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);1262const inlineBg = bgMatch ? bgMatch[1].trim() : '';1263if (inlineBg && !/gradient/i.test(inlineBg) && !/url\s*\(/i.test(inlineBg)) {1264bg = parseColorResolved(inlineBg, customPropMap) || parseAnyColor(inlineBg);1265}1266}1267}12681269if (bg && bg.a > 0.1) {1270if (DETECTOR_IS_BROWSER || bg.a >= 0.5) return bg;1271}1272// No solid bg-color at this level. If THIS level has a gradient/url1273// with no underlying solid color we can read:1274// • on body/html: assume white. Body-level gradients are almost1275// always decorative texture (paper grain, noise) on top of a1276// solid bg-color the page set via `background: var(--paper)`1277// shorthand — which jsdom can't decompose into bg-color. The1278// downstream gradient-stops fallback path produces catastrophic1279// false positives in this case (gradient noise stops have1280// accidental browns/blacks that look like card backgrounds).1281// • on other elements: bail to null and let the caller fall back1282// to gradient stops (gradient buttons / hero sections are real1283// bgs worth checking against).1284if (hasGradientOrUrl) {1285if (current.tagName === 'BODY' || current.tagName === 'HTML') {1286return { r: 255, g: 255, b: 255, a: 1 };1287}1288return null;1289}1290current = current.parentElement;1291}1292return { r: 255, g: 255, b: 255 };1293}12941295// Walk parents looking for a gradient background and return its color stops.1296// Used as a fallback when resolveBackground() returns null because the1297// effective background is a gradient (no single solid color to compare against).1298function resolveGradientStops(el, win) {1299let current = el;1300while (current && current.nodeType === 1) {1301const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);1302const bgImage = style.backgroundImage || '';1303if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {1304const stops = parseGradientColors(bgImage);1305if (stops.length > 0) return stops;1306}1307if (!DETECTOR_IS_BROWSER) {1308// jsdom doesn't decompose `background:` shorthand — peek at the raw inline style1309const rawStyle = current.getAttribute?.('style') || '';1310const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);1311if (bgMatch && /gradient/i.test(bgMatch[1])) {1312const stops = parseGradientColors(bgMatch[1]);1313if (stops.length > 0) return stops;1314}1315}1316current = current.parentElement;1317}1318return null;1319}13201321// Parse a single CSS length token to pixels. Accepts "12px", "50%", a1322// shorthand like "12px 4px" (uses the first value), or empty / null.1323// Returns the pixel value, or null when the input is unparseable.1324// Percentages convert against `widthPx` when one is supplied. Without a1325// usable width (jsdom returns "auto" for many real-world elements,1326// which parseFloat collapses to 0), fall back to the raw percentage1327// number so callers gating on `> 0` (border-accent-on-rounded,1328// isCardLike's hasRadius) still see a positive value, matching the1329// original parseFloat("50%") === 50 behavior.1330function parseRadiusToPx(value, widthPx) {1331if (!value || typeof value !== 'string') return null;1332const trimmed = value.trim();1333if (!trimmed) return null;1334const first = trimmed.split(/\s+/)[0];1335const num = parseFloat(first);1336if (Number.isNaN(num)) return null;1337if (/%$/.test(first)) {1338if (widthPx && widthPx > 0) return (num / 100) * widthPx;1339return num;1340}1341return num;1342}13431344function resolveBorderRadiusPx(el, style, widthPx, win) {1345const fromComputed = parseRadiusToPx(style.borderRadius, widthPx);1346if (fromComputed !== null) return fromComputed;1347return 0;1348}13491350// ─── Section 5: Element Adapters ────────────────────────────────────────────13511352// Browser adapters — call getComputedStyle/getBoundingClientRect on live DOM13531354function checkElementBordersDOM(el) {1355const tag = el.tagName.toLowerCase();1356if (BORDER_SAFE_TAGS.has(tag)) return [];1357const rect = el.getBoundingClientRect();1358if (rect.width < 20 || rect.height < 20) return [];1359const style = getComputedStyle(el);1360const sides = ['Top', 'Right', 'Bottom', 'Left'];1361const widths = {}, colors = {};1362for (const s of sides) {1363widths[s] = parseFloat(style[`border${s}Width`]) || 0;1364colors[s] = style[`border${s}Color`] || '';1365}1366return checkBorders(tag, widths, colors, parseFloat(style.borderRadius) || 0);1367}13681369function checkElementColorsDOM(el) {1370const tag = el.tagName.toLowerCase();1371// No early SAFE_TAGS bail here — checkColors() does its own gating that1372// includes the styled-button exception for <a> / <button> with their own1373// opaque background. Bailing here would prevent that exception from firing.1374const rect = el.getBoundingClientRect();1375if (rect.width < 10 || rect.height < 10) return [];1376const style = getComputedStyle(el);1377const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');1378const hasDirectText = directText.trim().length > 0;1379const effectiveBg = resolveBackground(el);1380return checkColors({1381tag,1382textColor: parseRgb(style.color),1383bgColor: readOwnBackgroundColor(el, style),1384effectiveBg,1385effectiveBgStops: effectiveBg ? null : resolveGradientStops(el),1386fontSize: parseFloat(style.fontSize) || 16,1387fontWeight: parseInt(style.fontWeight) || 400,1388hasDirectText,1389isEmojiOnly: isEmojiOnlyText(directText),1390bgClip: style.webkitBackgroundClip || style.backgroundClip || '',1391bgImage: style.backgroundImage || '',1392classList: el.getAttribute('class') || '',1393});1394}13951396function checkElementIconTileDOM(el) {1397const tag = el.tagName.toLowerCase();1398if (!HEADING_TAGS.has(tag)) return [];1399const sibling = el.previousElementSibling;1400if (!sibling) return [];14011402const sibRect = sibling.getBoundingClientRect();1403const headRect = el.getBoundingClientRect();1404const sibStyle = getComputedStyle(sibling);14051406// The tile may either contain an <svg>/<i> icon child, OR the tile itself1407// may contain an emoji/symbol character directly as its only text content1408// (the "card-icon" pattern from many AI-generated demos).1409const iconChild = sibling.querySelector('svg, i[data-lucide], i[class*="fa-"], i[class*="icon"]');1410const iconRect = iconChild?.getBoundingClientRect();1411const sibDirectText = [...sibling.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');1412const hasInlineEmojiIcon = sibling.children.length === 0 && isEmojiOnlyText(sibDirectText);14131414return checkIconTile({1415headingTag: tag,1416headingText: el.textContent || '',1417headingTop: headRect.top,1418siblingTag: sibling.tagName.toLowerCase(),1419siblingWidth: sibRect.width,1420siblingHeight: sibRect.height,1421siblingBottom: sibRect.bottom,1422siblingBgColor: parseRgb(sibStyle.backgroundColor),1423siblingBgImage: sibStyle.backgroundImage || '',1424siblingBorderWidth: parseFloat(sibStyle.borderTopWidth) || 0,1425siblingBorderRadius: parseFloat(sibStyle.borderRadius) || 0,1426hasIconChild: !!iconChild || hasInlineEmojiIcon,1427iconChildWidth: iconRect?.width || 0,1428});1429}14301431function checkElementItalicSerifDOM(el) {1432const tag = el.tagName.toLowerCase();1433if (tag !== 'h1' && tag !== 'h2') return [];1434const style = getComputedStyle(el);1435return checkItalicSerif({1436tag,1437fontStyle: style.fontStyle || '',1438fontFamily: style.fontFamily || '',1439fontSize: parseFloat(style.fontSize) || 0,1440headingText: el.textContent || '',1441});1442}14431444function checkElementHeroEyebrowDOM(el) {1445const tag = el.tagName.toLowerCase();1446if (tag !== 'h1') return [];1447const sibling = el.previousElementSibling;1448if (!sibling) return [];1449const headStyle = getComputedStyle(el);1450const sibStyle = getComputedStyle(sibling);1451return checkHeroEyebrow({1452headingTag: tag,1453headingText: el.textContent || '',1454headingFontSize: parseFloat(headStyle.fontSize) || 0,1455siblingTag: sibling.tagName.toLowerCase(),1456siblingText: sibling.textContent || '',1457siblingTextTransform: sibStyle.textTransform || '',1458siblingFontSize: parseFloat(sibStyle.fontSize) || 0,1459siblingLetterSpacing: parseFloat(sibStyle.letterSpacing) || 0,1460siblingFontWeight: sibStyle.fontWeight || '',1461siblingColor: sibStyle.color || '',1462});1463}14641465// Build a map of CSS custom properties declared on :root / :host / html.1466// Used to resolve var(--X) refs that jsdom returns verbatim in1467// getComputedStyle. Tailwind v4 routes every utility class through1468// CSS vars (font-weight: var(--font-weight-bold), font-size:1469// var(--text-xs), letter-spacing: var(--tracking-widest)), so without1470// resolution every style-based check silently fails on Tailwind v41471// builds — the values come back as literal "var(--font-weight-bold)"1472// strings and parseFloat returns NaN.1473function buildCustomPropMap(document) {1474const map = new Map();1475let sheets;1476try { sheets = Array.from(document.styleSheets || []); }1477catch { return map; }1478for (const sheet of sheets) {1479let rules;1480try { rules = Array.from(sheet.cssRules || []); }1481catch { continue; }1482for (const rule of rules) {1483// Style rules only (type 1). Walk @media / @supports if present.1484if (rule.type === 4 /* MEDIA_RULE */ || rule.type === 12 /* SUPPORTS_RULE */) {1485try { rules.push(...Array.from(rule.cssRules || [])); } catch { /* ignore */ }1486continue;1487}1488if (rule.type !== 1 /* STYLE_RULE */) continue;1489const sel = rule.selectorText || '';1490if (!/(^|,\s*)(:root|html|:host)\b/i.test(sel)) continue;1491const style = rule.style;1492if (!style) continue;1493for (let i = 0; i < style.length; i++) {1494const prop = style[i];1495if (!prop || !prop.startsWith('--')) continue;1496const val = style.getPropertyValue(prop).trim();1497if (val) map.set(prop, val);1498}1499}1500}1501return map;1502}15031504// Resolve var(--X[, fallback]) refs in a computed-style value string.1505// Recurses up to 8 levels for chained refs (--a: var(--b)). Returns1506// the original string when no refs are present or the chain doesn't1507// resolve. Safe to call on already-resolved values.1508function resolveVarRefs(raw, customPropMap, depth = 0) {1509if (typeof raw !== 'string' || !raw.includes('var(')) return raw;1510if (depth > 8) return raw;1511return raw.replace(/var\(\s*(--[a-zA-Z0-9_-]+)\s*(?:,\s*([^)]+))?\)/g, (_m, name, fallback) => {1512const v = customPropMap.get(name);1513if (v != null) return resolveVarRefs(v, customPropMap, depth + 1);1514return fallback ? resolveVarRefs(fallback.trim(), customPropMap, depth + 1) : _m;1515});1516}15171518// OKLCH → sRGB conversion (Björn Ottosson's matrices). L in 0..1 (or %),1519// C in 0..~0.4 typical, H in degrees. Returns clamped {r,g,b,a:1} in 0..255.1520// Needed because jsdom doesn't compute oklch() values — getComputedStyle1521// returns the literal "oklch(...)" string. Without this, the entire1522// Tailwind v4 color palette (which is OKLCH-based) is invisible to the1523// detector's contrast / color checks.1524function oklchToRgb(L, C, H) {1525const hRad = (H * Math.PI) / 180;1526const a = C * Math.cos(hRad);1527const b = C * Math.sin(hRad);1528const l_ = L + 0.3963377774 * a + 0.2158037573 * b;1529const m_ = L - 0.1055613458 * a - 0.0638541728 * b;1530const s_ = L - 0.0894841775 * a - 1.2914855480 * b;1531const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;1532const rLin = 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc;1533const gLin = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc;1534const bLin = -0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc;1535const enc = (x) => {1536const c = Math.max(0, Math.min(1, x));1537return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;1538};1539return {1540r: Math.round(enc(rLin) * 255),1541g: Math.round(enc(gLin) * 255),1542b: Math.round(enc(bLin) * 255),1543a: 1,1544};1545}15461547// Extended color parser: rgb/rgba/hex/oklch. Returns null on no match.1548// Use this when the input might be any CSS color form; use plain parseRgb1549// when you only expect computed rgb() values from real browsers.1550function parseAnyColor(s) {1551if (!s || typeof s !== 'string') return null;1552const str = s.trim();1553if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;1554let m;1555m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+))?\s*\)/);1556if (m) return { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: m[4] !== undefined ? +m[4] : 1 };1557m = str.match(/^#([0-9a-f]{3,8})$/i);1558if (m) {1559const h = m[1];1560if (h.length === 3 || h.length === 4) {1561return {1562r: parseInt(h[0] + h[0], 16),1563g: parseInt(h[1] + h[1], 16),1564b: parseInt(h[2] + h[2], 16),1565a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,1566};1567}1568if (h.length === 6 || h.length === 8) {1569return {1570r: parseInt(h.slice(0, 2), 16),1571g: parseInt(h.slice(2, 4), 16),1572b: parseInt(h.slice(4, 6), 16),1573a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,1574};1575}1576}1577// OKLCH parser. Tailwind v4's CSS minifier squishes the space after1578// `%` ("21.5%.02 50"), so the separator between L and C may be absent.1579// Match L (with optional %), then C and H separated permissively.1580m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);1581if (m) {1582const Lnum = parseFloat(m[1]);1583const L = m[2] === '%' ? Lnum / 100 : Lnum;1584const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));1585if (m[5] !== undefined) {1586const alpha = parseFloat(m[5]);1587rgb.a = m[6] === '%' ? alpha / 100 : alpha;1588}1589return rgb;1590}1591return null;1592}15931594// Resolve var() refs in a color string (via customPropMap), then parse.1595// Returns null on any failure. Used in jsdom-mode paths where1596// getComputedStyle returns literal "var(--X)" or "oklch(...)" strings.1597function parseColorResolved(str, customPropMap) {1598if (!str) return null;1599const resolved = customPropMap ? resolveVarRefs(str, customPropMap) : str;1600return parseAnyColor(resolved);1601}16021603const REPEATED_KICKER_SKIP_SELECTOR = [1604'nav',1605'form',1606'table',1607'thead',1608'tbody',1609'tfoot',1610'figure',1611'figcaption',1612'ol',1613'ul',1614'li',1615'[role="navigation"]',1616'[aria-label*="breadcrumb" i]',1617'[class*="breadcrumb" i]',1618'[aria-hidden="true"]',1619'[data-impeccable-allow-kickers]',1620].join(',');16211622const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [1623'article',1624'button',1625'a',1626'li',1627'[role="listitem"]',1628'[role="option"]',1629].join(',');16301631function cleanInlineText(el) {1632return [...el.childNodes]1633.filter(n => n.nodeType === 3)1634.map(n => n.textContent)1635.join(' ')1636.replace(/\s+/g, ' ')1637.trim();1638}16391640function isRepeatedKickerCardContext(heading, kicker) {1641const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR);1642return Boolean(item && (!item.contains || item.contains(kicker)));1643}16441645function isRepeatedKickerCandidate(opts) {1646const {1647headingTag,1648headingText,1649headingFontSize,1650kickerTag,1651kickerText,1652kickerTextTransform,1653kickerFontSize,1654kickerLetterSpacing,1655} = opts;1656if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;1657if (!headingText || headingText.length < 3) return false;1658if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false;1659if (!(headingFontSize >= 20)) return false;1660if (!kickerTag || HEADING_TAGS.has(kickerTag)) return false;1661if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false;1662if (!kickerText || kickerText.length < 2 || kickerText.length > 34) return false;1663if (/^step\s*\d+/i.test(kickerText) || /^\d{1,2}$/.test(kickerText)) return false;16641665const isUppercased = kickerTextTransform === 'uppercase'1666|| (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText));1667if (!isUppercased) return false;1668if (!(kickerFontSize > 0 && kickerFontSize <= 14)) return false;1669const minTrackedSpacing = Math.max(1, kickerFontSize * 0.08);1670if (!(kickerLetterSpacing >= minTrackedSpacing)) return false;1671return true;1672}16731674function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpacing) {1675const candidates = [];1676for (const heading of doc.querySelectorAll('h2, h3, h4')) {1677if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;1678const kicker = heading.previousElementSibling;1679if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;1680if (isRepeatedKickerCardContext(heading, kicker)) continue;16811682const headingStyle = getStyle(heading);1683const kickerStyle = getStyle(kicker);1684const headingText = (heading.textContent || '').replace(/\s+/g, ' ').trim();1685const kickerText = cleanInlineText(kicker) || (kicker.textContent || '').replace(/\s+/g, ' ').trim();1686const headingFontSize = resolveLetterSpacing(headingStyle.fontSize || '', 16) || parseFloat(headingStyle.fontSize) || 0;1687const kickerFontSize = resolveLetterSpacing(kickerStyle.fontSize || '', 16) || parseFloat(kickerStyle.fontSize) || 0;1688const kickerLetterSpacing = resolveLetterSpacing(kickerStyle.letterSpacing || '', kickerFontSize);16891690if (!isRepeatedKickerCandidate({1691headingTag: heading.tagName.toLowerCase(),1692headingText,1693headingFontSize,1694kickerTag: kicker.tagName.toLowerCase(),1695kickerText,1696kickerTextTransform: kickerStyle.textTransform || '',1697kickerFontSize,1698kickerLetterSpacing,1699})) {1700continue;1701}17021703candidates.push({1704headingTag: heading.tagName.toLowerCase(),1705headingText: headingText.replace(/^"|"$/g, '').slice(0, 60),1706kickerText: kickerText.slice(0, 40),1707});1708}1709return candidates;1710}17111712function checkRepeatedSectionKickersDOM() {1713const candidates = collectRepeatedSectionKickerCandidates(1714document,1715(el) => getComputedStyle(el),1716(value, fontSize) => resolveLengthPx(value, fontSize) || 0,1717);1718return checkRepeatedSectionKickers({ candidates });1719}17201721function checkElementMotionDOM(el) {1722const tag = el.tagName.toLowerCase();1723if (SAFE_TAGS.has(tag)) return [];1724const style = getComputedStyle(el);1725return checkMotion({1726tag,1727transitionProperty: style.transitionProperty || '',1728animationName: style.animationName || '',1729timingFunctions: [style.animationTimingFunction, style.transitionTimingFunction].filter(Boolean).join(' '),1730classList: el.getAttribute('class') || '',1731});1732}17331734function checkElementGlowDOM(el) {1735const tag = el.tagName.toLowerCase();1736const style = getComputedStyle(el);1737if (!style.boxShadow || style.boxShadow === 'none') return [];1738// Use parent's background — glow radiates outward, so the surrounding context matters1739// If resolveBackground returns null (gradient), try to infer from the gradient colors1740let parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el);1741if (!parentBg) {1742// Gradient background — sample its colors to determine if it's dark1743let cur = el.parentElement;1744while (cur && cur.nodeType === 1) {1745const bgImage = getComputedStyle(cur).backgroundImage || '';1746const gradColors = parseGradientColors(bgImage);1747if (gradColors.length > 0) {1748// Average the gradient colors1749const avg = { r: 0, g: 0, b: 0 };1750for (const c of gradColors) { avg.r += c.r; avg.g += c.g; avg.b += c.b; }1751avg.r = Math.round(avg.r / gradColors.length);1752avg.g = Math.round(avg.g / gradColors.length);1753avg.b = Math.round(avg.b / gradColors.length);1754parentBg = avg;1755break;1756}1757cur = cur.parentElement;1758}1759}1760return checkGlow({ tag, boxShadow: style.boxShadow, effectiveBg: parentBg });1761}17621763function checkElementAIPaletteDOM(el) {1764const style = getComputedStyle(el);1765const findings = [];17661767// Check gradient backgrounds for purple/violet or cyan1768const bgImage = style.backgroundImage || '';1769const gradColors = parseGradientColors(bgImage);1770for (const c of gradColors) {1771if (hasChroma(c, 50)) {1772const hue = getHue(c);1773if (hue >= 260 && hue <= 310) {1774findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet gradient background' });1775break;1776}1777if (hue >= 160 && hue <= 200) {1778findings.push({ id: 'ai-color-palette', snippet: 'Cyan gradient background' });1779break;1780}1781}1782}17831784// Check for neon text (vivid cyan/purple color on dark background)1785const textColor = parseRgb(style.color);1786if (textColor && hasChroma(textColor, 80)) {1787const hue = getHue(textColor);1788const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);1789if (isAIPalette) {1790const parentBg = el.parentElement ? resolveBackground(el.parentElement) : null;1791// Also check gradient parents1792let effectiveBg = parentBg;1793if (!effectiveBg) {1794let cur = el.parentElement;1795while (cur && cur.nodeType === 1) {1796const gi = getComputedStyle(cur).backgroundImage || '';1797const gc = parseGradientColors(gi);1798if (gc.length > 0) {1799const avg = { r: 0, g: 0, b: 0 };1800for (const c of gc) { avg.r += c.r; avg.g += c.g; avg.b += c.b; }1801avg.r = Math.round(avg.r / gc.length);1802avg.g = Math.round(avg.g / gc.length);1803avg.b = Math.round(avg.b / gc.length);1804effectiveBg = avg;1805break;1806}1807cur = cur.parentElement;1808}1809}1810if (effectiveBg && relativeLuminance(effectiveBg) < 0.1) {1811const label = hue >= 260 ? 'Purple/violet' : 'Cyan';1812findings.push({ id: 'ai-color-palette', snippet: `${label} neon text on dark background` });1813}1814}1815}18161817return findings;1818}18191820const QUALITY_TEXT_TAGS = new Set(['p', 'li', 'td', 'th', 'dd', 'blockquote', 'figcaption']);18211822// Resolve a CSS font-size value to pixels by walking up the parent chain.1823// Browsers resolve em/rem/% to px in getComputedStyle, but jsdom returns the1824// specified value verbatim — so for the Node path we walk parents ourselves.1825function resolveFontSizePx(el, win) {1826const chain = []; // raw font-size strings, leaf → root1827let cur = el;1828while (cur && cur.nodeType === 1) {1829const fs = (win ? win.getComputedStyle(cur) : getComputedStyle(cur)).fontSize;1830chain.push(fs || '');1831cur = cur.parentElement;1832}1833// Walk root → leaf, resolving each value relative to its parent context.1834let px = 16; // root default1835for (let i = chain.length - 1; i >= 0; i--) {1836const v = chain[i];1837if (!v || v === 'inherit') continue;1838const num = parseFloat(v);1839if (isNaN(num)) continue;1840if (v.endsWith('px')) px = num;1841else if (v.endsWith('rem')) px = num * 16;1842else if (v.endsWith('em')) px = num * px;1843else if (v.endsWith('%')) px = (num / 100) * px;1844else px = num; // unitless — already resolved1845}1846return px;1847}18481849// Resolve a CSS length value (line-height, letter-spacing, etc.) given a1850// known font-size context. Returns null for "normal" / unparseable values.1851function resolveLengthPx(value, fontSizePx) {1852if (!value || value === 'normal' || value === 'auto' || value === 'inherit') return null;1853const num = parseFloat(value);1854if (isNaN(num)) return null;1855if (value.endsWith('px')) return num;1856if (value.endsWith('rem')) return num * 16;1857if (value.endsWith('em')) return num * fontSizePx;1858if (value.endsWith('%')) return (num / 100) * fontSizePx;1859// Unitless line-height = multiplier, return px equivalent1860return num * fontSizePx;1861}18621863function cssColorIsTransparent(value) {1864if (!value) return true;1865const str = String(value).trim().toLowerCase();1866if (!str || str === 'transparent' || str === 'rgba(0, 0, 0, 0)') return true;1867const parsed = parseAnyColor(str);1868if (parsed) return (parsed.a ?? 1) <= 0.05;1869return /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(str);1870}18711872function colorsNearlyMatch(a, b) {1873const ca = parseAnyColor(a);1874const cb = parseAnyColor(b);1875if (!ca || !cb) return false;1876const alphaDelta = Math.abs((ca.a ?? 1) - (cb.a ?? 1));1877const channelDelta = Math.max(1878Math.abs(ca.r - cb.r),1879Math.abs(ca.g - cb.g),1880Math.abs(ca.b - cb.b),1881);1882return alphaDelta <= 0.03 && channelDelta <= 3;1883}18841885function getComputedStyleFor(win, el) {1886if (win && typeof win.getComputedStyle === 'function') {1887try { return win.getComputedStyle(el); } catch {}1888}1889if (typeof getComputedStyle === 'function') {1890try { return getComputedStyle(el); } catch {}1891}1892return null;1893}18941895function hasVisibleBackgroundBoundary(style, el, win) {1896const bg = style?.backgroundColor || '';1897if (cssColorIsTransparent(bg)) return false;18981899let parent = el?.parentElement || null;1900while (parent) {1901const parentStyle = getComputedStyleFor(win, parent);1902const parentBg = parentStyle?.backgroundColor || '';1903if (!cssColorIsTransparent(parentBg)) {1904return !colorsNearlyMatch(bg, parentBg);1905}1906parent = parent.parentElement;1907}19081909return true;1910}19111912const TEXT_EDGE_TAGS = new Set(['A', 'BUTTON', 'CODE', 'DD', 'DT', 'FIGCAPTION', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'P', 'PRE', 'SPAN', 'TD', 'TH']);19131914function hasMeaningfulDirectText(node) {1915if (!node?.childNodes) return false;1916for (const child of node.childNodes) {1917if (child.nodeType === 3 && child.textContent.trim().length > 4) return true;1918}1919return false;1920}19211922function textDescendantsFlushSides(el, rect) {1923const flush = { top: false, right: false, bottom: false, left: false };1924if (!rect || !el?.querySelectorAll) return flush;1925const TEXT_EDGE_THRESHOLD = 4;1926const candidates = el.querySelectorAll('a, button, code, dd, dt, figcaption, h1, h2, h3, h4, h5, h6, li, p, pre, span, td, th');1927for (const node of candidates) {1928if (!TEXT_EDGE_TAGS.has(node.tagName) || !hasMeaningfulDirectText(node)) continue;1929let nodeRect = null;1930try { nodeRect = node.getBoundingClientRect(); } catch {}1931if (!nodeRect || nodeRect.width <= 0 || nodeRect.height <= 0) continue;1932if (nodeRect.bottom < rect.top || nodeRect.top > rect.bottom || nodeRect.right < rect.left || nodeRect.left > rect.right) continue;1933if (nodeRect.top - rect.top <= TEXT_EDGE_THRESHOLD) flush.top = true;1934if (rect.right - nodeRect.right <= TEXT_EDGE_THRESHOLD) flush.right = true;1935if (rect.bottom - nodeRect.bottom <= TEXT_EDGE_THRESHOLD) flush.bottom = true;1936if (nodeRect.left - rect.left <= TEXT_EDGE_THRESHOLD) flush.left = true;1937}1938return flush;1939}19401941// Pure quality checks. Most run on computed CSS and DOM-only inputs (work in1942// jsdom and the browser). Two checks (line-length, cramped-padding) gate on1943// element rect dimensions, which jsdom can't compute — pass `rect: null` from1944// the Node adapter to skip those.1945//1946// Both adapters resolve font-size, line-height and letter-spacing to pixels1947// before calling this so the pure function only deals with numbers.1948function checkQuality(opts) {1949const { el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax = 80, viewportWidth = 0, win = null } = opts;1950const findings = [];1951// Skip browser extension injected elements1952const elId = el.id || '';1953if (elId.startsWith('claude-') || elId.startsWith('cic-')) return findings;19541955// --- Line length too long --- (browser-only: needs rect.width)1956if (rect && hasDirectText && QUALITY_TEXT_TAGS.has(tag) && rect.width > 0 && textLen > lineMax) {1957const charsPerLine = rect.width / (fontSize * 0.5);1958if (charsPerLine > lineMax + 5) {1959findings.push({ id: 'line-length', snippet: `~${Math.round(charsPerLine)} chars/line (aim for <${lineMax})` });1960}1961}19621963// --- Cramped padding --- (browser-only: needs rect to skip small badges/labels)1964// Vertical and horizontal thresholds are independent because line-height1965// already provides built-in vertical breathing room (the line box is taller1966// than the cap height), but horizontal has no equivalent. Both scale with1967// font-size — bigger text demands proportionally more padding.1968// vertical: max(4px, fontSize × 0.3)1969// horizontal: max(8px, fontSize × 0.5)1970const isInlineCode = tag === 'code' && !(el.closest && el.closest('pre'));1971if (!isInlineCode && rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) {1972const borders = {1973top: parseFloat(style.borderTopWidth) || 0,1974right: parseFloat(style.borderRightWidth) || 0,1975bottom: parseFloat(style.borderBottomWidth) || 0,1976left: parseFloat(style.borderLeftWidth) || 0,1977};1978const borderCount = Object.values(borders).filter(w => w > 0).length;1979const hasBg = hasVisibleBackgroundBoundary(style, el, win);1980if (borderCount >= 2 || hasBg) {1981const vPads = [], hPads = [];1982if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0);1983if (hasBg || borders.bottom > 0) vPads.push(parseFloat(style.paddingBottom) || 0);1984if (hasBg || borders.left > 0) hPads.push(parseFloat(style.paddingLeft) || 0);1985if (hasBg || borders.right > 0) hPads.push(parseFloat(style.paddingRight) || 0);19861987const vMin = vPads.length ? Math.min(...vPads) : Infinity;1988const hMin = hPads.length ? Math.min(...hPads) : Infinity;1989const vThresh = Math.max(4, fontSize * 0.3);1990const hThresh = Math.max(8, fontSize * 0.5);19911992// Emit at most one finding per element — pick whichever axis is worse.1993if (vMin < vThresh) {1994findings.push({ id: 'cramped-padding', snippet: `${vMin}px vertical padding (need ≥${vThresh.toFixed(1)}px for ${fontSize}px text)` });1995} else if (hMin < hThresh) {1996findings.push({ id: 'cramped-padding', snippet: `${hMin}px horizontal padding (need ≥${hThresh.toFixed(1)}px for ${fontSize}px text)` });1997}1998}1999}20002001// --- Flush against a visible boundary ---2002// Fires when a container has a visible boundary (border, outline, OR a2003// non-transparent background) AND near-zero padding on the bounded2004// side(s) AND text-bearing children land flush against the boundary.2005//2006// Distinct from cramped-padding: that rule needs the element itself to2007// have direct text (hasDirectText). This rule targets the OPPOSITE2008// shape — a container with NO direct text, only children — which is2009// exactly what cramped-padding misses (a section wrapping a label +2010// list lands a free pass).2011//2012// The classic shape: agent writes `padding: 28px 0 0` shorthand on a2013// section that also has a border, zeroing horizontal padding so the2014// text-bearing children touch the side borders. Background and2015// outline count too: a colored card with zero padding has the same2016// visual failure mode.2017{2018const 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']);2019const upperTag = tag ? tag.toUpperCase() : '';2020const elPosition = style.position || '';2021if (2022!FLUSH_SKIP_TAGS.has(upperTag) &&2023!hasDirectText &&2024!['fixed', 'absolute'].includes(elPosition) &&2025el.children && el.children.length > 02026) {2027const borderW = {2028top: parseFloat(style.borderTopWidth) || 0,2029right: parseFloat(style.borderRightWidth) || 0,2030bottom: parseFloat(style.borderBottomWidth) || 0,2031left: parseFloat(style.borderLeftWidth) || 0,2032};2033const borderVisible = {2034top: borderW.top > 0 && !cssColorIsTransparent(style.borderTopColor),2035right: borderW.right > 0 && !cssColorIsTransparent(style.borderRightColor),2036bottom: borderW.bottom > 0 && !cssColorIsTransparent(style.borderBottomColor),2037left: borderW.left > 0 && !cssColorIsTransparent(style.borderLeftColor),2038};2039// Outline detection. jsdom decomposes `border` shorthand into2040// border{Top,…}Width/Color but does NOT decompose `outline` —2041// the longhands come back empty when the value was set via the2042// shorthand. Fall back to parsing `style.outline` ourselves.2043let outlineW = parseFloat(style.outlineWidth) || 0;2044let outlineStyleVal = style.outlineStyle || '';2045let outlineColorVal = style.outlineColor || '';2046if (!outlineW && style.outline) {2047const wMatch = style.outline.match(/(\d+(?:\.\d+)?)\s*px/);2048if (wMatch) outlineW = parseFloat(wMatch[1]) || 0;2049if (!outlineStyleVal) {2050outlineStyleVal = /\b(solid|dashed|dotted|double|groove|ridge|inset|outset)\b/.test(style.outline) ? 'solid' : '';2051}2052if (!outlineColorVal) {2053const cMatch = style.outline.match(/(rgba?\([^)]+\)|#[0-9a-fA-F]{3,8}|[a-zA-Z]+)\s*$/);2054if (cMatch) outlineColorVal = cMatch[1];2055}2056}2057const outlineVisible = outlineW > 0 && !cssColorIsTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none';2058const bgVisible = hasVisibleBackgroundBoundary(style, el, win);20592060const anyVisible = borderVisible.top || borderVisible.right || borderVisible.bottom || borderVisible.left || outlineVisible || bgVisible;2061if (anyVisible) {2062// Resolve padding to px (jsdom returns raw "1.5rem" etc., not the2063// computed px value; parseFloat would strip the unit and treat2064// 1.5rem as 1.5px, false-flagging legitimate insets).2065const pad = {2066top: resolveLengthPx(style.paddingTop, fontSize) ?? 0,2067right: resolveLengthPx(style.paddingRight, fontSize) ?? 0,2068bottom: resolveLengthPx(style.paddingBottom, fontSize) ?? 0,2069left: resolveLengthPx(style.paddingLeft, fontSize) ?? 0,2070};2071const PAD_THRESHOLD = 2;2072// Children-insulate-this-side: a side is insulated if ANY direct2073// child has its own padding ≥ 4px on that side. Rationale: in2074// typical flow, only the first/last (or leftmost/rightmost)2075// children actually sit at the parent's edges. If even one of2076// them has its own padding, the visual flush is broken on that2077// side. Classic example: a column-flow card frame where the2078// top child (header) has padding-top:12 and the bottom child2079// (footer) has padding-bottom:8 — the parent's padding:0 doesn't2080// matter; nothing is actually flush. The `any-child-insulates`2081// heuristic accepts some false negatives (a card with one heavily2082// padded middle child won't flag) for far fewer false positives.2083const CHILD_INSULATE_THRESHOLD = 4;2084const childrenInsulate = { top: false, right: false, bottom: false, left: false };2085for (const child of el.children) {2086let childStyle = getComputedStyleFor(win, child);2087if (!childStyle) continue;2088const childPad = {2089top: resolveLengthPx(childStyle.paddingTop, fontSize) ?? 0,2090right: resolveLengthPx(childStyle.paddingRight, fontSize) ?? 0,2091bottom: resolveLengthPx(childStyle.paddingBottom, fontSize) ?? 0,2092left: resolveLengthPx(childStyle.paddingLeft, fontSize) ?? 0,2093};2094const childMargin = {2095top: resolveLengthPx(childStyle.marginTop, fontSize) ?? 0,2096right: resolveLengthPx(childStyle.marginRight, fontSize) ?? 0,2097bottom: resolveLengthPx(childStyle.marginBottom, fontSize) ?? 0,2098left: resolveLengthPx(childStyle.marginLeft, fontSize) ?? 0,2099};2100if (rect && typeof child.getBoundingClientRect === 'function') {2101try {2102const childRect = child.getBoundingClientRect();2103if (childRect && childRect.width > 0 && childRect.height > 0) {2104if (childRect.top - rect.top >= CHILD_INSULATE_THRESHOLD) childrenInsulate.top = true;2105if (rect.right - childRect.right >= CHILD_INSULATE_THRESHOLD) childrenInsulate.right = true;2106if (rect.bottom - childRect.bottom >= CHILD_INSULATE_THRESHOLD) childrenInsulate.bottom = true;2107if (childRect.left - rect.left >= CHILD_INSULATE_THRESHOLD) childrenInsulate.left = true;2108}2109} catch {}2110}2111for (const s of ['top', 'right', 'bottom', 'left']) {2112if (childPad[s] >= CHILD_INSULATE_THRESHOLD || childMargin[s] >= CHILD_INSULATE_THRESHOLD) {2113childrenInsulate[s] = true;2114}2115}2116}21172118const textFlush = rect ? textDescendantsFlushSides(el, rect) : null;2119const fullBleedBgBand = rect && viewportWidth > 0 && rect.width >= viewportWidth * 0.94 && bgVisible && !outlineVisible;2120const flushSides = [];2121for (const side of ['top', 'right', 'bottom', 'left']) {2122const bgBoundsSide = bgVisible && !(fullBleedBgBand && (side === 'left' || side === 'right'));2123const sideBounded = borderVisible[side] || outlineVisible || bgBoundsSide;2124if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side] && (!textFlush || textFlush[side])) {2125flushSides.push(side);2126}2127}21282129if (flushSides.length > 0) {2130// Confirm at least one direct child has substantial text content2131// (> 4 chars). Without this, the flush is harmless: e.g. an2132// image-only card.2133let hasTextChild = false;2134for (const child of el.children) {2135const childText = (child.textContent || '').trim();2136if (childText.length > 4) { hasTextChild = true; break; }2137}2138if (hasTextChild) {2139const cls = (typeof el.className === 'string' && el.className.trim())2140? el.className.trim().split(/\s+/)[0]2141: '';2142const boundaryParts = [];2143const borderSidesVisible = ['top', 'right', 'bottom', 'left'].filter(s => borderVisible[s]);2144if (borderSidesVisible.length === 4) boundaryParts.push('border');2145else if (borderSidesVisible.length > 0) boundaryParts.push(`border-${borderSidesVisible.join('/')}`);2146if (outlineVisible) boundaryParts.push('outline');2147if (bgVisible) boundaryParts.push('bg');2148const sidesLabel = flushSides.length === 4 ? 'all sides' : flushSides.join('/');2149const ident = cls2150? `<${tag.toLowerCase()}> "${cls}"`2151: `<${tag.toLowerCase()}>`;2152findings.push({2153id: 'cramped-padding',2154snippet: `${ident}: children flush against ${boundaryParts.join('+')} on ${sidesLabel} (no inset)`,2155});2156}2157}2158}2159}2160}21612162// --- Body text touching viewport edge --- (browser-only: needs rect)2163// Catches the failure mode where the agent ships body paragraphs2164// with NO container providing horizontal padding — text bleeds2165// directly to the viewport edge. Different from cramped-padding,2166// which requires a colored/bordered container. Here the failure2167// is the absence of the container entirely.2168//2169// Gate aggressively to avoid false positives:2170// - <p> or <li> only (body content; not headings, not nav, not2171// wrappers)2172// - text > 40 chars (paragraph-like, not a label)2173// - rect.width > 50% of viewport (real body, not a pull-quote)2174// - rect.left < 16 OR rect.right > viewport - 16 (actually2175// touching the edge)2176// - not inside <nav> or <header> (those legitimately bleed)2177// - element itself has no background-color (intentional full-bleed2178// sections set a bg-color and provide their own internal padding)2179if (rect && hasDirectText && textLen > 40 && ['P', 'LI'].includes(tag.toUpperCase()) && viewportWidth > 0) {2180const inNavHeader = el.closest && (el.closest('nav') || el.closest('header'));2181const hasOwnBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)' && style.backgroundColor !== 'transparent';2182const isPositioned = ['fixed', 'absolute'].includes(style.position || '');2183const widthRatio = rect.width / viewportWidth;2184const leftClose = rect.left < 16;2185const rightClose = rect.right > viewportWidth - 16;2186if (!inNavHeader && !hasOwnBg && !isPositioned && widthRatio > 0.5 && (leftClose || rightClose)) {2187const which = leftClose && rightClose2188? `left ${Math.round(rect.left)}px / right ${Math.round(viewportWidth - rect.right)}px`2189: leftClose2190? `left ${Math.round(rect.left)}px`2191: `right ${Math.round(viewportWidth - rect.right)}px`;2192findings.push({ id: 'body-text-viewport-edge', snippet: `<${tag.toLowerCase()}> with ${textLen}-char body bleeds to viewport edge (${which})` });2193}2194}21952196// --- Tight line height ---2197if (hasDirectText && textLen > 50 && !['h1','h2','h3','h4','h5','h6'].includes(tag)) {2198if (lineHeightPx != null && fontSize > 0) {2199const ratio = lineHeightPx / fontSize;2200if (ratio > 0 && ratio < 1.3) {2201findings.push({ id: 'tight-leading', snippet: `line-height ${ratio.toFixed(2)}x (need >=1.3)` });2202}2203}2204}22052206// --- Justified text (without hyphens) ---2207if (hasDirectText && style.textAlign === 'justify') {2208const hyphens = style.hyphens || style.webkitHyphens || '';2209if (hyphens !== 'auto') {2210findings.push({ id: 'justified-text', snippet: 'text-align: justify without hyphens: auto' });2211}2212}22132214// --- Tiny body text ---2215// Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.)2216if (hasDirectText && textLen > 20 && fontSize < 12) {2217const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption'];2218const 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]');2219const isUppercase = style.textTransform === 'uppercase';2220if (!skipTags.includes(tag) && !inUIContext && !isUppercase) {2221findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` });2222}2223}22242225// --- All-caps body text ---2226if (hasDirectText && textLen > 30 && style.textTransform === 'uppercase') {2227if (!['h1','h2','h3','h4','h5','h6'].includes(tag)) {2228findings.push({ id: 'all-caps-body', snippet: `text-transform: uppercase on ${textLen} chars of body text` });2229}2230}22312232// --- Wide letter spacing on body text ---2233if (hasDirectText && textLen > 20 && style.textTransform !== 'uppercase') {2234if (letterSpacingPx != null && letterSpacingPx > 0 && fontSize > 0) {2235const trackingEm = letterSpacingPx / fontSize;2236if (trackingEm > 0.05) {2237findings.push({ id: 'wide-tracking', snippet: `letter-spacing: ${trackingEm.toFixed(2)}em on body text` });2238}2239}2240}22412242// --- Crushed letter spacing (mirror of wide-tracking) ---2243// Tracking pulled tighter than ~-0.05em crushes characters into each other.2244// Optical tightening that display type legitimately wants (around -0.02em)2245// stays well above this floor.2246if (hasDirectText && textLen > 20 && fontSize > 0) {2247if (letterSpacingPx != null && letterSpacingPx < 0) {2248const trackingEm = letterSpacingPx / fontSize;2249if (trackingEm <= -0.05) {2250const excerpt = (el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 40);2251findings.push({ id: 'extreme-negative-tracking', snippet: `letter-spacing: ${trackingEm.toFixed(2)}em — "${excerpt}"` });2252}2253}2254}22552256return findings;2257}22582259function checkElementQualityDOM(el) {2260const tag = el.tagName.toLowerCase();2261const style = getComputedStyle(el);2262const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 10);2263const textLen = el.textContent?.trim().length || 0;2264// Browser getComputedStyle resolves everything to px — direct parseFloat2265// works.2266const fontSize = parseFloat(style.fontSize) || 16;2267const lineHeightPx = resolveLengthPx(style.lineHeight, fontSize);2268const letterSpacingPx = resolveLengthPx(style.letterSpacing, fontSize);2269const rect = el.getBoundingClientRect();2270const lineMax = (typeof window !== 'undefined' && window.__IMPECCABLE_CONFIG__?.lineLengthMax) || 80;2271const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0;2272return checkQuality({ el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax, viewportWidth, win: typeof window !== 'undefined' ? window : null });2273}22742275// Pure page-level skipped-heading walk. Takes a Document so it works in both2276// the browser and jsdom.2277function checkPageQualityFromDoc(doc) {2278const findings = [];2279const headings = doc.querySelectorAll('h1, h2, h3, h4, h5, h6');2280let prevLevel = 0;2281let prevText = '';2282for (const h of headings) {2283const level = parseInt(h.tagName[1]);2284const text = (h.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 60);2285if (prevLevel > 0 && level > prevLevel + 1) {2286findings.push({2287id: 'skipped-heading',2288snippet: `<h${prevLevel}> "${prevText}" followed by <h${level}> "${text}" (missing h${prevLevel + 1})`,2289});2290}2291prevLevel = level;2292prevText = text;2293}2294return findings;2295}22962297// Browser adapter (returns the legacy { type, detail } shape used by the overlay loop)2298function checkPageQualityDOM() {2299return checkPageQualityFromDoc(document).map(f => ({ type: f.id, detail: f.snippet }));2300}23012302// Node adapters — take pre-extracted jsdom computed style23032304// jsdom doesn't lay out OR resolve em/rem/% to px — so we pre-resolve every2305// CSS length the rule needs ourselves (walking the parent chain for2306// font-size inheritance), and pass `rect: null` to skip the two rules that2307// genuinely need element rects (line-length, cramped-padding).2308function checkElementQuality(el, style, tag, window) {2309const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 10);2310const textLen = el.textContent?.trim().length || 0;2311const fontSize = resolveFontSizePx(el, window);2312const lineHeightPx = resolveLengthPx(style.lineHeight, fontSize);2313const letterSpacingPx = resolveLengthPx(style.letterSpacing, fontSize);2314return checkQuality({ el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect: null, win: window });2315}23162317function checkElementBorders(tag, style, overrides, resolvedRadius) {2318const sides = ['Top', 'Right', 'Bottom', 'Left'];2319const widths = {}, colors = {};2320for (const s of sides) {2321widths[s] = parseFloat(style[`border${s}Width`]) || 0;2322colors[s] = style[`border${s}Color`] || '';2323// jsdom silently drops any border shorthand containing var(), leaving2324// both width and color empty on the computed style. When the detectHtml2325// pre-pass pulled a resolved value off the rule, use it to fill in the2326// missing side so the side-tab check can run. Real browsers resolve2327// var() natively, so this fallback is a no-op in the browser path.2328if (widths[s] === 0 && overrides && overrides[s]) {2329widths[s] = overrides[s].width;2330colors[s] = overrides[s].color;2331} else if (colors[s] && colors[s].startsWith('var(') && overrides && overrides[s]) {2332// Longhand case: jsdom kept the width but left the color as the2333// literal `var(...)` string. Substitute the resolved color.2334colors[s] = overrides[s].color;2335}2336}2337// resolvedRadius lets the caller pre-resolve the radius via2338// resolveBorderRadiusPx so the value survives jsdom 29.1.0's broken2339// shorthand serialization. Falls back to the computed value for tests2340// and browser callers that don't pre-resolve.2341const radius = resolvedRadius != null2342? resolvedRadius2343: (parseFloat(style.borderRadius) || 0);2344return checkBorders(tag, widths, colors, radius);2345}23462347function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInheritRule) {2348const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');2349const hasDirectText = directText.trim().length > 0;23502351const effectiveBg = resolveBackground(el, window, customPropMap);2352// jsdom returns literal "var(--X)" / "oklch(...)" for color, so plain2353// parseRgb misses Tailwind-tokenized text colors. Resolve through the2354// customPropMap first; fall back to parseRgb for vanilla rgb() pages.2355let textColor = customPropMap ? parseColorResolved(style.color, customPropMap) : null;2356if (!textColor) textColor = parseRgb(style.color);23572358// Anchor-inherit FP workaround: jsdom's UA stylesheet has `:link { color:2359// blue }` at high specificity. The page's `a { color: inherit }` rule2360// (Tailwind v4 preflight) loses to jsdom even though it WINS in real2361// browsers (Chrome's UA wraps :link in :where() — zero specificity).2362// When the page declares the inherit rule AND we see jsdom's default2363// link blue on an anchor, walk to the nearest non-anchor ancestor and2364// use its color instead.2365if (2366hasAnchorInheritRule &&2367textColor &&2368textColor.r === 0 && textColor.g === 0 && textColor.b === 238 &&2369(tag === 'a' || el.closest?.('a'))2370) {2371let cur = el.parentElement;2372while (cur && cur.tagName !== 'HTML') {2373if (cur.tagName !== 'A') {2374const ps = window.getComputedStyle(cur);2375const inh = (customPropMap ? parseColorResolved(ps.color, customPropMap) : null) || parseRgb(ps.color);2376if (inh && !(inh.r === 0 && inh.g === 0 && inh.b === 238)) {2377textColor = inh;2378break;2379}2380}2381cur = cur.parentElement;2382}2383}23842385return checkColors({2386tag,2387textColor,2388bgColor: readOwnBackgroundColor(el, style),2389effectiveBg,2390effectiveBgStops: effectiveBg ? null : resolveGradientStops(el, window),2391fontSize: parseFloat(style.fontSize) || 16,2392fontWeight: parseInt(style.fontWeight) || 400,2393hasDirectText,2394isEmojiOnly: isEmojiOnlyText(directText),2395bgClip: style.webkitBackgroundClip || style.backgroundClip || '',2396bgImage: style.backgroundImage || '',2397classList: el.getAttribute?.('class') || el.className || '',2398});2399}24002401function checkElementIconTile(el, tag, window) {2402if (!HEADING_TAGS.has(tag)) return [];2403const sibling = el.previousElementSibling;2404if (!sibling) return [];24052406const sibStyle = window.getComputedStyle(sibling);2407// jsdom doesn't lay out — read explicit pixel dimensions from CSS instead.2408const sibWidth = parseFloat(sibStyle.width) || 0;2409const sibHeight = parseFloat(sibStyle.height) || 0;24102411const iconChild = sibling.querySelector('svg, i[data-lucide], i[class*="fa-"], i[class*="icon"]');2412let iconWidth = 0;2413if (iconChild) {2414const iconStyle = window.getComputedStyle(iconChild);2415iconWidth = parseFloat(iconStyle.width) || parseFloat(iconChild.getAttribute('width')) || 0;2416}2417// Or: tile contains an emoji/symbol character directly as its only content2418const sibDirectText = [...sibling.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');2419const hasInlineEmojiIcon = sibling.children.length === 0 && isEmojiOnlyText(sibDirectText);24202421return checkIconTile({2422headingTag: tag,2423headingText: el.textContent || '',2424headingTop: 0, // jsdom: no layout, skip vertical-stacking gate2425siblingTag: sibling.tagName.toLowerCase(),2426siblingWidth: sibWidth,2427siblingHeight: sibHeight,2428siblingBottom: 0,2429siblingBgColor: parseRgb(sibStyle.backgroundColor),2430siblingBgImage: sibStyle.backgroundImage || '',2431siblingBorderWidth: parseFloat(sibStyle.borderTopWidth) || 0,2432siblingBorderRadius: resolveBorderRadiusPx(sibling, sibStyle, sibWidth, window),2433hasIconChild: !!iconChild || hasInlineEmojiIcon,2434iconChildWidth: iconWidth,2435});2436}24372438function checkElementItalicSerif(el, style, tag) {2439if (tag !== 'h1' && tag !== 'h2') return [];2440return checkItalicSerif({2441tag,2442fontStyle: style.fontStyle || '',2443fontFamily: style.fontFamily || '',2444fontSize: parseFloat(style.fontSize) || 0,2445headingText: el.textContent || '',2446});2447}24482449function checkElementHeroEyebrow(el, style, tag, window, customPropMap) {2450if (tag !== 'h1') return [];2451const sibling = el.previousElementSibling;2452if (!sibling) return [];2453const sibStyle = window.getComputedStyle(sibling);2454// Resolve Tailwind v4 CSS-variable wrappers (font-weight:var(--font-weight-bold)2455// etc.) before parsing. jsdom returns these verbatim from getComputedStyle;2456// without resolution every style-based gate fails silently on Tailwind v4 builds.2457const fontSizeRaw = customPropMap ? resolveVarRefs(sibStyle.fontSize, customPropMap) : sibStyle.fontSize;2458const fontWeightRaw = customPropMap ? resolveVarRefs(sibStyle.fontWeight, customPropMap) : sibStyle.fontWeight;2459const letterSpacingRaw = customPropMap ? resolveVarRefs(sibStyle.letterSpacing, customPropMap) : sibStyle.letterSpacing;2460const colorRaw = customPropMap ? resolveVarRefs(sibStyle.color, customPropMap) : sibStyle.color;2461const headingFontSizeRaw = customPropMap ? resolveVarRefs(style.fontSize, customPropMap) : style.fontSize;2462const siblingFontSize = parseFloat(fontSizeRaw) || 0;2463// resolveLengthPx returns null for 'normal' / 'auto'; coerce to 0 so the2464// gate falls through cleanly. jsdom returns letter-spacing verbatim2465// (e.g. '0.15em'), unlike real browsers, so this conversion is required.2466return checkHeroEyebrow({2467headingTag: tag,2468headingText: el.textContent || '',2469headingFontSize: parseFloat(headingFontSizeRaw) || 0,2470siblingTag: sibling.tagName.toLowerCase(),2471siblingText: sibling.textContent || '',2472siblingTextTransform: sibStyle.textTransform || '',2473siblingFontSize,2474siblingLetterSpacing: resolveLengthPx(letterSpacingRaw, siblingFontSize) || 0,2475siblingFontWeight: fontWeightRaw || '',2476siblingColor: colorRaw || '',2477});2478}24792480function checkRepeatedSectionKickersFromDoc(doc, win) {2481const candidates = collectRepeatedSectionKickerCandidates(2482doc,2483(el) => win.getComputedStyle(el),2484(value, fontSize) => resolveLengthPx(value, fontSize) || 0,2485);2486return checkRepeatedSectionKickers({ candidates });2487}24882489function checkElementMotion(tag, style) {2490return checkMotion({2491tag,2492transitionProperty: style.transitionProperty || '',2493animationName: style.animationName || '',2494timingFunctions: [style.animationTimingFunction, style.transitionTimingFunction].filter(Boolean).join(' '),2495classList: '',2496});2497}24982499function checkElementGlow(tag, style, effectiveBg) {2500if (!style.boxShadow || style.boxShadow === 'none') return [];2501return checkGlow({ tag, boxShadow: style.boxShadow, effectiveBg });2502}25032504// ─── Section 6: Page-Level Checks ───────────────────────────────────────────25052506// Browser page-level checks — use document/getComputedStyle globals25072508function checkTypography() {2509const findings = [];25102511// Walk actual text-bearing elements and tally font usage by *computed style*.2512// This is much more accurate than scanning CSS rules — it ignores rules that2513// exist in the stylesheet but apply to nothing (e.g. demo classes showing2514// anti-patterns), and counts what the user actually sees.2515const fontUsage = new Map(); // primary font name → count of elements2516let totalTextElements = 0;2517for (const el of document.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, td, th, dd, blockquote, figcaption, a, button, label, span')) {2518// Skip impeccable's own elements2519if (el.closest && el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue;2520// Only count elements that actually have visible direct text2521const hasText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0);2522if (!hasText) continue;2523const style = getComputedStyle(el);2524const ff = style.fontFamily;2525if (!ff) continue;2526const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());2527const primary = stack.find(f => f && !GENERIC_FONTS.has(f));2528if (!primary) continue;2529fontUsage.set(primary, (fontUsage.get(primary) || 0) + 1);2530totalTextElements++;2531}25322533if (totalTextElements >= 20) {2534// A font is "primary" if it's used by at least 15% of text elements2535const PRIMARY_THRESHOLD = 0.15;2536for (const [font, count] of fontUsage) {2537const share = count / totalTextElements;2538if (share < PRIMARY_THRESHOLD) continue;2539if (!OVERUSED_FONTS.has(font)) continue;2540if (isBrandFontOnOwnDomain(font)) continue;2541findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });2542}25432544// Single-font check: only one distinct primary font across all text2545if (fontUsage.size === 1) {2546const only = [...fontUsage.keys()][0];2547findings.push({ type: 'single-font', detail: `only font used is ${only}` });2548}2549}25502551const sizes = new Set();2552for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) {2553const fs = parseFloat(getComputedStyle(el).fontSize);2554if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10);2555}2556if (sizes.size >= 3) {2557const sorted = [...sizes].sort((a, b) => a - b);2558const ratio = sorted[sorted.length - 1] / sorted[0];2559if (ratio < 2.0) {2560findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` });2561}2562}25632564return findings;2565}25662567function isCardLikeDOM(el) {2568const tag = el.tagName.toLowerCase();2569if (SAFE_TAGS.has(tag) || ['input','select','textarea','img','video','canvas','picture'].includes(tag)) return false;2570const style = getComputedStyle(el);2571const cls = el.getAttribute('class') || '';2572const hasShadow = (style.boxShadow && style.boxShadow !== 'none') || /\bshadow(?:-sm|-md|-lg|-xl|-2xl)?\b/.test(cls);2573const hasBorder = /\bborder\b/.test(cls);2574const hasRadius = parseFloat(style.borderRadius) > 0 || /\brounded(?:-sm|-md|-lg|-xl|-2xl|-full)?\b/.test(cls);2575const hasBg = (style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)') || /\bbg-(?:white|gray-\d+|slate-\d+)\b/.test(cls);2576return isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg);2577}25782579function checkLayout() {2580const findings = [];2581const flaggedEls = new Set();25822583for (const el of document.querySelectorAll('*')) {2584if (!isCardLikeDOM(el) || flaggedEls.has(el)) continue;2585const cls = el.getAttribute('class') || '';2586const style = getComputedStyle(el);2587if (style.position === 'absolute' || style.position === 'fixed') continue;2588if (/\b(?:dropdown|popover|tooltip|menu|modal|dialog)\b/i.test(cls)) continue;2589if ((el.textContent?.trim().length || 0) < 10) continue;2590const rect = el.getBoundingClientRect();2591if (rect.width < 50 || rect.height < 30) continue;25922593let parent = el.parentElement;2594while (parent) {2595if (isCardLikeDOM(parent)) { flaggedEls.add(el); break; }2596parent = parent.parentElement;2597}2598}25992600for (const el of flaggedEls) {2601let isAncestor = false;2602for (const other of flaggedEls) {2603if (other !== el && el.contains(other)) { isAncestor = true; break; }2604}2605if (!isAncestor) findings.push({ type: 'nested-cards', detail: 'Card inside card', el });2606}26072608return findings;2609}26102611// Node page-level checks — take document/window as parameters26122613function checkPageTypography(doc, win) {2614const findings = [];26152616const fonts = new Set();2617const overusedFound = new Set();26182619for (const sheet of doc.styleSheets) {2620let rules;2621try { rules = sheet.cssRules || sheet.rules; } catch { continue; }2622if (!rules) continue;2623for (const rule of rules) {2624if (rule.type !== 1) continue;2625const ff = rule.style?.fontFamily;2626if (!ff) continue;2627const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());2628const primary = stack.find(f => f && !GENERIC_FONTS.has(f));2629if (primary) {2630fonts.add(primary);2631if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);2632}2633}2634}26352636// Check Google Fonts links in HTML2637const html = doc.documentElement?.outerHTML || '';2638const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;2639let m;2640while ((m = gfRe.exec(html)) !== null) {2641const families = m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase());2642for (const f of families) {2643fonts.add(f);2644if (OVERUSED_FONTS.has(f)) overusedFound.add(f);2645}2646}26472648// Also parse raw HTML/style content for font-family (jsdom may not expose all via CSSOM)2649const ffRe = /font-family\s*:\s*([^;}]+)/gi;2650let fm;2651while ((fm = ffRe.exec(html)) !== null) {2652for (const f of fm[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) {2653if (f && !GENERIC_FONTS.has(f)) {2654fonts.add(f);2655if (OVERUSED_FONTS.has(f)) overusedFound.add(f);2656}2657}2658}26592660for (const font of overusedFound) {2661findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` });2662}26632664// Single font2665if (fonts.size === 1) {2666const els = doc.querySelectorAll('*');2667if (els.length >= 20) {2668findings.push({ id: 'single-font', snippet: `only font used is ${[...fonts][0]}` });2669}2670}26712672// Flat type hierarchy2673const sizes = new Set();2674const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div');2675for (const el of textEls) {2676const fontSize = parseFloat(win.getComputedStyle(el).fontSize);2677// Filter out sub-8px values (jsdom doesn't resolve relative units properly)2678if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10);2679}2680if (sizes.size >= 3) {2681const sorted = [...sizes].sort((a, b) => a - b);2682const ratio = sorted[sorted.length - 1] / sorted[0];2683if (ratio < 2.0) {2684findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` });2685}2686}26872688return findings;2689}26902691function isCardLike(el, win) {2692const tag = el.tagName.toLowerCase();2693if (SAFE_TAGS.has(tag) || ['input', 'select', 'textarea', 'img', 'video', 'canvas', 'picture'].includes(tag)) return false;26942695const style = win.getComputedStyle(el);2696const rawStyle = el.getAttribute?.('style') || '';2697const cls = el.getAttribute?.('class') || '';26982699const hasShadow = (style.boxShadow && style.boxShadow !== 'none') ||2700/\bshadow(?:-sm|-md|-lg|-xl|-2xl)?\b/.test(cls) || /box-shadow/i.test(rawStyle);2701const hasBorder = /\bborder\b/.test(cls);2702const widthPx = parseFloat(style.width) || 0;2703const hasRadius = resolveBorderRadiusPx(el, style, widthPx, win) > 0 ||2704/\brounded(?:-sm|-md|-lg|-xl|-2xl|-full)?\b/.test(cls) || /border-radius/i.test(rawStyle);2705const hasBg = /\bbg-(?:white|gray-\d+|slate-\d+)\b/.test(cls) ||2706/background(?:-color)?\s*:\s*(?!transparent)/i.test(rawStyle);27072708return isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg);2709}27102711function checkPageLayout(doc, win) {2712const findings = [];27132714// Nested cards2715const allEls = doc.querySelectorAll('*');2716const flaggedEls = new Set();2717for (const el of allEls) {2718if (!isCardLike(el, win)) continue;2719if (flaggedEls.has(el)) continue;27202721const tag = el.tagName.toLowerCase();2722const cls = el.getAttribute?.('class') || '';2723const rawStyle = el.getAttribute?.('style') || '';27242725if (['pre', 'code'].includes(tag)) continue;2726if (/\b(?:absolute|fixed)\b/.test(cls) || /position\s*:\s*(?:absolute|fixed)/i.test(rawStyle)) continue;2727if ((el.textContent?.trim().length || 0) < 10) continue;2728if (/\b(?:dropdown|popover|tooltip|menu|modal|dialog)\b/i.test(cls)) continue;27292730// Walk up to find card-like ancestor2731let parent = el.parentElement;2732while (parent) {2733if (isCardLike(parent, win)) {2734flaggedEls.add(el);2735break;2736}2737parent = parent.parentElement;2738}2739}27402741// Only report innermost nested cards2742for (const el of flaggedEls) {2743let isAncestorOfFlagged = false;2744for (const other of flaggedEls) {2745if (other !== el && el.contains(other)) {2746isAncestorOfFlagged = true;2747break;2748}2749}2750if (!isAncestorOfFlagged) {2751findings.push({ id: 'nested-cards', snippet: `Card inside card (${el.tagName.toLowerCase()})` });2752}2753}27542755return findings;2756}27572758// ─── Cream / beige palette (the default "tasteful" AI surface) ────────────────2759// A warm, lightly-tinted off-white page background — light, with R≥G≥B and a2760// small warm tint (not white, not a strong color). The current reflex surface.2761function isCreamColor(rgb) {2762if (!rgb) return false;2763const { r, g, b } = rgb;2764if (Math.min(r, g, b) < 209) return false; // must be light2765if (!(r >= g && g >= b)) return false; // warm ordering2766const warmth = r - b;2767return warmth >= 6 && warmth <= 48; // tinted, not white, not strong2768}27692770// Tailwind background utilities that render as a warm off-white surface. The2771// static engine doesn't fetch Tailwind's CSS, so a `bg-amber-50` on <body>2772// resolves to nothing in computed style — catch it from the class list2773// instead. Candidate tokens map to their actual Tailwind hex and are still2774// filtered through isCreamColor, so neutral grays (stone) and over-saturated2775// shades drop out on their own.2776const TAILWIND_BG_HEX = {2777'bg-amber-50': '#fffbeb', 'bg-amber-100': '#fef3c7',2778'bg-orange-50': '#fff7ed', 'bg-orange-100': '#ffedd5',2779'bg-yellow-50': '#fefce8',2780'bg-stone-50': '#fafaf9', 'bg-stone-100': '#f5f5f4', 'bg-stone-200': '#e7e5e4',2781};27822783function creamFromClassList(cls) {2784if (!cls) return null;2785// Arbitrary value: bg-[#f5f0e6] / bg-[rgb(245_240_230)] (underscores = spaces).2786const arb = cls.match(/\bbg-\[([^\]]+)\]/);2787if (arb && isCreamColor(parseAnyColor(arb[1].replace(/_/g, ' ')))) return `bg-[${arb[1]}]`;2788// Named warm-light utilities.2789for (const [tok, hex] of Object.entries(TAILWIND_BG_HEX)) {2790if (new RegExp(`(^|\\s)${tok}($|\\s)`).test(cls) && isCreamColor(parseAnyColor(hex))) return tok;2791}2792return null;2793}27942795function checkCreamPalette(doc, win) {2796const findings = [];2797const body = doc.body || (doc.querySelector ? doc.querySelector('body') : null);2798if (!body) return findings;2799const html = doc.documentElement;2800const getCS = (el) => (win ? win.getComputedStyle(el) : getComputedStyle(el));28012802// 1. Computed background — covers inline / <style> / linked CSS, and Tailwind2803// once it's actually rendered (browser path).2804let bg = readOwnBackgroundColor(body, getCS(body));2805if (!bg || bg.a === 0) {2806if (html) bg = readOwnBackgroundColor(html, getCS(html));2807}2808if (isCreamColor(bg)) {2809findings.push({ id: 'cream-palette', snippet: `cream/beige page background rgb(${bg.r}, ${bg.g}, ${bg.b})` });2810return findings;2811}28122813// 2. Tailwind class fallback — for the static path, where utility classes2814// never resolve to computed CSS.2815for (const el of [body, html]) {2816const tok = creamFromClassList(el && el.getAttribute ? el.getAttribute('class') : '');2817if (tok) {2818findings.push({ id: 'cream-palette', snippet: `cream/beige page background (Tailwind ${tok})` });2819break;2820}2821}2822return findings;2823}28242825// ─── Oversized hero headline ────────────────────────────────────────────────2826// Fires when a *long* headline is set at display size and actually dominates2827// the viewport. A punchy one- or two-word headline at the same size is a2828// legitimate stylistic choice, and a large-but-contained two-line hero should2829// pass too — length and viewport share together are the tell.2830const OVERSIZED_H1_FONT_PX = 72;2831const OVERSIZED_H1_MIN_CHARS = 40;2832const OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO = 0.28;2833const OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO = 0.25;2834function checkOversizedH1({ tag, fontSize, headingText, rect = null, viewportWidth = 0, viewportHeight = 0 }) {2835if (tag !== 'h1') return [];2836const textLen = headingText.length;2837if (fontSize >= OVERSIZED_H1_FONT_PX && textLen >= OVERSIZED_H1_MIN_CHARS) {2838let viewportDetail = '';2839if (rect && viewportWidth > 0 && viewportHeight > 0) {2840const heightRatio = rect.height / viewportHeight;2841const areaRatio = (rect.width * rect.height) / (viewportWidth * viewportHeight);2842const dominatesViewport = heightRatio >= OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO2843|| areaRatio >= OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO;2844if (!dominatesViewport) return [];2845viewportDetail = `, ${Math.round(heightRatio * 100)}vh`;2846}2847return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars${viewportDetail} "${headingText.slice(0, 60)}"` }];2848}2849return [];2850}28512852function checkElementOversizedH1(el, style, tag, window) {2853if (tag !== 'h1') return [];2854const fontSize = resolveFontSizePx(el, window);2855const headingText = (el.textContent || '').trim().replace(/\s+/g, ' ');2856return checkOversizedH1({ tag, fontSize, headingText });2857}28582859function checkElementOversizedH1DOM(el) {2860const tag = el.tagName.toLowerCase();2861if (tag !== 'h1') return [];2862const style = getComputedStyle(el);2863const fontSize = parseFloat(style.fontSize) || 0;2864const headingText = (el.textContent || '').trim().replace(/\s+/g, ' ');2865const rect = el.getBoundingClientRect();2866const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0;2867const viewportHeight = (typeof window !== 'undefined' ? window.innerHeight : 0) || 0;2868return checkOversizedH1({ tag, fontSize, headingText, rect, viewportWidth, viewportHeight });2869}28702871// ─── GPT tell: hairline border + wide diffuse shadow (gated --gpt) ────────────2872const 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;28732874function shadowLayerAlpha(layer) {2875CSS_COLOR_TOKEN_RE.lastIndex = 0;2876const match = CSS_COLOR_TOKEN_RE.exec(layer);2877if (!match) return 1;2878if (match[0].toLowerCase() === 'transparent') return 0;2879const parsed = parseAnyColor(match[0]);2880return parsed ? (parsed.a ?? 1) : 1;2881}28822883function shadowMaxBlurPx(boxShadow, { minAlpha = 0 } = {}) {2884if (!boxShadow || boxShadow === 'none') return 0;2885let maxBlur = 0;2886// Split into layers on commas not inside parentheses (rgba(...) etc.).2887for (const layer of boxShadow.split(/,(?![^()]*\))/)) {2888if (shadowLayerAlpha(layer) < minAlpha) continue;2889// Strip colors and keywords (rgba()/hsl()/hex/named/inset/px), leaving the2890// ordered length tokens: offsetX offsetY blur [spread]. Static jsdom keeps2891// unitless zeros ("0 0 24px"); browsers normalize to px ("0px 0px 24px") —2892// both reduce to the same numbers here.2893const cleaned = layer.replace(CSS_COLOR_TOKEN_RE, ' ').replace(/\b[a-z]+\b/gi, ' ');2894const nums = [...cleaned.matchAll(/-?\d*\.?\d+/g)].map(m => parseFloat(m[0]));2895if (nums.length >= 3) maxBlur = Math.max(maxBlur, nums[2]);2896}2897return maxBlur;2898}28992900function cssColorAlpha(value) {2901if (cssColorIsTransparent(value)) return 0;2902const parsed = parseAnyColor(value);2903return parsed ? (parsed.a ?? 1) : 1;2904}29052906function checkGptThinBorderWideShadow({ borderWidths, borderColors, boxShadow }) {2907const visibleThinBorders = borderWidths2908.map((width, index) => ({ width, alpha: cssColorAlpha(borderColors?.[index] || '') }))2909.filter(({ width, alpha }) => width > 0 && width <= 1.5 && alpha >= 0.28);2910const maxBorder = Math.max(0, ...visibleThinBorders.map(({ width }) => width));2911const blur = shadowMaxBlurPx(boxShadow, { minAlpha: 0.12 });2912if (visibleThinBorders.length >= 2 && blur >= 16) {2913return [{ id: 'gpt-thin-border-wide-shadow', snippet: `${maxBorder}px border + ${Math.round(blur)}px shadow blur` }];2914}2915return [];2916}29172918function borderWidthsFromStyle(style) {2919return [2920parseFloat(style.borderTopWidth) || 0,2921parseFloat(style.borderRightWidth) || 0,2922parseFloat(style.borderBottomWidth) || 0,2923parseFloat(style.borderLeftWidth) || 0,2924];2925}29262927function borderColorsFromStyle(style) {2928return [2929style.borderTopColor || '',2930style.borderRightColor || '',2931style.borderBottomColor || '',2932style.borderLeftColor || '',2933];2934}29352936function checkElementGptBorderShadow(el, style) {2937return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' });2938}29392940function checkElementGptBorderShadowDOM(el) {2941const style = getComputedStyle(el);2942return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' });2943}29442945// ─── Clipped overflow container ───────────────────────────────────────────────2946// A clipping container (overflow hidden/clip, not a scroll region) wrapping an2947// absolutely/fixed-positioned descendant clips popovers/menus that must escape.2948function classSelector(el) {2949const cls = (el.getAttribute ? el.getAttribute('class') : el.className) || '';2950const tokens = String(cls).trim().split(/\s+/).filter(Boolean);2951const tag = el.tagName ? el.tagName.toLowerCase() : 'el';2952return tokens.length ? `${tag}.${tokens.join('.')}` : tag;2953}29542955function positionedChildIsDecorative(child) {2956if (!child || typeof child.getAttribute !== 'function') return false;2957if (child.closest?.('[aria-hidden="true"]')) return true;2958const role = (child.getAttribute('role') || '').toLowerCase();2959if (role === 'none' || role === 'presentation') return true;2960const tag = child.tagName ? child.tagName.toLowerCase() : '';2961if (['img', 'svg', 'canvas', 'video'].includes(tag)) return true;2962const ident = `${child.getAttribute('class') || ''} ${child.getAttribute('id') || ''}`;2963if (2964/\b(art|bg|background|badge|blob|crop|decor|dot|glow|grain|image|mask|ornament|overlay|photo|scrim|shadow|shine|texture)\b/i.test(ident) &&2965!positionedChildHasSubstantiveContent(child)2966) {2967return true;2968}2969return false;2970}29712972const POSITIONED_CHILD_INTERACTIVE_SELECTOR = [2973'a[href]',2974'button',2975'input',2976'select',2977'summary',2978'textarea',2979'[tabindex]:not([tabindex="-1"])',2980'[role="button"]',2981'[role="dialog"]',2982'[role="link"]',2983'[role="listbox"]',2984'[role="menu"]',2985'[role="menuitem"]',2986'[role="option"]',2987'[role="tooltip"]',2988].join(',');29892990function positionedChildHasSubstantiveContent(child) {2991const text = (child.textContent || '').replace(/\s+/g, ' ').trim();2992if (text.length > 0) return true;2993if (typeof child.matches === 'function') {2994try {2995if (child.matches(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true;2996} catch {}2997}2998if (typeof child.querySelector === 'function') {2999try {3000if (child.querySelector(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true;3001} catch {}3002}3003return false;3004}30053006function clippingContainerIsIntentionalViewport(el) {3007if (!el || typeof el.getAttribute !== 'function') return false;3008const roleDescription = (el.getAttribute('aria-roledescription') || '').toLowerCase();3009if (/\b(carousel|slider)\b/.test(roleDescription)) return true;3010const ident = `${el.getAttribute('class') || ''} ${el.getAttribute('id') || ''}`.toLowerCase();3011return /\b(carousel|comparison|compare|fisheye|marquee|preview|scroller|slider|slideshow|split|viewport)\b/.test(ident) ||3012/\b(demo-area|demo-stage|demo-viewport)\b/.test(ident);3013}30143015function elementRect(el) {3016if (!el || typeof el.getBoundingClientRect !== 'function') return null;3017try {3018const rect = el.getBoundingClientRect();3019if (!rect) return null;3020const values = [rect.top, rect.right, rect.bottom, rect.left, rect.width, rect.height];3021if (!values.every(Number.isFinite)) return null;3022if (rect.width <= 0 && rect.height <= 0) return null;3023return rect;3024} catch {3025return null;3026}3027}30283029function positionedStyleImpliesEscape(style) {3030const values = [3031style.top,3032style.right,3033style.bottom,3034style.left,3035style.inset,3036style.insetBlock,3037style.insetInline,3038style.insetBlockStart,3039style.insetBlockEnd,3040style.insetInlineStart,3041style.insetInlineEnd,3042].filter(Boolean).map(value => String(value).trim().toLowerCase());3043for (const value of values) {3044if (/(^|[\s(])-+(?:\d|\.)/.test(value)) return true;3045if (/(^|[\s(])100(?:\.0+)?%/.test(value)) return true;3046}3047return false;3048}30493050function positionedChildEscapesClip(el, child, clipX, clipY) {3051const parentRect = elementRect(el);3052const childRect = elementRect(child);3053if (!parentRect || !childRect) return null;3054const threshold = 2;3055return Boolean(3056(clipX && (childRect.left < parentRect.left - threshold || childRect.right > parentRect.right + threshold)) ||3057(clipY && (childRect.top < parentRect.top - threshold || childRect.bottom > parentRect.bottom + threshold))3058);3059}30603061function checkClippedOverflow(el, style, getStyle) {3062const clips = (v) => v === 'hidden' || v === 'clip';3063const scrolls = (v) => v === 'auto' || v === 'scroll';3064const ox = style.overflowX || '', oy = style.overflowY || '', ov = style.overflow || '';3065const clipX = clips(ox) || clips(ov);3066const clipY = clips(oy) || clips(ov);3067const anyClip = clipX || clipY;3068const anyScroll = scrolls(ox) || scrolls(oy) || scrolls(ov);3069if (!anyClip || anyScroll) return [];3070if (clippingContainerIsIntentionalViewport(el)) return [];3071if (!el.querySelectorAll) return [];3072for (const child of el.querySelectorAll('*')) {3073const childStyle = getStyle(child);3074const pos = childStyle.position || '';3075if (pos === 'absolute' || pos === 'fixed') {3076if (positionedChildIsDecorative(child)) continue;3077const escapes = positionedChildEscapesClip(el, child, clipX, clipY);3078if (escapes === false) continue;3079if (escapes === null && !positionedStyleImpliesEscape(childStyle)) continue;3080return [{ id: 'clipped-overflow-container', snippet: `${classSelector(el)} clips a positioned child` }];3081}3082}3083return [];3084}30853086function checkElementClippedOverflow(el, style, tag, window) {3087return checkClippedOverflow(el, style, (n) => window.getComputedStyle(n));3088}30893090function checkElementClippedOverflowDOM(el) {3091const style = getComputedStyle(el);3092return checkClippedOverflow(el, style, (n) => getComputedStyle(n));3093}30943095// ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ──────────────3096const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']);30973098function metricLengthPx(value, fontSizePx = 16) {3099if (typeof value === 'number' && Number.isFinite(value)) return value;3100if (typeof value !== 'string') return null;3101return resolveLengthPx(value, fontSizePx);3102}31033104function firstMetricLengthPx(fontSizePx, ...values) {3105for (const value of values) {3106const parsed = metricLengthPx(value, fontSizePx);3107if (parsed !== null) return parsed;3108}3109return null;3110}31113112function expandBoxShorthand(parts) {3113if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]];3114if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]];3115if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]];3116return [parts[0], parts[1], parts[2], parts[3]];3117}31183119function clippedByInset(clipPath) {3120const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/);3121if (!match) return false;3122const beforeRound = match[1].split(/\s+round\s+/)[0].trim();3123if (!beforeRound) return false;3124const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4));3125const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/));3126if (percents.some(match => !match)) return false;3127const [top, right, bottom, left] = percents.map(match => parseFloat(match[1]));3128return top + bottom >= 100 || left + right >= 100;3129}31303131function clippedByRect(clip) {3132const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/);3133if (!match) return false;3134const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean);3135if (values.length !== 4) return false;3136const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16));3137if ([top, right, bottom, left].some(value => value === null)) return false;3138return bottom <= top || right <= left;3139}31403141function isScreenReaderOnlyTextStyle(style, metrics = {}) {3142if (!style) return false;3143const overflowValues = [style.overflow, style.overflowX, style.overflowY]3144.map(value => String(value || '').toLowerCase());3145const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip');31463147const fontSize = metricLengthPx(style.fontSize, 16) || 16;3148const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize);3149const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize);3150const isTiny = width !== null && height !== null && width <= 2 && height <= 2;3151const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow;31523153const clipPath = String(style.clipPath || style.webkitClipPath || '').trim();3154const clip = String(style.clip || '').trim();3155return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip);3156}31573158function isRenderedForBrowserRule(el) {3159for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) {3160if (cur.getAttribute?.('aria-hidden') === 'true') return false;3161const style = getComputedStyle(cur);3162const visibility = String(style.visibility || '').toLowerCase();3163if (style.display === 'none' || visibility === 'hidden' || visibility === 'collapse') return false;3164if ((parseFloat(style.opacity) || 0) <= 0.01) return false;3165if (String(style.contentVisibility || '').toLowerCase() === 'hidden') return false;3166}3167return true;3168}31693170function checkElementTextOverflowDOM(el) {3171const tag = el.tagName.toLowerCase();3172if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];3173if (!isRenderedForBrowserRule(el)) return [];3174// Only the element that actually owns overflowing text — not its ancestors,3175// which inherit a wider scrollWidth from the spilling descendant.3176const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0);3177if (!hasDirectText) return [];3178const style = getComputedStyle(el);3179const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;3180if (isScreenReaderOnlyTextStyle(style, {3181width: rect?.width,3182height: rect?.height,3183clientWidth: el.clientWidth,3184clientHeight: el.clientHeight,3185})) return [];3186const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || '');3187if (isScrollRegion(style)) return [];3188// A scrollable ancestor means this overflow is intentional and scrollable.3189for (let p = el.parentElement; p; p = p.parentElement) {3190if (isScrollRegion(getComputedStyle(p))) return [];3191}3192const delta = el.scrollWidth - el.clientWidth;3193if (el.clientWidth > 0 && delta >= 16) {3194return [{ id: 'text-overflow', snippet: `${classSelector(el)} overflows its box by ${Math.round(delta)}px` }];3195}3196return [];3197}31983199// --- cli/engine/browser/injected/index.mjs ---3200const IS_BROWSER = typeof window !== 'undefined';32013202// ─── Section 7: Browser UI (IS_BROWSER only) ────────────────────────────────32033204if (IS_BROWSER) {3205// Detect extension mode via the script tag's data attribute or the document element fallback.3206// currentScript is reliable for synchronously-executing scripts (which our IIFE is).3207const _myScript = document.currentScript;3208const EXTENSION_MODE = (_myScript && _myScript.dataset.impeccableExtension === 'true')3209|| document.documentElement.dataset.impeccableExtension === 'true';32103211// Kinpaku gold — pinned to the site's brand token (see3212// site/styles/kinpaku-tokens.css --ks-kinpaku). Keep this in sync with3213// the picker's C.brand in skill/scripts/live-browser.js and the kit's3214// picker section in site/styles/kinpaku-kit.css.3215//3216// One color across both light and dark host pages. The outline is a3217// 2px gesture pointing at an element + a labeled tag — it's a marker,3218// not body text, so it doesn't need WCAG AA against the page. The3219// label text inside the gold tag is dark (LABEL_INK) which has ~16:13220// against the leaf gold, so reading the rule name is solid in both3221// modes. Hover deepens the gold (preserves chroma — never drops it,3222// dropping chroma washes the gold into a sand/olive tone).3223const BRAND_COLOR = 'oklch(84% 0.19 80.46)';3224const BRAND_COLOR_HOVER = 'oklch(74% 0.18 80)';3225const LABEL_INK = 'oklch(4% 0.004 95)';3226const LABEL_BG = BRAND_COLOR;3227const OUTLINE_COLOR = BRAND_COLOR;32283229// Inject hover styles via CSS (more reliable than JS event listeners)3230const styleEl = document.createElement('style');3231styleEl.textContent = `3232@keyframes impeccable-reveal {3233from { opacity: 0; }3234to { opacity: 1; }3235}3236.impeccable-overlay:not(.impeccable-banner) {3237pointer-events: none;3238outline: 2px solid ${OUTLINE_COLOR};3239border-radius: 4px;3240transition: outline-color 0.15s ease;3241animation: impeccable-reveal 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;3242animation-play-state: paused;3243border-top-left-radius: 0;3244}3245.impeccable-overlay.impeccable-visible {3246animation-play-state: running;3247}3248.impeccable-overlay.impeccable-hover {3249outline-color: ${BRAND_COLOR_HOVER};3250z-index: 100001 !important;3251}3252.impeccable-overlay.impeccable-hover .impeccable-label {3253background: ${BRAND_COLOR_HOVER};3254}3255.impeccable-overlay.impeccable-spotlight {3256z-index: 100002 !important;3257}3258.impeccable-overlay.impeccable-spotlight-dimmed {3259opacity: 0.15 !important;3260animation: none !important;3261filter: blur(3px);3262}3263.impeccable-spotlight-backdrop {3264position: fixed;3265top: 0; left: 0; right: 0; bottom: 0;3266backdrop-filter: blur(3px) brightness(0.6);3267-webkit-backdrop-filter: blur(3px) brightness(0.6);3268pointer-events: none;3269z-index: 99998;3270opacity: 0;3271outline: none !important;3272animation: none !important;3273}3274.impeccable-spotlight-backdrop.impeccable-visible {3275opacity: 1;3276}3277.impeccable-hidden .impeccable-overlay${EXTENSION_MODE ? '' : ':not(.impeccable-banner)'} {3278display: none !important;3279}3280`;3281(document.head || document.documentElement).appendChild(styleEl);32823283// Spotlight backdrop element (created lazily on first use)3284let spotlightBackdrop = null;3285let spotlightTarget = null;32863287function getSpotlightBackdrop() {3288if (!spotlightBackdrop) {3289spotlightBackdrop = document.createElement('div');3290spotlightBackdrop.className = 'impeccable-spotlight-backdrop';3291document.body.appendChild(spotlightBackdrop);3292}3293return spotlightBackdrop;3294}32953296function updateSpotlightClipPath() {3297if (!spotlightBackdrop || !spotlightTarget) return;3298const r = spotlightTarget.getBoundingClientRect();3299// Match the overlay's outer edge: element rect + 4px (2px overlay offset + 2px outline width)3300const inset = 4;3301const radius = 6; // outline border-radius (4) + outline width (2)3302const x1 = r.left - inset;3303const y1 = r.top - inset;3304const x2 = r.right + inset;3305const y2 = r.bottom + inset;3306const vw = window.innerWidth;3307const vh = window.innerHeight;3308// Outer rect + rounded inner rect (evenodd creates a hole)3309const path = `M0 0H${vw}V${vh}H0Z M${x1 + radius} ${y1}H${x2 - radius}A${radius} ${radius} 0 0 1 ${x2} ${y1 + radius}V${y2 - radius}A${radius} ${radius} 0 0 1 ${x2 - radius} ${y2}H${x1 + radius}A${radius} ${radius} 0 0 1 ${x1} ${y2 - radius}V${y1 + radius}A${radius} ${radius} 0 0 1 ${x1 + radius} ${y1}Z`;3310spotlightBackdrop.style.clipPath = `path(evenodd, "${path}")`;3311}33123313function showSpotlight(target) {3314if (!target || !target.getBoundingClientRect) return;3315// Respect the spotlightBlur setting: if disabled, don't show the backdrop3316if (window.__IMPECCABLE_CONFIG__?.spotlightBlur === false) {3317spotlightTarget = target;3318return;3319}3320spotlightTarget = target;3321const bd = getSpotlightBackdrop();3322updateSpotlightClipPath();3323bd.classList.add('impeccable-visible');3324}33253326function hideSpotlight() {3327spotlightTarget = null;3328if (spotlightBackdrop) spotlightBackdrop.classList.remove('impeccable-visible');3329}33303331function isInViewport(el) {3332const r = el.getBoundingClientRect();3333return r.top >= 0 && r.left >= 0 && r.bottom <= window.innerHeight && r.right <= window.innerWidth;3334}33353336// Reposition spotlight on scroll/resize3337window.addEventListener('scroll', () => {3338if (spotlightTarget) updateSpotlightClipPath();3339}, { passive: true });3340window.addEventListener('resize', () => {3341if (spotlightTarget) updateSpotlightClipPath();3342});33433344const overlays = [];3345const TYPE_LABELS = {};3346const RULE_CATEGORY = {};3347for (const ap of ANTIPATTERNS) {3348TYPE_LABELS[ap.id] = ap.name.toLowerCase();3349RULE_CATEGORY[ap.id] = ap.category || 'quality';3350}33513352function isInFixedContext(el) {3353let p = el;3354while (p && p !== document.body) {3355if (getComputedStyle(p).position === 'fixed') return true;3356p = p.parentElement;3357}3358return false;3359}33603361function positionOverlay(overlay) {3362const el = overlay._targetEl;3363if (!el) return;3364const rect = el.getBoundingClientRect();3365if (overlay._isFixed) {3366// Viewport-relative coords for fixed targets3367overlay.style.top = `${rect.top - 2}px`;3368overlay.style.left = `${rect.left - 2}px`;3369} else {3370// Document-relative coords for normal targets3371overlay.style.top = `${rect.top + scrollY - 2}px`;3372overlay.style.left = `${rect.left + scrollX - 2}px`;3373}3374overlay.style.width = `${rect.width + 4}px`;3375overlay.style.height = `${rect.height + 4}px`;3376}33773378function repositionOverlays() {3379for (const o of overlays) {3380if (!o._targetEl || o.classList.contains('impeccable-banner')) continue;3381// Skip overlays whose target is currently hidden (display: none on the overlay)3382if (o.style.display === 'none') continue;3383positionOverlay(o);3384}3385}33863387let resizeRAF;3388const onResize = () => {3389cancelAnimationFrame(resizeRAF);3390resizeRAF = requestAnimationFrame(repositionOverlays);3391};3392window.addEventListener('resize', onResize);3393// Reposition on scroll too -- catches sticky/parallax shifts3394window.addEventListener('scroll', onResize, { passive: true });3395// Reposition when body resizes (lazy-loaded images, dynamic content, fonts loading)3396if (typeof ResizeObserver !== 'undefined') {3397const bodyResizeObserver = new ResizeObserver(onResize);3398bodyResizeObserver.observe(document.body);3399}34003401// Track target element visibility via IntersectionObserver.3402// Uses a huge rootMargin so all *rendered* elements count as intersecting,3403// while display:none / closed <details> / hidden modals etc. do not.3404// This is event-driven -- no polling needed.3405let overlayIndex = 0;3406const visibilityObserver = new IntersectionObserver((entries) => {3407for (const entry of entries) {3408const overlay = entry.target._impeccableOverlay;3409if (!overlay) continue;3410if (entry.isIntersecting) {3411overlay.style.display = '';3412positionOverlay(overlay);3413if (!overlay._revealed) {3414overlay._revealed = true;3415if (firstScanDone) {3416// Subsequent reveals (re-scans, scroll-into-view): instant, no animation3417overlay.style.animation = 'none';3418} else {3419// Initial scan: staggered cascade reveal3420overlay.style.animationDelay = `${Math.min((overlay._staggerIndex || 0) * 60, 600)}ms`;3421}3422requestAnimationFrame(() => {3423overlay.classList.add('impeccable-visible');3424if (overlay._checkLabel) overlay._checkLabel();3425});3426}3427} else {3428overlay.style.display = 'none';3429}3430}3431}, { rootMargin: '99999px' });34323433function detachOverlay(overlay) {3434if (!overlay) return;3435if (typeof overlay._cleanup === 'function') {3436try { overlay._cleanup(); } catch { /* best effort overlay teardown */ }3437}3438if (overlay._targetEl && overlay._targetEl._impeccableOverlay === overlay) {3439visibilityObserver.unobserve(overlay._targetEl);3440delete overlay._targetEl._impeccableOverlay;3441}3442const idx = overlays.indexOf(overlay);3443if (idx >= 0) overlays.splice(idx, 1);3444overlay.remove();3445}34463447// Reposition overlays after CSS transitions end (e.g. reveal animations).3448// Listens at document level so it catches transitions on ancestor elements3449// (the transform may be on a parent, not the flagged element itself).3450document.addEventListener('transitionend', (e) => {3451if (e.propertyName !== 'transform') return;3452for (const o of overlays) {3453if (!o._targetEl || o.classList.contains('impeccable-banner') || o.style.display === 'none') continue;3454if (e.target === o._targetEl || e.target.contains(o._targetEl)) {3455positionOverlay(o);3456}3457}3458});34593460const highlight = function(el, findings) {3461if (el._impeccableOverlay) detachOverlay(el._impeccableOverlay);3462const hasSlop = findings.some(f => RULE_CATEGORY[f.type || f.id] === 'slop');34633464const fixed = isInFixedContext(el);3465const rect = el.getBoundingClientRect();3466const outline = document.createElement('div');3467outline.className = 'impeccable-overlay';3468outline._targetEl = el;3469outline._isFixed = fixed;3470Object.assign(outline.style, {3471position: fixed ? 'fixed' : 'absolute',3472top: fixed ? `${rect.top - 2}px` : `${rect.top + scrollY - 2}px`,3473left: fixed ? `${rect.left - 2}px` : `${rect.left + scrollX - 2}px`,3474width: `${rect.width + 4}px`, height: `${rect.height + 4}px`,3475zIndex: '99999', boxSizing: 'border-box',3476});34773478// Build per-finding label entries: ✦ prefix for slop3479const entries = findings.map(f => {3480const name = TYPE_LABELS[f.type || f.id] || f.type || f.id;3481const prefix = RULE_CATEGORY[f.type || f.id] === 'slop' ? '\u2726 ' : '';3482return { name: prefix + name, detail: f.detail || f.snippet };3483});3484const allText = entries.map(e => e.name).join(', ');34853486const label = document.createElement('div');3487label.className = 'impeccable-label';3488Object.assign(label.style, {3489position: 'absolute', bottom: '100%', left: '-2px',3490display: 'flex', alignItems: 'center',3491whiteSpace: 'nowrap',3492fontSize: '11px', fontWeight: '600', letterSpacing: '0.02em',3493color: LABEL_INK, lineHeight: '14px',3494background: LABEL_BG,3495fontFamily: 'system-ui, sans-serif',3496borderRadius: '4px 4px 0 0',3497});34983499const textSpan = document.createElement('span');3500textSpan.style.padding = '3px 8px';3501textSpan.textContent = allText;3502label.appendChild(textSpan);35033504// State for cycling mode3505let cycleMode = false;3506let cycleIndex = 0;3507let isHovered = false;3508let prevBtn, nextBtn;35093510function updateCycleText() {3511const e = entries[cycleIndex];3512textSpan.textContent = isHovered ? e.detail : e.name;3513}35143515function enableCycleMode() {3516if (cycleMode || entries.length < 2) return;3517cycleMode = true;35183519const btnStyle = {3520background: 'none', border: 'none', color: 'rgba(255,255,255,0.7)',3521fontSize: '11px', cursor: 'pointer', padding: '3px 4px',3522fontFamily: 'system-ui, sans-serif', lineHeight: '14px',3523pointerEvents: 'auto',3524};35253526const navGroup = document.createElement('span');3527Object.assign(navGroup.style, {3528display: 'inline-flex', alignItems: 'center', flexShrink: '0',3529});35303531prevBtn = document.createElement('button');3532prevBtn.textContent = '\u2039';3533Object.assign(prevBtn.style, btnStyle);3534prevBtn.style.paddingLeft = '6px';3535prevBtn.addEventListener('click', (e) => {3536e.stopPropagation();3537cycleIndex = (cycleIndex - 1 + entries.length) % entries.length;3538updateCycleText();3539});35403541nextBtn = document.createElement('button');3542nextBtn.textContent = '\u203A';3543Object.assign(nextBtn.style, btnStyle);3544nextBtn.style.paddingRight = '2px';3545nextBtn.addEventListener('click', (e) => {3546e.stopPropagation();3547cycleIndex = (cycleIndex + 1) % entries.length;3548updateCycleText();3549});35503551navGroup.appendChild(prevBtn);3552navGroup.appendChild(nextBtn);3553label.insertBefore(navGroup, textSpan);3554textSpan.style.padding = '3px 8px 3px 4px';3555updateCycleText();3556}35573558outline.appendChild(label);35593560// Start hidden; the IntersectionObserver will show it once the target is rendered3561outline.style.display = 'none';3562outline._staggerIndex = overlayIndex++;3563el._impeccableOverlay = outline;3564visibilityObserver.observe(el);35653566// After first paint, check label width vs outline3567outline._checkLabel = () => {3568if (entries.length > 1 && label.offsetWidth > outline.offsetWidth) {3569enableCycleMode();3570}3571};35723573// Hover: show detail text, darken3574const onMouseEnter = () => {3575isHovered = true;3576outline.classList.add('impeccable-hover');3577outline.style.outlineColor = BRAND_COLOR_HOVER;3578label.style.background = BRAND_COLOR_HOVER;3579if (cycleMode) {3580updateCycleText();3581} else {3582textSpan.textContent = entries.map(e => e.detail).join(' | ');3583}3584};3585const onMouseLeave = () => {3586isHovered = false;3587outline.classList.remove('impeccable-hover');3588outline.style.outlineColor = '';3589label.style.background = LABEL_BG;3590if (cycleMode) {3591updateCycleText();3592} else {3593textSpan.textContent = allText;3594}3595};3596el.addEventListener('mouseenter', onMouseEnter);3597el.addEventListener('mouseleave', onMouseLeave);3598outline._cleanup = () => {3599el.removeEventListener('mouseenter', onMouseEnter);3600el.removeEventListener('mouseleave', onMouseLeave);3601};36023603document.body.appendChild(outline);3604overlays.push(outline);3605};36063607const showPageBanner = function(findings) {3608if (!findings.length) return;3609const banner = document.createElement('div');3610banner.className = 'impeccable-overlay impeccable-banner';3611Object.assign(banner.style, {3612position: 'fixed', top: '0', left: '0', right: '0', zIndex: '100000',3613background: LABEL_BG, color: LABEL_INK,3614fontFamily: 'system-ui, sans-serif', fontSize: '13px',3615display: 'flex', alignItems: 'center', pointerEvents: 'auto',3616height: '36px', overflow: 'hidden', maxWidth: '100vw',3617transform: 'translateY(-100%)',3618transition: 'transform 0.4s cubic-bezier(0.16, 1, 0.3, 1)',3619});3620requestAnimationFrame(() => requestAnimationFrame(() => {3621banner.style.transform = 'translateY(0)';3622}));36233624// Scrollable findings area3625const scrollArea = document.createElement('div');3626Object.assign(scrollArea.style, {3627flex: '1', minWidth: '0', overflowX: 'auto', overflowY: 'hidden',3628display: 'flex', gap: '8px', alignItems: 'center',3629padding: '0 12px', scrollSnapType: 'x mandatory',3630scrollbarWidth: 'none',3631});3632for (const f of findings) {3633const prefix = RULE_CATEGORY[f.type] === 'slop' ? '\u2726 ' : '';3634const tag = document.createElement('span');3635tag.textContent = `${prefix}${TYPE_LABELS[f.type] || f.type}: ${f.detail}`;3636Object.assign(tag.style, {3637background: 'rgba(255,255,255,0.15)', padding: '2px 8px',3638borderRadius: '3px', fontSize: '12px', fontFamily: 'ui-monospace, monospace',3639whiteSpace: 'nowrap', flexShrink: '0', scrollSnapAlign: 'start',3640});3641scrollArea.appendChild(tag);3642}3643banner.appendChild(scrollArea);36443645// Controls area (only in standalone mode, not extension)3646if (!EXTENSION_MODE) {3647const controls = document.createElement('div');3648Object.assign(controls.style, {3649display: 'flex', alignItems: 'center', gap: '2px',3650padding: '0 8px', flexShrink: '0',3651});36523653// Toggle visibility button3654const toggle = document.createElement('button');3655toggle.textContent = '\u25C9'; // circle with dot (visible state)3656toggle.title = 'Toggle overlay visibility';3657Object.assign(toggle.style, {3658background: 'none', border: 'none',3659color: 'white', fontSize: '16px', cursor: 'pointer', padding: '0 4px',3660opacity: '0.85', transition: 'opacity 0.15s',3661});3662let overlaysVisible = true;3663toggle.addEventListener('click', () => {3664overlaysVisible = !overlaysVisible;3665document.body.classList.toggle('impeccable-hidden', !overlaysVisible);3666toggle.textContent = overlaysVisible ? '\u25C9' : '\u25CB'; // filled vs empty circle3667toggle.style.opacity = overlaysVisible ? '0.85' : '0.5';3668});3669controls.appendChild(toggle);36703671// Close button3672const close = document.createElement('button');3673close.textContent = '\u00d7';3674close.title = 'Dismiss banner';3675Object.assign(close.style, {3676background: 'none', border: 'none',3677color: 'white', fontSize: '18px', cursor: 'pointer', padding: '0 4px',3678});3679close.addEventListener('click', () => banner.remove());3680controls.appendChild(close);36813682banner.appendChild(controls);3683}3684document.body.appendChild(banner);3685overlays.push(banner);3686};36873688// Heuristic for skipping CSS-in-JS hashed class names like "css-1a2b3c" or "_2x4hG_".3689// These change between builds and produce brittle, ugly selectors.3690function isLikelyHashedClass(c) {3691if (!c) return true;3692if (/^(css|sc|emotion|jsx|module)-[\w-]{4,}$/i.test(c)) return true;3693if (/^_[\w-]{5,}$/.test(c)) return true;3694if (/^[a-z0-9]{6,}$/i.test(c) && /\d/.test(c)) return true;3695return false;3696}36973698function buildSelectorSegment(el) {3699const tag = el.tagName.toLowerCase();3700let sel = tag;37013702if (el.classList && el.classList.length > 0) {3703const classes = [...el.classList]3704.filter(c => !c.startsWith('impeccable-') && !isLikelyHashedClass(c))3705.slice(0, 2);3706if (classes.length > 0) {3707sel += '.' + classes.map(c => CSS.escape(c)).join('.');3708}3709}37103711// Disambiguate among siblings only if the parent has multiple matches3712const parent = el.parentElement;3713if (parent) {3714try {3715const matching = parent.querySelectorAll(':scope > ' + sel);3716if (matching.length > 1) {3717const sameType = [...parent.children].filter(c => c.tagName === el.tagName);3718const idx = sameType.indexOf(el) + 1;3719sel += `:nth-of-type(${idx})`;3720}3721} catch {3722const idx = [...parent.children].indexOf(el) + 1;3723sel = `${tag}:nth-child(${idx})`;3724}3725}3726return sel;3727}37283729function generateSelector(el) {3730if (el === document.body) return 'body';3731if (el === document.documentElement) return 'html';3732if (el.id) return '#' + CSS.escape(el.id);37333734const parts = [];3735let current = el;3736let depth = 0;3737const MAX_DEPTH = 10;37383739while (current && current !== document.body && current !== document.documentElement && depth < MAX_DEPTH) {3740parts.unshift(buildSelectorSegment(current));37413742// Anchor on an ancestor's ID and stop walking up3743if (current.id) {3744parts[0] = '#' + CSS.escape(current.id);3745break;3746}37473748// Stop as soon as the partial selector uniquely identifies the target3749const trySelector = parts.join(' > ');3750try {3751const matches = document.querySelectorAll(trySelector);3752if (matches.length === 1 && matches[0] === el) {3753return trySelector;3754}3755} catch { /* invalid selector — keep walking */ }37563757current = current.parentElement;3758depth++;3759}37603761return parts.join(' > ');3762}37633764function getDirectText(el) {3765return [...el.childNodes]3766.filter(n => n.nodeType === 3)3767.map(n => n.textContent || '')3768.join('');3769}37703771function getDirectTextRect(el) {3772const rects = [];3773for (const node of el.childNodes) {3774if (node.nodeType !== 3 || !(node.textContent || '').trim()) continue;3775const range = document.createRange();3776range.selectNodeContents(node);3777for (const rect of range.getClientRects()) {3778if (rect.width >= 1 && rect.height >= 1) rects.push(rect);3779}3780range.detach?.();3781}3782if (rects.length === 0) return null;3783const left = Math.min(...rects.map(r => r.left));3784const top = Math.min(...rects.map(r => r.top));3785const right = Math.max(...rects.map(r => r.right));3786const bottom = Math.max(...rects.map(r => r.bottom));3787return {3788left,3789top,3790right,3791bottom,3792width: right - left,3793height: bottom - top,3794x: left,3795y: top,3796};3797}37983799function collectVisualContrastReasons(el, style) {3800const reasons = new Set();3801const bgClip = style.webkitBackgroundClip || style.backgroundClip || '';3802const ownBgImage = style.backgroundImage || '';3803if (bgClip === 'text' && ownBgImage && ownBgImage !== 'none') {3804reasons.add('background-clip text');3805}3806if (style.textShadow && style.textShadow !== 'none') reasons.add('text shadow');38073808let current = el;3809while (current && current.nodeType === 1) {3810const tag = current.tagName?.toLowerCase();3811const currentStyle = getComputedStyle(current);3812const bgImage = currentStyle.backgroundImage || '';3813const isDocumentSurface = tag === 'body' || tag === 'html';38143815if (!isDocumentSurface && bgImage && bgImage !== 'none') {3816if (/url\s*\(/i.test(bgImage)) reasons.add('image background');3817if (/gradient/i.test(bgImage)) reasons.add('gradient background');3818}3819if (parseFloat(currentStyle.opacity) < 0.99) reasons.add('opacity stack');3820if (currentStyle.mixBlendMode && currentStyle.mixBlendMode !== 'normal') reasons.add('blend mode');3821if (currentStyle.filter && currentStyle.filter !== 'none') reasons.add('filter');3822if (currentStyle.backdropFilter && currentStyle.backdropFilter !== 'none') reasons.add('backdrop filter');38233824const solidBg = parseRgb(currentStyle.backgroundColor);3825if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break;3826current = current.parentElement;3827}38283829const sampleRect = getDirectTextRect(el) || el.getBoundingClientRect();3830if (sampleRect && document.elementsFromPoint) {3831const points = [3832[sampleRect.left + sampleRect.width / 2, sampleRect.top + sampleRect.height / 2],3833[sampleRect.left + Math.min(sampleRect.width - 1, Math.max(1, sampleRect.width * 0.25)), sampleRect.top + sampleRect.height / 2],3834[sampleRect.left + Math.min(sampleRect.width - 1, Math.max(1, sampleRect.width * 0.75)), sampleRect.top + sampleRect.height / 2],3835];3836for (const [x, y] of points) {3837if (x < 0 || y < 0 || x > window.innerWidth || y > window.innerHeight) continue;3838const stack = document.elementsFromPoint(x, y);3839const selfIndex = stack.findIndex(node => node === el || el.contains(node) || node.contains?.(el));3840if (selfIndex < 0) continue;3841for (const node of stack.slice(selfIndex + 1)) {3842const nodeTag = node.tagName?.toLowerCase();3843if (nodeTag === 'img' || nodeTag === 'picture' || nodeTag === 'video' || nodeTag === 'canvas' || nodeTag === 'svg') {3844reasons.add(`${nodeTag} underlay`);3845break;3846}3847}3848}3849}38503851return [...reasons];3852}38533854function collectVisualContrastCandidates(options = {}) {3855const maxCandidates = Number.isFinite(options.maxCandidates) ? options.maxCandidates : 12;3856const candidates = [];3857for (const el of document.querySelectorAll('*')) {3858if (candidates.length >= maxCandidates) break;3859if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue;3860if (el.closest('[id^="impeccable-live-"]')) continue;3861if (el === document.body || el === document.documentElement) continue;3862if (!isRenderedForBrowserRule(el)) continue;38633864const tag = el.tagName.toLowerCase();3865const style = getComputedStyle(el);3866if (style.display === 'none' || style.visibility === 'hidden') continue;3867const directText = getDirectText(el);3868const hasDirectText = directText.trim().length > 0;3869if (!hasDirectText || isEmojiOnlyText(directText)) continue;38703871const bgColor = readOwnBackgroundColor(el, style);3872const isStyledButton = (tag === 'a' || tag === 'button')3873&& bgColor && bgColor.a > 0.5;3874if (SAFE_TAGS.has(tag) && !isStyledButton) continue;38753876const rect = getDirectTextRect(el) || el.getBoundingClientRect();3877if (!rect || rect.width < 4 || rect.height < 4) continue;38783879const reasons = collectVisualContrastReasons(el, style);3880if (reasons.length === 0) continue;38813882const textColor = parseRgb(style.color);3883const fontSize = parseFloat(style.fontSize) || 16;3884const fontWeight = parseInt(style.fontWeight) || 400;3885const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700);3886const threshold = isLargeText ? 3.0 : 4.5;3887const clip = {3888x: Math.max(0, Math.floor(rect.left + window.scrollX - 2)),3889y: Math.max(0, Math.floor(rect.top + window.scrollY - 2)),3890width: Math.max(1, Math.ceil(rect.width + 4)),3891height: Math.max(1, Math.ceil(rect.height + 4)),3892};38933894candidates.push({3895selector: generateSelector(el),3896tagName: tag,3897text: directText.trim().replace(/\s+/g, ' ').slice(0, 80),3898threshold,3899reasons,3900clip,3901textColor,3902preferRenderedForeground: !textColor || textColor.a < 0.99 || reasons.some(reason =>3903reason === 'opacity stack' ||3904reason === 'blend mode' ||3905reason === 'filter' ||3906reason === 'backdrop filter' ||3907reason === 'background-clip text'3908),3909backgroundClipText: reasons.includes('background-clip text'),3910});3911}3912return candidates;3913}39143915const visualContrastImageCache = new Map();3916const visualContrastRasterCache = new WeakMap();39173918function clampByte(value) {3919return Math.max(0, Math.min(255, Math.round(value)));3920}39213922function blendRgba(fg, bg) {3923if (!fg) return bg || null;3924if (!bg || fg.a == null || fg.a >= 0.999) {3925return { r: clampByte(fg.r), g: clampByte(fg.g), b: clampByte(fg.b), a: fg.a == null ? 1 : fg.a };3926}3927const alpha = Math.max(0, Math.min(1, fg.a));3928return {3929r: clampByte(fg.r * alpha + bg.r * (1 - alpha)),3930g: clampByte(fg.g * alpha + bg.g * (1 - alpha)),3931b: clampByte(fg.b * alpha + bg.b * (1 - alpha)),3932a: 1,3933};3934}39353936function pickWorstContrastColor(textColor, colors) {3937const usable = (colors || []).filter(Boolean);3938if (!usable.length) return null;3939let worst = usable[0];3940let worstRatio = contrastRatio(textColor, worst);3941for (const color of usable.slice(1)) {3942const ratio = contrastRatio(textColor, color);3943if (ratio < worstRatio) {3944worst = color;3945worstRatio = ratio;3946}3947}3948return worst;3949}39503951function firstCssUrl(value) {3952const match = String(value || '').match(/url\((?:"([^"]+)"|'([^']+)'|([^)]*))\)/i);3953if (!match) return '';3954return (match[1] || match[2] || match[3] || '').trim();3955}39563957function getLayerValue(value, index = 0) {3958return String(value || '').split(',')[index]?.trim() || '';3959}39603961function parsePositionToken(token, container, painted) {3962if (!token || token === 'center') return (container - painted) / 2;3963if (token === 'left' || token === 'top') return 0;3964if (token === 'right' || token === 'bottom') return container - painted;3965if (/%$/.test(token)) {3966const pct = parseFloat(token) / 100;3967return (container - painted) * pct;3968}3969if (/px$/.test(token)) return parseFloat(token) || 0;3970return (container - painted) / 2;3971}39723973function parsePositionPair(positionValue) {3974const tokens = String(positionValue || '50% 50%').trim().split(/\s+/).filter(Boolean);3975const first = tokens[0] || '50%';3976if (tokens.length < 2) {3977if (first === 'top' || first === 'bottom') return ['50%', first];3978return [first, '50%'];3979}3980return [first, tokens[1] || '50%'];3981}39823983function resolvePaintedImageRect(containerRect, image, sizeValue, positionValue) {3984const intrinsicWidth = image.naturalWidth || image.videoWidth || image.width || 1;3985const intrinsicHeight = image.naturalHeight || image.videoHeight || image.height || 1;3986let paintedWidth = intrinsicWidth;3987let paintedHeight = intrinsicHeight;3988const size = String(sizeValue || 'auto').trim();39893990if (size === 'cover' || size === 'contain') {3991const scale = size === 'cover'3992? Math.max(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight)3993: Math.min(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight);3994paintedWidth = intrinsicWidth * scale;3995paintedHeight = intrinsicHeight * scale;3996} else if (size && size !== 'auto') {3997const parts = size.split(/\s+/);3998const widthToken = parts[0];3999const heightToken = parts[1] || 'auto';4000if (/%$/.test(widthToken)) paintedWidth = containerRect.width * (parseFloat(widthToken) / 100);4001else if (/px$/.test(widthToken)) paintedWidth = parseFloat(widthToken) || paintedWidth;4002if (heightToken === 'auto') paintedHeight = paintedWidth * (intrinsicHeight / intrinsicWidth);4003else if (/%$/.test(heightToken)) paintedHeight = containerRect.height * (parseFloat(heightToken) / 100);4004else if (/px$/.test(heightToken)) paintedHeight = parseFloat(heightToken) || paintedHeight;4005}40064007const [xToken, yToken] = parsePositionPair(positionValue);4008const positionX = parsePositionToken(xToken, containerRect.width, paintedWidth);4009const positionY = parsePositionToken(yToken, containerRect.height, paintedHeight);4010return {4011left: containerRect.left + positionX,4012top: containerRect.top + positionY,4013width: paintedWidth,4014height: paintedHeight,4015intrinsicWidth,4016intrinsicHeight,4017};4018}40194020function parseObjectPosition(positionValue) {4021return parsePositionPair(positionValue);4022}40234024function resolveObjectImageRect(containerRect, image, style) {4025const intrinsicWidth = image.naturalWidth || image.videoWidth || image.width || 1;4026const intrinsicHeight = image.naturalHeight || image.videoHeight || image.height || 1;4027const fit = style.objectFit || 'fill';4028let paintedWidth = containerRect.width;4029let paintedHeight = containerRect.height;4030if (fit === 'contain' || fit === 'cover') {4031const scale = fit === 'cover'4032? Math.max(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight)4033: Math.min(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight);4034paintedWidth = intrinsicWidth * scale;4035paintedHeight = intrinsicHeight * scale;4036} else if (fit === 'none') {4037paintedWidth = intrinsicWidth;4038paintedHeight = intrinsicHeight;4039} else if (fit === 'scale-down') {4040const containScale = Math.min(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight, 1);4041paintedWidth = intrinsicWidth * containScale;4042paintedHeight = intrinsicHeight * containScale;4043}4044const [xToken, yToken] = parseObjectPosition(style.objectPosition);4045return {4046left: containerRect.left + parsePositionToken(xToken, containerRect.width, paintedWidth),4047top: containerRect.top + parsePositionToken(yToken, containerRect.height, paintedHeight),4048width: paintedWidth,4049height: paintedHeight,4050intrinsicWidth,4051intrinsicHeight,4052};4053}40544055function pointToImageSource(point, paintedRect) {4056if (4057point.x < paintedRect.left ||4058point.y < paintedRect.top ||4059point.x > paintedRect.left + paintedRect.width ||4060point.y > paintedRect.top + paintedRect.height4061) {4062return null;4063}4064return {4065x: Math.max(0, Math.min(paintedRect.intrinsicWidth - 1, ((point.x - paintedRect.left) / paintedRect.width) * paintedRect.intrinsicWidth)),4066y: Math.max(0, Math.min(paintedRect.intrinsicHeight - 1, ((point.y - paintedRect.top) / paintedRect.height) * paintedRect.intrinsicHeight)),4067};4068}40694070async function loadVisualContrastImage(src) {4071if (!src) return null;4072if (visualContrastImageCache.has(src)) return visualContrastImageCache.get(src);4073const promise = new Promise(resolve => {4074const img = new Image();4075let settled = false;4076const finish = value => {4077if (settled) return;4078settled = true;4079clearTimeout(timer);4080resolve(value);4081};4082const timer = setTimeout(() => finish(null), 800);4083try {4084const absolute = new URL(src, location.href);4085if (absolute.origin !== location.origin && absolute.protocol !== 'data:' && absolute.protocol !== 'blob:') {4086img.crossOrigin = 'anonymous';4087}4088} catch {4089// Let the browser resolve unusual URLs itself.4090}4091img.onload = () => finish(img);4092img.onerror = () => finish(null);4093img.src = src;4094});4095visualContrastImageCache.set(src, promise);4096return promise;4097}40984099function sampleDrawablePixel(drawable, sourcePoint) {4100if (visualContrastRasterCache.has(drawable)) {4101const cached = visualContrastRasterCache.get(drawable);4102if (!cached || !cached.ctx) return { status: 'unresolved', reason: cached?.reason || 'image sample failed' };4103try {4104const x = Math.max(0, Math.min(cached.width - 1, Math.floor(sourcePoint.x * cached.scaleX)));4105const y = Math.max(0, Math.min(cached.height - 1, Math.floor(sourcePoint.y * cached.scaleY)));4106const data = cached.ctx.getImageData(x, y, 1, 1).data;4107return {4108status: 'sampled',4109color: { r: data[0], g: data[1], b: data[2], a: data[3] / 255 },4110};4111} catch (err) {4112return {4113status: 'unresolved',4114reason: /taint|cross-origin|Security/i.test(err?.message || '') ? 'tainted image' : 'image sample failed',4115};4116}4117}41184119const canvas = document.createElement('canvas');4120const intrinsicWidth = drawable.naturalWidth || drawable.videoWidth || drawable.width || 1;4121const intrinsicHeight = drawable.naturalHeight || drawable.videoHeight || drawable.height || 1;4122const maxRasterSide = 640;4123const scale = Math.min(1, maxRasterSide / Math.max(intrinsicWidth, intrinsicHeight));4124canvas.width = Math.max(1, Math.round(intrinsicWidth * scale));4125canvas.height = Math.max(1, Math.round(intrinsicHeight * scale));4126const ctx = canvas.getContext('2d', { willReadFrequently: true });4127if (!ctx) return { status: 'unresolved', reason: 'canvas unavailable' };4128try {4129ctx.drawImage(drawable, 0, 0, canvas.width, canvas.height);4130const cached = {4131ctx,4132width: canvas.width,4133height: canvas.height,4134scaleX: canvas.width / intrinsicWidth,4135scaleY: canvas.height / intrinsicHeight,4136};4137visualContrastRasterCache.set(drawable, cached);4138const x = Math.max(0, Math.min(cached.width - 1, Math.floor(sourcePoint.x * cached.scaleX)));4139const y = Math.max(0, Math.min(cached.height - 1, Math.floor(sourcePoint.y * cached.scaleY)));4140const data = ctx.getImageData(x, y, 1, 1).data;4141return {4142status: 'sampled',4143color: { r: data[0], g: data[1], b: data[2], a: data[3] / 255 },4144};4145} catch (err) {4146const reason = /taint|cross-origin|Security/i.test(err?.message || '') ? 'tainted image' : 'image sample failed';4147visualContrastRasterCache.set(drawable, { ctx: null, reason });4148return {4149status: 'unresolved',4150reason,4151};4152}4153}41544155async function sampleCssBackground(el, style, point, textColor) {4156const rect = el.getBoundingClientRect();4157const bgImage = style.backgroundImage || '';4158if (bgImage && bgImage !== 'none') {4159if (/gradient/i.test(bgImage)) {4160const color = pickWorstContrastColor(textColor, parseGradientColors(bgImage));4161if (color) return { status: 'sampled', color, method: 'analytic-gradient' };4162}4163if (/url\s*\(/i.test(bgImage)) {4164const img = await loadVisualContrastImage(firstCssUrl(bgImage));4165if (!img) return { status: 'unresolved', reason: 'image unavailable' };4166const paintedRect = resolvePaintedImageRect(4167rect,4168img,4169getLayerValue(style.backgroundSize) || 'auto',4170getLayerValue(style.backgroundPosition) || '50% 50%',4171);4172const sourcePoint = pointToImageSource(point, paintedRect);4173if (!sourcePoint) return { status: 'unresolved', reason: 'point outside background image' };4174const sample = sampleDrawablePixel(img, sourcePoint);4175if (sample.status === 'sampled') return { ...sample, method: 'canvas-background-image' };4176return sample;4177}4178}4179const bg = parseRgb(style.backgroundColor);4180if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' };4181return { status: 'unresolved', reason: 'no readable background' };4182}41834184async function sampleImageElement(img, point) {4185const rect = img.getBoundingClientRect();4186const style = getComputedStyle(img);4187const paintedRect = resolveObjectImageRect(rect, img, style);4188const sourcePoint = pointToImageSource(point, paintedRect);4189if (!sourcePoint) return { status: 'unresolved', reason: 'point outside image' };4190const sample = sampleDrawablePixel(img, sourcePoint);4191if (sample.status === 'sampled') return { ...sample, method: 'canvas-img-underlay' };41924193if (img.currentSrc || img.src) {4194const loaded = await loadVisualContrastImage(img.currentSrc || img.src);4195if (loaded) {4196const loadedRect = { ...paintedRect, intrinsicWidth: loaded.naturalWidth || loaded.width || paintedRect.intrinsicWidth, intrinsicHeight: loaded.naturalHeight || loaded.height || paintedRect.intrinsicHeight };4197const loadedPoint = pointToImageSource(point, loadedRect);4198if (loadedPoint) {4199const loadedSample = sampleDrawablePixel(loaded, loadedPoint);4200if (loadedSample.status === 'sampled') return { ...loadedSample, method: 'canvas-img-underlay' };4201}4202}4203}4204return sample;4205}42064207function textSamplePoints(rect) {4208const insetX = Math.min(12, Math.max(1, rect.width * 0.12));4209const insetY = Math.min(8, Math.max(1, rect.height * 0.22));4210const xs = rect.width < 284211? [rect.left + rect.width / 2]4212: [rect.left + insetX, rect.