/** * Anti-Pattern Browser Detector for Impeccable * Copyright (c) 2026 Paul Bakaus * SPDX-License-Identifier: Apache-2.0 * * GENERATED -- do not edit. Source: cli/engine/browser/injected/index.mjs * Rebuild: node scripts/build-browser-detector.js * * Usage: * Re-scan: window.impeccableScan() */ (function () { if (typeof window === 'undefined') return; // --- cli/engine/shared/constants.mjs --- // ─── Section 1: Constants ─────────────────────────────────────────────────── const SAFE_TAGS = new Set([ 'blockquote', 'nav', 'a', 'input', 'textarea', 'select', 'pre', 'code', 'span', 'th', 'td', 'tr', 'li', 'label', 'button', 'hr', 'html', 'head', 'body', 'script', 'style', 'link', 'meta', 'title', 'br', 'img', 'svg', 'path', 'circle', 'rect', 'line', 'polyline', 'polygon', 'g', 'defs', 'use', ]); // Per-check safe-tags override for the border (side-tab / border-accent) // rule. We intentionally re-allow here because card-shaped clickable // labels (e.g. .checklist-item wrapping a checkbox + content) are one of the // canonical side-tab anti-pattern shapes and must be detected. The rule's // other preconditions (non-neutral color, width >= 2px on a single side, // radius > 0 or width >= 3, element size >= 20x20 in the browser path) // already filter out plain inline form labels so this does not introduce // false positives. See modern-color-borders.html for the test matrix. const BORDER_SAFE_TAGS = new Set( [...SAFE_TAGS].filter(t => t !== 'label') ); const OVERUSED_FONTS = new Set([ // Older monoculture (still ubiquitous): 'inter', 'roboto', 'open sans', 'lato', 'montserrat', 'arial', 'helvetica', // Newer monoculture (the Anthropic-skill / Vercel / GitHub default wave): 'fraunces', 'instrument sans', 'instrument serif', 'geist', 'geist sans', 'geist mono', 'mona sans', 'plus jakarta sans', 'space grotesk', 'recoleta', ]); // Brand-associated fonts: don't flag these as "overused" on the brand's own domains. // Keys are font names, values are arrays of hostname suffixes where the font is allowed. const GOOGLE_DOMAINS = [ 'google.com', 'youtube.com', 'android.com', 'chromium.org', 'chrome.com', 'web.dev', 'gstatic.com', 'firebase.google.com', ]; const VERCEL_DOMAINS = ['vercel.com', 'nextjs.org', 'v0.app']; const GITHUB_DOMAINS = ['github.com', 'githubnext.com']; const BRAND_FONT_DOMAINS = { 'roboto': GOOGLE_DOMAINS, 'google sans': GOOGLE_DOMAINS, 'product sans': GOOGLE_DOMAINS, 'geist': VERCEL_DOMAINS, 'geist sans': VERCEL_DOMAINS, 'geist mono': VERCEL_DOMAINS, 'mona sans': GITHUB_DOMAINS, }; function isBrandFontOnOwnDomain(font) { if (typeof location === 'undefined') return false; const allowed = BRAND_FONT_DOMAINS[font]; if (!allowed) return false; const host = location.hostname.toLowerCase(); return allowed.some(suffix => host === suffix || host.endsWith('.' + suffix)); } const GENERIC_FONTS = new Set([ 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded', '-apple-system', 'blinkmacsystemfont', 'segoe ui', 'inherit', 'initial', 'unset', 'revert', ]); // WCAG large text thresholds are defined in points: 18pt normal text and // 14pt bold text. Browsers expose font-size in CSS pixels at 96px per inch. const WCAG_LARGE_TEXT_PX = 18 * (96 / 72); const WCAG_LARGE_BOLD_TEXT_PX = 14 * (96 / 72); // Em-dash overuse (advisory) thresholds, shared by the regex/static-HTML // analyzer and the browser DOM check so both fire on the same saturation // pattern. Two gates must hold: an absolute floor of EM_DASH_FLOOR dashes, and // a density of at least one dash per EM_DASH_CHARS_PER_DASH characters of body // text. A long article that uses a few em-dashes is left alone; a short, // dash-per-clause page is not. const EM_DASH_FLOOR = 8; const EM_DASH_CHARS_PER_DASH = 500; // Serif faces that show up in italic-display heroes. The rule also fires when // the primary face is unknown but the stack ends in the generic `serif` token, // which catches custom/private faces with a serif fallback. const KNOWN_SERIF_FONTS = new Set([ 'fraunces', 'recoleta', 'newsreader', 'playfair display', 'playfair', 'cormorant', 'cormorant garamond', 'garamond', 'eb garamond', 'tiempos', 'tiempos headline', 'tiempos text', 'lora', 'vollkorn', 'spectral', 'source serif pro', 'source serif 4', 'source serif', 'ibm plex serif', 'merriweather', 'libre caslon', 'libre baskerville', 'baskerville', 'georgia', 'times new roman', 'times', 'dm serif display', 'dm serif text', 'instrument serif', 'gt sectra', 'ogg', 'canela', 'freight display', 'freight text', ]); // --- cli/engine/registry/antipatterns.mjs --- const ANTIPATTERNS = [ // ── AI slop: tells that something was AI-generated ── { id: 'side-tab', category: 'slop', name: 'Side-tab accent border', description: '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.', skillSection: 'Visual Details', skillGuideline: 'colored accent stripe', }, { id: 'border-accent-on-rounded', category: 'slop', name: 'Border accent on rounded element', description: 'Thick accent border on a rounded card — the border clashes with the rounded corners. Remove the border or the border-radius.', skillSection: 'Visual Details', skillGuideline: 'colored accent stripe', }, { id: 'overused-font', category: 'slop', scopes: ['type'], name: 'Overused font', description: '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.', skillSection: 'Typography', skillGuideline: 'overused fonts like Inter', }, { id: 'flat-type-hierarchy', category: 'slop', scopes: ['type'], name: 'Flat type hierarchy', description: '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).', skillSection: 'Typography', skillGuideline: 'flat type hierarchy', }, { id: 'gradient-text', category: 'slop', name: 'Gradient text', description: 'Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.', skillSection: 'Color & Contrast', skillGuideline: 'gradient text for', }, { id: 'ai-color-palette', category: 'slop', name: 'AI color palette', description: 'Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.', skillSection: 'Color & Contrast', skillGuideline: 'AI color palette', }, { id: 'cream-palette', category: 'slop', name: 'Cream / beige palette', description: '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.', skillSection: 'Color & Contrast', skillGuideline: 'cream and beige as the default surface', }, { id: 'nested-cards', category: 'slop', scopes: ['layout'], name: 'Nested cards', description: 'Cards inside cards create visual noise and excessive depth. Flatten the hierarchy — use spacing, typography, and dividers instead of nesting containers.', skillSection: 'Layout & Space', skillGuideline: 'Nest cards inside cards', }, { id: 'monotonous-spacing', category: 'slop', scopes: ['layout'], name: 'Monotonous spacing', description: 'The same spacing value used everywhere — no rhythm, no variation. Use tight groupings for related items and generous separations between sections.', skillSection: 'Layout & Space', skillGuideline: 'same spacing everywhere', }, { id: 'bounce-easing', category: 'slop', name: 'Bounce or elastic easing', description: 'Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.', skillSection: 'Motion', skillGuideline: 'bounce or elastic easing', }, { id: 'pulsing-dot', category: 'slop', name: 'Pulsing status dot', description: 'Small pulsing status dots simulate liveness decoratively. Reserve pulse animation for indicators tied to genuinely live, changing data; a static indicator with clear labeling is honest and calmer.', skillSection: 'Motion', skillGuideline: 'decorative pulsing status dot', }, { id: 'blinking-cursor', category: 'slop', severity: 'advisory', name: 'Decorative blinking cursor', description: 'A blinking text cursor animated into a hero or landing section simulates typing where no input exists. It borrows the dev-tool aesthetic as decoration. Real editable fields draw their own caret; anywhere else, let the composition hold attention without a fake prompt.', skillSection: 'Motion', }, { id: 'shape-assembled-illustration', category: 'slop', severity: 'advisory', name: 'Shape-assembled illustration', description: 'A large inline SVG that builds a pictorial scene from a pile of primitive shapes reads as placeholder clip art, not illustration. Icons, logos, and data graphics are fine at their scale; a hero-sized visual deserves real artwork, a photograph, or a deliberately drawn graphic.', skillSection: 'Imagery', }, { id: 'dark-glow', category: 'slop', name: 'Glowing shadow accents', description: 'Colored glow shadows — a zero-offset chromatic halo (box- or text-shadow) on any background, or any colored blurred shadow on a dark background — are the default "cool" look of AI-generated UIs. Use neutral elevation shadows and subtle, purposeful lighting instead.', skillSection: 'Color & Contrast', skillGuideline: 'dark mode with glowing accents', }, { id: 'radial-halo', category: 'slop', name: 'Radial-gradient background halo', description: 'A chromatic radial-gradient wash — saturated at the center, fading to transparent — used as a decorative background glow on a dark page. Same tell as glowing shadows, drawn with a gradient instead of a shadow. Ground the surface with a solid or subtly shifted background instead.', skillSection: 'Color & Contrast', skillGuideline: 'dark mode with glowing accents', }, { id: 'radial-spotlight-glow', category: 'slop', name: 'Decorative radial spotlight glow', description: 'A soft, low-opacity accent-colored radial gradient fading to transparent, dropped behind a hero or section as a "spotlight." It is a reflex AI decoration — the translucent cousin of the saturated radial halo. Let the surface stand on its own, or light the composition with a deliberate material accent rather than a floating colored haze.', skillSection: 'Color & Contrast', skillGuideline: 'dark mode with glowing accents', }, { id: 'marquee', category: 'slop', name: 'Auto-scrolling marquee', description: 'Continuously auto-scrolling content demands attention it has not earned and hides half its content at any moment. Reserve motion for content that changes; let readers move at their own pace.', skillSection: 'Motion', skillGuideline: 'auto-scrolling marquee', }, { id: 'icon-tile-stack', category: 'slop', scopes: ['layout'], name: 'Icon tile stacked above heading', description: '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.', skillSection: 'Typography', skillGuideline: 'large icons with rounded corners above every heading', }, { id: 'italic-serif-display', category: 'slop', scopes: ['type'], name: 'Italic serif display headline', description: '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.', skillSection: 'Typography', skillGuideline: 'oversized italic serif as the hero headline', }, { id: 'hero-eyebrow-chip', category: 'slop', scopes: ['type'], name: 'Hero eyebrow / pill chip', description: '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.', skillSection: 'Typography', skillGuideline: 'tiny uppercase tracked label above the hero headline', }, { id: 'kicker-above-heading', category: 'slop', scopes: ['type'], name: 'Kicker / eyebrow label above heading', description: 'A tiny tracked uppercase or small-caps label sitting as its own block directly above a heading is banned outright, repeated or not. Generated kickers never earn their place: the heading carries its own weight. Delete the label and let the heading speak; if the words matter, work them into the heading or the body.', skillSection: 'Typography', skillGuideline: 'kicker or eyebrow labels above headings', }, { id: 'numbered-section-labels', category: 'slop', scopes: ['type'], severity: 'advisory', name: 'Tiny numbered section labels', description: 'Small numeric index labels riding next to section headings, repeated section after section, are AI editorial scaffolding — a page numbering its own chapters instead of earning structure. Let hierarchy, content, and rhythm carry the sequence.', skillSection: 'Layout & Space', skillGuideline: 'numbered section markers', }, { id: 'em-dash-overuse', category: 'slop', // Advisory: humans use em-dashes legitimately, so this rule is opt-in noise // rather than a failure. It fires only on the AI saturation pattern, not on // ordinary prose. Advisory findings are surfaced separately, never counted // as failures, and skipped by the design hook unless a project opts in. advisory: true, name: 'Em-dash overuse', description: 'Em-dash saturation in body copy is an AI cadence tell. Advisory only: humans use em-dashes legitimately, so this fires only on saturation — at least 8 em-dashes (— or --) at a density near one per 500 characters of body text — never on a long article that uses a few. Prefer commas, colons, periods, or parentheses.', skillSection: 'Copy', skillGuideline: 'no em dashes', }, { id: 'marketing-buzzword', category: 'slop', name: 'Marketing buzzword', description: '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.', skillSection: 'Copy', skillGuideline: 'marketing buzzwords', }, { id: 'aphoristic-cadence', category: 'slop', name: 'Aphoristic-cadence copy', description: '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.', skillSection: 'Copy', skillGuideline: 'aphoristic cadence', }, { id: 'oversized-h1', category: 'slop', scopes: ['type'], name: 'Oversized hero headline', description: '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.', skillSection: 'Typography', skillGuideline: 'long headline set at display size', }, { id: 'extreme-negative-tracking', category: 'slop', scopes: ['type'], name: 'Crushed letter spacing', description: 'Letter-spacing pulled tighter than the point where characters keep their own shapes costs legibility. Tighten display type optically, not destructively.', skillSection: 'Typography', skillGuideline: 'letter spacing crushed past legibility', }, { id: 'broken-image', category: 'quality', name: 'Broken or placeholder image', description: ' tags with empty src, missing src, or placeholder values ship as broken-image boxes. Use real images, generated assets, or remove the tag.', skillSection: 'Imagery', skillGuideline: 'broken image references', }, // ── Quality: general design and accessibility issues ── { id: 'script-error', category: 'quality', severity: 'error', name: 'Uncaught script error on load', description: 'A script threw an uncaught exception or failed to parse while the page loaded. Broken JavaScript silently kills reveals, interactions, and dynamic content, and can leave most of a page invisible. Fix the error before judging anything else.', }, { id: 'content-hidden-at-rest', category: 'quality', severity: 'error', scopes: ['layout'], name: 'Content invisible at rest', description: 'A large share of the page text sits at opacity 0 or visibility hidden even after every reveal handler had a chance to run. This is the failed-reveal signature: the content shipped but never becomes visible. Make content visible by default and let JavaScript enhance its entrance instead of gating its existence.', }, { id: 'edge-flush-cards', category: 'quality', scopes: ['layout'], name: 'Cards flush against the scroller edge', description: 'Cards inside a horizontal scroller or tab panel sit flush against the container edge at rest while keeping a gutter on the other side, so their edges and rounded corners get cut off. Usually the panel is sized wider than its clip box. Keep a consistent inset on both sides.', }, { id: 'text-occlusion', category: 'quality', scopes: ['layout'], name: 'Text occluded by an overlapping element', description: 'Text is painted under an opaque element or a second text run, so part of it cannot be read. A decorative box, a stacked layer, or an inline element with leaked padding lands on the words instead of beside them. Give overlapping layers room, or move the text out from under the layer above it.', skillSection: 'Layout & Space', }, { id: 'first-viewport-column-overflow', category: 'quality', scopes: ['layout'], name: 'One column stretches the first viewport', description: 'A multi-column opening section lets one column run far past the fold while its sibling fits in a single viewport, so the short column floats in dead space and the fold falls deep inside one section. Balance the columns, cap the tall one, or let the long content flow below the opening row.', skillSection: 'Layout & Space', }, { id: 'gray-on-color', category: 'quality', name: 'Gray text on colored background', description: 'Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.', skillSection: 'Color & Contrast', skillGuideline: 'gray text on colored backgrounds', }, { id: 'low-contrast', category: 'quality', name: 'Low contrast text', description: '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.', }, { id: 'layout-transition', category: 'quality', name: 'Layout property animation', description: 'Animating width, height, padding, or margin causes layout thrash and janky performance. Use transform and opacity instead, or grid-template-rows for height animations.', skillSection: 'Motion', skillGuideline: 'Animate layout properties', }, { id: 'line-length', category: 'quality', scopes: ['type', 'layout'], name: 'Line length too long', description: '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.', skillSection: 'Layout & Space', skillGuideline: 'wrap beyond ~80 characters', }, { id: 'cramped-padding', category: 'quality', scopes: ['layout'], name: 'Cramped padding', description: '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.', skillSection: 'Layout & Space', skillGuideline: 'inside bordered or colored containers', }, { id: 'body-text-viewport-edge', category: 'quality', scopes: ['layout'], name: 'Body text touching viewport edge', description: '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.', }, { id: 'tight-leading', category: 'quality', scopes: ['type'], name: 'Tight line height', description: '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.', }, { id: 'skipped-heading', category: 'quality', scopes: ['type'], name: 'Skipped heading level', description: '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.', }, { id: 'heading-rhythm', category: 'quality', scopes: ['layout', 'type'], name: 'Heading crowded against the previous block', description: 'A heading binds to the content it introduces, so the rendered space above it should exceed the space below it. When headings across a page sit as close or closer to the block above than to their own content, every section reads as if it captions the previous one. Open up the space above each heading.', skillSection: 'Layout & Space', }, { id: 'justified-text', category: 'quality', scopes: ['type'], name: 'Justified text', description: '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.', }, { id: 'tiny-text', category: 'quality', scopes: ['type'], name: 'Tiny body text', description: 'Body text below 12px is hard to read, especially on high-DPI screens. Use at least 14px for body content, 16px is ideal.', }, { id: 'undersized-ui-text', category: 'quality', scopes: ['type'], name: 'Undersized functional text', description: 'Interactive and content-bearing UI text (links, buttons, nav items, labels, table cells, meta rows, timecodes) below 11px is a legibility failure, not a style choice. WCAG sets no absolute pixel floor, but functional text under 11px is a defensible quality bar: it fails on high-DPI and small viewports and it degrades tap and read targets. The 11px floor holds even inside a footer; only non-interactive legal smallprint gets the softer 10px floor. Being ON the DESIGN.md size ramp does not exempt a value here: adding 8px to the ramp launders the token but not the legibility problem, and that is exactly the escape hatch this rule closes. Exempts sup/sub, visually-hidden (sr-only) text, and code/terminal contexts. Decorative letterspaced micro-labels are still functional and stay in scope.', }, { id: 'all-caps-body', category: 'quality', scopes: ['type'], name: 'All-caps body text', description: '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.', skillSection: 'Typography', skillGuideline: 'long body passages in uppercase', }, { id: 'wide-tracking', category: 'quality', scopes: ['type'], name: 'Wide letter spacing on body text', description: 'Letter spacing above 0.05em on body text disrupts natural character groupings and slows reading. Reserve wide tracking for short uppercase labels only.', }, { id: 'text-overflow', category: 'quality', scopes: ['layout'], name: 'Content overflowing its container', description: '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.', skillSection: 'Layout & Space', skillGuideline: 'content wider than its container', }, { id: 'repeated-container-text', category: 'quality', name: 'Same text repeated inside one container', description: 'The same literal text rendered three or more times in structurally different spots inside a single card or panel is redundant messaging — usually a status or label wired into every slot of a template. Say it once, in the slot where it matters most.', }, { id: 'clipped-overflow-container', category: 'quality', scopes: ['layout'], name: 'Positioned child clipped by overflow container', description: '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.', skillSection: 'Layout & Space', skillGuideline: 'overflow container clipping positioned children', }, { id: 'design-system-font', category: 'quality', scopes: ['type'], name: 'Font outside DESIGN.md', description: '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.', skillSection: 'Typography', skillGuideline: 'font family outside the project design system', }, { id: 'design-system-color', category: 'quality', severity: 'advisory', name: 'Color outside DESIGN.md', description: '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.', skillSection: 'Color & Contrast', skillGuideline: 'literal color outside the project design system', }, { id: 'design-system-radius', category: 'quality', severity: 'advisory', name: 'Radius outside DESIGN.md', description: '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.', skillSection: 'Visual Details', skillGuideline: 'border radius outside the project design system', }, { id: 'design-system-font-size', category: 'quality', severity: 'advisory', scopes: ['type'], name: 'Font size outside DESIGN.md', description: 'A literal font-size is off the type ramp documented in DESIGN.md typography. Use a documented size step or update the design system if the new step is intentional.', skillSection: 'Typography', skillGuideline: 'font size outside the project design system', }, // ── Common generated-UI tells ─────────────────────────────────────────── { id: 'gpt-thin-border-wide-shadow', category: 'slop', severity: 'advisory', name: 'Hairline border with wide shadow', description: '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.', skillSection: 'Visual Details', skillGuideline: 'hairline border plus wide diffuse shadow', }, { id: 'repeating-stripes-gradient', category: 'slop', severity: 'advisory', name: 'Repeating-gradient stripes', description: 'Repeating-gradient stripes used as surface decoration are a recurring generated-UI signature. Reach for a deliberate texture or leave the surface plain.', skillSection: 'Visual Details', skillGuideline: 'repeating-gradient decorative stripes', }, { id: 'codex-grid-background', category: 'slop', severity: 'advisory', name: 'Decorative grid-line background', description: 'A decorative grid or line-field background drawn with hairline linear-gradient layers tiled by a fixed pixel cell is a recurring generated-UI signature. Reserve grid overlays for actual canvas, map, blueprint, or measurement surfaces; elsewhere use product structure or a plain surface.', skillSection: 'Visual Details', skillGuideline: 'two-axis grid-line gradient background', }, { id: 'theater-slop-phrase', category: 'slop', severity: 'advisory', name: 'Theater framing copy', description: 'Dismissing something as "theater" is a recurring generated-copy tic. Say plainly what the thing does or does not do.', skillSection: 'Copy', skillGuideline: 'theater framing copy', }, { id: 'image-hover-transform', category: 'slop', severity: 'advisory', name: 'Image hover transform', description: 'Scaling or rotating an image on hover is a recurring generated-UI signature. Let imagery sit still, or use a subtler, purposeful interaction.', skillSection: 'Motion', skillGuideline: 'image scale or rotate on hover', }, ]; // --- cli/engine/shared/color.mjs --- // ─── Section 2: Color Utilities ───────────────────────────────────────────── function isNeutralColor(color) { if (!color || color === 'transparent') return true; // rgb/rgba — use channel spread. Threshold 30 ≈ 11.7% of the 0–255 range. const rgb = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); if (rgb) { return (Math.max(+rgb[1], +rgb[2], +rgb[3]) - Math.min(+rgb[1], +rgb[2], +rgb[3])) < 30; } // oklch()/lch() — chroma is the second numeric component. // oklch chroma is ~0–0.4 in sRGB gamut; >= 0.02 reads as tinted, not gray. // lch chroma is ~0–150; >= 3 reads as tinted. jsdom emits both formats // literally (it does NOT convert them to rgb). const oklch = color.match(/oklch\(\s*[\d.]+%?\s*([\d.-]+)/i); if (oklch) return parseFloat(oklch[1]) < 0.02; const lch = color.match(/lch\(\s*[\d.]+%?\s*([\d.-]+)/i); if (lch) return parseFloat(lch[1]) < 3; // oklab()/lab() — a and b are signed axes; chroma = sqrt(a² + b²). // oklab a/b are ~-0.4..0.4, threshold 0.02. lab a/b are ~-128..127, threshold 3. const oklab = color.match(/oklab\(\s*[\d.]+%?\s*([\d.-]+)\s+([\d.-]+)/i); if (oklab) { const a = parseFloat(oklab[1]), b = parseFloat(oklab[2]); return Math.hypot(a, b) < 0.02; } const lab = color.match(/lab\(\s*[\d.]+%?\s*([\d.-]+)\s+([\d.-]+)/i); if (lab) { const a = parseFloat(lab[1]), b = parseFloat(lab[2]); return Math.hypot(a, b) < 3; } // hsl/hsla — saturation is the second numeric component (percent). // Modern jsdom usually converts hsl() to rgb, but handle it directly for // safety across versions and for any engine that preserves the format. const hsl = color.match(/hsla?\(\s*[\d.-]+\s*,?\s*([\d.]+)%/i); if (hsl) return parseFloat(hsl[1]) < 10; // hwb(hue whiteness% blackness%) — a pixel is fully gray when // whiteness + blackness >= 100; chroma-like saturation = 1 - (w+b)/100. const hwb = color.match(/hwb\(\s*[\d.-]+\s+([\d.]+)%\s+([\d.]+)%/i); if (hwb) { const w = parseFloat(hwb[1]), b = parseFloat(hwb[2]); return (1 - Math.min(100, w + b) / 100) < 0.1; } // Unknown / unrecognized format — err on the side of DETECTING rather // than silently skipping. This is the opposite of the previous default, // which was the root cause of the oklch bug. return false; } function parseRgb(color) { if (!color || color === 'transparent') return null; const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/); if (!m) return null; return { r: +m[1], g: +m[2], b: +m[3], a: m[4] !== undefined ? +m[4] : 1 }; } function relativeLuminance({ r, g, b }) { const [rs, gs, bs] = [r / 255, g / 255, b / 255].map(c => c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4 ); return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs; } function contrastRatio(c1, c2) { const l1 = relativeLuminance(c1); const l2 = relativeLuminance(c2); return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); } function parseGradientColors(bgImage) { if (!bgImage || !bgImage.includes('gradient')) return []; const colors = []; for (const m of bgImage.matchAll(/rgba?\([^)]+\)/g)) { const c = parseRgb(m[0]); if (c) colors.push(c); } for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) { const h = m[1]; if (h.length === 6) { colors.push({ r: parseInt(h.slice(0,2),16), g: parseInt(h.slice(2,4),16), b: parseInt(h.slice(4,6),16), a: 1 }); } else { colors.push({ r: parseInt(h[0]+h[0],16), g: parseInt(h[1]+h[1],16), b: parseInt(h[2]+h[2],16), a: 1 }); } } return colors; } function hasChroma(c, threshold = 30) { if (!c) return false; return (Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b)) >= threshold; } function getHue(c) { if (!c) return 0; const r = c.r / 255, g = c.g / 255, b = c.b / 255; const max = Math.max(r, g, b), min = Math.min(r, g, b); if (max === min) return 0; const d = max - min; let h; if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6; else if (max === g) h = ((b - r) / d + 2) / 6; else h = ((r - g) / d + 4) / 6; return Math.round(h * 360); } function colorToHex(c) { if (!c) return '?'; return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join(''); } // --- cli/engine/shared/fonts.mjs --- const GOOGLE_FONTS_URL_RE = /fonts\.googleapis\.com\/css2?\?[^"'\s)<>]*/gi; function normalizeGoogleFontFamilyParam(value) { return String(value || '') .split('|') .map(part => part.split(':')[0].trim().toLowerCase()) .filter(Boolean); } function extractGoogleFontFamilies(text) { const families = []; if (!text) return families; GOOGLE_FONTS_URL_RE.lastIndex = 0; let urlMatch; while ((urlMatch = GOOGLE_FONTS_URL_RE.exec(text)) !== null) { const url = urlMatch[0]; const queryStart = url.indexOf('?'); if (queryStart === -1) continue; const params = new URLSearchParams(url.slice(queryStart + 1).replace(/&/g, '&')); for (const value of params.getAll('family')) { families.push(...normalizeGoogleFontFamilyParam(value)); } } return families; } // --- cli/engine/rules/checks.mjs --- const DETECTOR_IS_BROWSER = typeof window !== 'undefined'; // ─── Section 3: Pure Detection ────────────────────────────────────────────── function checkBorders(tag, widths, colors, radius, opts = {}) { // Badge-shaped s (own visible background) are a real stripe target // for the top/bottom variant — the inline-tag exemption exists to quiet // text-level borders, not chips. They skip the left/right arms below. const spanBadge = tag === 'span' && !!opts.badgeLike; if (BORDER_SAFE_TAGS.has(tag) && !spanBadge) return []; // A live status/alert region wears a colored single-edge border as a // severity accent (toast, snackbar, callout), not as the side-tab tell. if (opts.statusContext) return []; const findings = []; const sides = ['Top', 'Right', 'Bottom', 'Left']; for (const side of sides) { const w = widths[side]; if (w < 1 || isNeutralColor(colors[side])) continue; const otherSides = sides.filter(s => s !== side); const maxOther = Math.max(...otherSides.map(s => widths[s])); if (!(w >= 2 && (maxOther <= 1 || w >= maxOther * 2))) continue; const sn = side.toLowerCase(); const isSide = side === 'Left' || side === 'Right'; if (isSide) { if (spanBadge) continue; if (radius > 0) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` }); else if (w >= 3) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px` }); } else { if (radius > 0 && w >= 2) findings.push({ id: 'border-accent-on-rounded', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` }); // Horizontal variant of the side-tab stripe: a thick chromatic accent // riding the top or bottom edge of a card/badge/container. Same // dominant-edge + chroma gates as left/right, 3-12px band. Selected- // tab underlines are exempt via opts.tabContext (adapters look for // tablist/nav/tab ancestors and aria-selected); links, buttons, // table cells, and never reach here (BORDER_SAFE_TAGS). else if (!opts.tabContext && w >= 3 && w <= 12) { findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px` }); } } } return findings; } // Returns true if the given text is composed entirely of emoji characters // (plus whitespace / variation selectors). Emojis render as multicolor glyphs // regardless of CSS `color`, so contrast checks against the element's text // color are meaningless for these nodes. const 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; const 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; function isEmojiOnlyText(text) { if (!text) return false; if (!EMOJI_CHAR_RE.test(text)) return false; return text.replace(EMOJI_CHARS_GLOBAL, '').trim() === ''; } function checkColors(opts) { const { tag, textColor, bgColor, effectiveBg, effectiveBgStops, fontSize, fontWeight, hasDirectText, isEmojiOnly, bgClip, bgImage, classList } = opts; if (SAFE_TAGS.has(tag)) { // Exception for elements styled as controls or chips. SAFE_TAGS exists to // suppress contrast noise on inline links and unstyled spans, where the // element has no own background and the contrast against the ancestor // surface is already the intended visual. When the element paints its own // opaque background under direct text, it is a styled button, chip, or // badge regardless of tag, and contrast on its own surface is a real, // frequent bug worth flagging. (The shipped miss: a severity chip // whose white text lost a specificity fight and rendered muted-on-red at // 1.2:1; the old a/button-only exception never looked at it.) The 9px // font floor keeps sub-text decorations out. const isStyledControl = hasDirectText && ((bgColor && bgColor.a > 0.5) // A gradient painted on the element itself is an own surface the // same way a solid background is. Without this branch a nav CTA // built as `` with `background: linear-gradient(…)` and a text // color that fails against every stop sails through on the // SAFE_TAGS suppression (the shipped escape). || (bgImage && /gradient/i.test(bgImage))) && fontSize >= 9; if (!isStyledControl) return []; } const findings = []; if (hasDirectText && textColor && !isEmojiOnly) { // Gradient-clipped text (`background-clip: text`, typically with a // transparent text-fill) paints its glyphs *with* the element's own // gradient. The `color` value the cascade still reports is never painted, // and the gradient is the fill, not a backdrop — so measuring `color` // against that gradient (which resolveGradientStops picks up as the // element's own background-image) is a guaranteed false positive // (issue #409 Case A). Skip the backdrop-contrast checks; the gradient-text // rule below still flags the pattern itself. Skipping a rule beats a false // positive here — the true painted contrast can't be measured from `color`. const isGradientClippedText = bgClip === 'text'; // Run background-dependent checks against either a solid bg or, if the // ancestor is a gradient, against every gradient stop (use the worst case). const bgs = isGradientClippedText ? null : (effectiveBg ? [effectiveBg] : (effectiveBgStops && effectiveBgStops.length ? effectiveBgStops : null)); if (bgs) { // Gray on colored background — flag if every stop is chromatic const textLum = relativeLuminance(textColor); const isGray = !hasChroma(textColor, 20) && textLum > 0.05 && textLum < 0.85; if (isGray && bgs.every(b => hasChroma(b, 40))) { const bgLabel = effectiveBg ? colorToHex(effectiveBg) : `gradient(${bgs.map(colorToHex).join(', ')})`; findings.push({ id: 'gray-on-color', snippet: `text ${colorToHex(textColor)} on bg ${bgLabel}` }); } // Low contrast (WCAG AA) — worst case across all bg stops const ratios = bgs.map(b => contrastRatio(textColor, b)); let worstIdx = 0; for (let i = 1; i < ratios.length; i++) if (ratios[i] < ratios[worstIdx]) worstIdx = i; const ratio = ratios[worstIdx]; const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700); const threshold = isLargeText ? 3.0 : 4.5; if (ratio < threshold) { // Skip the false-positive class where text has alpha < 1 AND we // couldn't find an opaque ancestor (effectiveBg is null, we're // comparing against gradient-stop fallback). In jsdom mode the // detector can't resolve `var(--X)` color tokens, so a dark // section sitting between the text and the body's decorative // gradient is invisible to us — we end up measuring contrast // against the body's paper-grain noise instead of the real // local bg. Real low-contrast bugs use alpha=1 and have a // resolvable opaque ancestor; semi-transparent Tailwind tokens // like `text-paper/60` on `bg-ink` sections are the FP pattern. const isAlphaFallbackFP = !DETECTOR_IS_BROWSER && !effectiveBg && (textColor.a != null && textColor.a < 1); if (!isAlphaFallbackFP) { // Near-threshold ratios (e.g. 4.497) would round to the threshold // itself at one decimal and read as "4.5 needs 4.5" — show two // decimals there so the finding stays legible. const ratioLabel = ratio.toFixed(1) === threshold.toFixed(1) ? ratio.toFixed(2) : ratio.toFixed(1); findings.push({ id: 'low-contrast', snippet: `${ratioLabel}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(bgs[worstIdx])}` }); } } } // AI palette: purple/violet on headings if (hasChroma(textColor, 50)) { const hue = getHue(textColor); if (hue >= 260 && hue <= 310 && (['h1', 'h2', 'h3'].includes(tag) || fontSize >= 20)) { findings.push({ id: 'ai-color-palette', snippet: `Purple/violet text (${colorToHex(textColor)}) on heading` }); } } } // Gradient text if (bgClip === 'text' && bgImage && bgImage.includes('gradient')) { findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' }); } // Tailwind class checks if (classList) { const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' '); const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/); const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); if (grayMatch && colorBgMatch) { findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` }); } if (/\bbg-clip-text\b/.test(classStr) && /\bbg-gradient-to-/.test(classStr)) { findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' }); } const purpleText = classStr.match(/\btext-(?:purple|violet|indigo)-\d+\b/); if (purpleText && (['h1', 'h2', 'h3'].includes(tag) || /\btext-(?:[2-9]xl)\b/.test(classStr))) { findings.push({ id: 'ai-color-palette', snippet: `${purpleText[0]} on heading` }); } if (/\bfrom-(?:purple|violet|indigo)-\d+\b/.test(classStr) && /\bto-(?:purple|violet|indigo|blue|cyan|pink|fuchsia)-\d+\b/.test(classStr)) { findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet gradient (Tailwind)' }); } } return findings; } // WCAG contrast for the :hover state of an element whose hover rules change // its text color and/or background. The classic miss: a nav CTA whose // author-intended hover pair passes AA, but a broader selector (e.g. // `.nav-links a:hover`) wins the specificity fight and swaps in a color // that fails. Only fires on elements that present as styled controls — // direct text plus an opaque-ish own background in either state — so plain // inline links keep the same suppression they get in checkColors. function checkHoverContrast(opts) { const { tag, textColor, bg, ownBgAlpha, fontSize, fontWeight, hasDirectText, isEmojiOnly } = opts; if (!hasDirectText || isEmojiOnly || !textColor || !bg) return []; if (SAFE_TAGS.has(tag) && !(ownBgAlpha != null && ownBgAlpha > 0.5)) return []; const ratio = contrastRatio(textColor, bg); const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700); const threshold = isLargeText ? 3.0 : 4.5; if (ratio >= threshold) return []; return [{ id: 'low-contrast', snippet: `:hover state ${ratio.toFixed(1)}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(bg)}`, }]; } function isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg) { if (!hasShadow && !hasBorder) return false; return hasRadius || hasBg; } const HEADING_TAGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']); // Pure check: given a heading and metrics about its previousElementSibling, // decide if the sibling is the canonical "icon-tile-stacked-above-heading" shape. // // Triggers when ALL of the following hold for the sibling: // • size 32–128px on both axes (not too small, not a hero image) // • aspect ratio 0.7–1.4 (squarish — excludes wide thumbnails / pill badges) // • has a non-transparent background-color, background-image, OR a visible border // (covers solid colors, white-with-border, gradients — anything that visually // defines a tile) // • border-radius < width/2 (excludes round avatars; rounded squares pass) // • contains an or icon-class element that's smaller than the tile // • the tile sits above the heading (its bottom is above the heading's top) function checkIconTile(opts) { const { headingTag, headingText, headingTop, siblingTag, siblingWidth, siblingHeight, siblingBottom, siblingBgColor, siblingBgImage, siblingBorderWidth, siblingBorderRadius, hasIconChild, iconChildWidth } = opts; if (!HEADING_TAGS.has(headingTag)) return []; if (!siblingTag) return []; // Don't recurse into nested headings (e.g. h2 above h3 in a section header) if (HEADING_TAGS.has(siblingTag)) return []; // Size window: 32–128px on each axis if (!(siblingWidth >= 32 && siblingWidth <= 128)) return []; if (!(siblingHeight >= 32 && siblingHeight <= 128)) return []; // Squarish aspect ratio const ratio = siblingWidth / siblingHeight; if (ratio < 0.7 || ratio > 1.4) return []; // Must have something that visually defines the tile const bgVisible = (siblingBgColor && siblingBgColor.a > 0.1) || (siblingBgImage && siblingBgImage !== 'none' && siblingBgImage !== ''); const borderVisible = siblingBorderWidth > 0; if (!bgVisible && !borderVisible) return []; // Exclude circles (avatars). Rounded squares pass. if (siblingBorderRadius >= siblingWidth / 2) return []; // Must contain an icon element smaller than the tile if (!hasIconChild) return []; if (iconChildWidth && iconChildWidth >= siblingWidth * 0.95) return []; // Vertical stacking: tile must end above where the heading starts. // (Allow the check to skip when both top/bottom are 0 — jsdom layout case.) if (headingTop && siblingBottom && siblingBottom > headingTop + 4) return []; const text = (headingText || '').trim().slice(0, 60); return [{ id: 'icon-tile-stack', snippet: `${Math.round(siblingWidth)}x${Math.round(siblingHeight)}px icon tile above ${headingTag} "${text}"`, }]; } // Resolve the primary (non-generic) face from a font-family string and return // whether the resolved primary is serif. Two paths: // 1. Primary face is in KNOWN_SERIF_FONTS → serif. // 2. Primary face is unknown but the stack ends in the generic `serif` // token → treat as serif. Authors who declare `font-family: 'X', serif` // almost always have a serif primary; a sans declared with a serif // fallback is a code smell, not the common case. // Returns { primary, isSerif } so the snippet can name the face. function resolveSerif(fontFamily) { if (!fontFamily) return { primary: null, isSerif: false }; const tokens = fontFamily.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase()); const primary = tokens.find(f => f && !GENERIC_FONTS.has(f)) || null; if (!primary) return { primary: null, isSerif: false }; if (KNOWN_SERIF_FONTS.has(primary)) return { primary, isSerif: true }; if (tokens.includes('serif')) return { primary, isSerif: true }; return { primary, isSerif: false }; } function checkItalicSerif(opts) { const { tag, fontStyle, fontFamily, fontSize, headingText } = opts; if (fontStyle !== 'italic') return []; // Anchor the rule on hero-scale text. h1 is the canonical hero element; // h2 ≥ 48px catches the cases where the design demotes the visual hero // to an h2 but keeps the size. if (tag !== 'h1' && !(tag === 'h2' && fontSize >= 48)) return []; if (fontSize < 48) return []; const { primary, isSerif } = resolveSerif(fontFamily); if (!isSerif) return []; const text = (headingText || '').trim().slice(0, 60); return [{ id: 'italic-serif-display', snippet: `italic serif ${tag} (${primary || 'serif'}) at ${Math.round(fontSize)}px "${text}"`, }]; } // Color saturation check. Returns true when the color has visible // chroma — i.e., it's an "accent color" rather than near-neutral. // Handles rgb()/rgba(), #hex, oklch(), and hsl(). var() refs are // expected to be pre-resolved by the caller. function isAccentColor(cssColor) { if (!cssColor) return false; const s = String(cssColor).trim(); // rgb / rgba — direct channel-distance check. const rgbM = /rgba?\(\s*(\d+)\s*,?\s+|\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(s.replace(/rgba?\(\s*/, 'rgb(').replace(/,/g, ', ')); const rgbStrict = /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(s); if (rgbStrict) { const r = +rgbStrict[1], g = +rgbStrict[2], b = +rgbStrict[3]; return (Math.max(r, g, b) - Math.min(r, g, b)) >= 40; } // #hex — 3, 4, 6, or 8 digit. const hexM = /^#([0-9a-f]{3,8})\b/i.exec(s); if (hexM) { let h = hexM[1]; if (h.length === 3 || h.length === 4) h = h.split('').map((c) => c + c).join('').slice(0, 6); else h = h.slice(0, 6); if (h.length === 6) { const r = parseInt(h.slice(0, 2), 16); const g = parseInt(h.slice(2, 4), 16); const b = parseInt(h.slice(4, 6), 16); return (Math.max(r, g, b) - Math.min(r, g, b)) >= 40; } } // oklch(L C H) — chroma C is what matters. Typical neutral grays // have C < 0.02; visible accents are 0.05+. CSS minification can // collapse spaces between L% and C ("oklch(43%.15 34)"), so we // extract all numbers and take the second rather than matching a // strict L-then-whitespace-then-C pattern. if (/^oklch\(/i.test(s)) { const nums = s.match(/\d*\.\d+|\d+/g); if (nums && nums.length >= 2) { const c = parseFloat(nums[1]); return !Number.isNaN(c) && c >= 0.05; } } // hsl(H, S%, L%) — saturation > 20% reads as accent. const hslM = /hsla?\(\s*[\d.]+\s*,\s*([\d.]+)%/i.exec(s); if (hslM) { const sat = parseFloat(hslM[1]); return !Number.isNaN(sat) && sat >= 20; } return false; } function resolveHeroHeadingSizePx(value) { const input = String(value || '').trim().toLowerCase(); if (!input) return 0; const simpleLengthPx = (token) => { const match = /^(-?\d*\.?\d+)\s*(px|rem|em|%)?$/.exec(String(token || '').trim()); if (!match) return null; const amount = Number(match[1]); if (!Number.isFinite(amount)) return null; if (match[2] === 'rem' || match[2] === 'em') return amount * 16; if (match[2] === '%') return amount * 0.16; return amount; }; const direct = simpleLengthPx(input); if (direct !== null) return direct; // Static CSS engines cannot resolve viewport units, but clamp's min/max // bounds still tell us whether the heading can ever reach hero scale. const clamp = /^clamp\((.*)\)$/.exec(input); if (clamp) { const parts = clamp[1].split(','); if (parts.length === 3) { const bounds = [simpleLengthPx(parts[0]), simpleLengthPx(parts[2])] .filter((candidate) => candidate !== null); if (bounds.length > 0) return Math.max(...bounds); } } return 0; } // Sibling-relationship rule. Anchor on a hero-scale h1, look at the // previousElementSibling, and gate on EITHER the classic tracked- // uppercase eyebrow OR the modern accent-colored bold eyebrow. function checkHeroEyebrow(opts) { const { headingTag, headingText, headingFontSize, headingInApplicationContext, siblingTag, siblingText, siblingTextTransform, siblingFontSize, siblingLetterSpacing, siblingFontWeight, siblingColor, siblingHasAccentDashPseudo, } = opts; if (headingTag !== 'h1') return []; // This is specifically a marketing-hero cliché, not a ban on compact // context labels in product UI (for example, a station name inside a tab // panel). Browser-computed sizes are reliable; the static adapter also // resolves ordinary px/rem/em and clamp() bounds before reaching here. if (headingInApplicationContext) return []; if (!(headingFontSize >= 48)) return []; if (!siblingTag) return []; // An h2 above an h1 is a different anti-pattern (heading hierarchy / dual // headings) — never an eyebrow. if (HEADING_TAGS.has(siblingTag)) return []; const text = (siblingText || '').trim(); if (text.length < 2 || text.length > 60) return []; if (!(siblingFontSize > 0 && siblingFontSize <= 14)) return []; // Branch A: classic tracked-uppercase eyebrow. const isUppercased = siblingTextTransform === 'uppercase' || (/[A-Z]/.test(text) && !/[a-z]/.test(text)); const isClassicTracked = isUppercased && siblingLetterSpacing >= 1.6; // Branch B: modern accent-bold eyebrow — sentence case, low // tracking, but bold + accent-colored. The style choices changed; // the pattern is the same kicker-above-headline anti-pattern. const weight = Number(siblingFontWeight) || 400; const isAccentBold = weight >= 700 && isAccentColor(siblingColor || ''); // Branch C: dash-prefix eyebrow — sentence case, low tracking, regular // weight, but announced by a short chromatic ::before/::after bar // (the kicker dash). Same label-above-headline pattern, third styling. const isDashPrefixed = !!siblingHasAccentDashPseudo; if (!isClassicTracked && !isAccentBold && !isDashPrefixed) return []; const headingTextSnippet = (headingText || '').trim().slice(0, 60); const eyebrowSnippet = text.slice(0, 40); const style = isClassicTracked ? 'tracked-caps' : isAccentBold ? 'accent-bold' : 'dash-prefix'; return [{ id: 'hero-eyebrow-chip', snippet: `eyebrow chip (${style}) "${eyebrowSnippet}" above ${headingTag} "${headingTextSnippet}"`, }]; } // Outright ban: one kicker is one too many, so every collected candidate is // a finding. The judgment lives in the candidate gate (isKickerCandidate) and // the collector's context skips, not in a repetition count. function checkKickerAboveHeading(opts) { const { candidates } = opts; if (!Array.isArray(candidates)) return []; return candidates.map(candidate => ({ id: 'kicker-above-heading', snippet: `kicker "${candidate.kickerText}" above ${candidate.headingTag} "${candidate.headingText}"`, })); } const LAYOUT_TRANSITION_PROPS = new Set([ 'width', 'height', 'padding', 'margin', 'max-height', 'max-width', 'min-height', 'min-width', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left', ]); function checkMotion(opts) { const { tag, transitionProperty, animationName, timingFunctions, classList } = opts; if (SAFE_TAGS.has(tag)) return []; const findings = []; // --- Bounce/elastic easing --- if (animationName && animationName !== 'none' && /bounce|elastic|wobble|jiggle|spring/i.test(animationName)) { findings.push({ id: 'bounce-easing', snippet: `animation: ${animationName}` }); } if (classList && /\banimate-bounce\b/.test(classList)) { findings.push({ id: 'bounce-easing', snippet: 'animate-bounce (Tailwind)' }); } // Check timing functions for overshoot cubic-bezier (y values outside [0, 1]) if (timingFunctions) { const bezierRe = /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g; let m; while ((m = bezierRe.exec(timingFunctions)) !== null) { const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]); if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) { findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${m[1]}, ${m[2]}, ${m[3]}, ${m[4]})` }); break; } } } // --- Layout property transition --- if (transitionProperty && transitionProperty !== 'all' && transitionProperty !== 'none') { const props = transitionProperty.split(',').map(p => p.trim().toLowerCase()); const layoutFound = props.filter(p => LAYOUT_TRANSITION_PROPS.has(p)); if (layoutFound.length > 0) { findings.push({ id: 'layout-transition', snippet: `transition: ${layoutFound.join(', ')}` }); } } return findings; } // Locate the color token in a single shadow layer. Returns // { color, start, end } where color is the parsed {r,g,b,a} (null when the // token exists but can't be parsed — e.g. an unresolved var() or an exotic // color space), or null when no color token is present at all. Handles both // serialization orders: computed style puts the color first // ("rgb(…) 0px 0px 20px"), authored CSS usually puts it last // ("0 0 20px #3b82f6"). function findShadowColor(layer) { const fn = layer.match(/(?:rgba?|hsla?|hwb|oklch|oklab|lch|lab|color)\([^)]*\)/i); if (fn) return { color: parseAnyColor(fn[0]), start: fn.index, end: fn.index + fn[0].length }; const hex = layer.match(/#[0-9a-fA-F]{3,8}\b/); if (hex) return { color: parseAnyColor(hex[0]), start: hex.index, end: hex.index + hex[0].length }; const wordRe = /[a-zA-Z][a-zA-Z]*/g; let m; while ((m = wordRe.exec(layer)) !== null) { const named = CSS_NAMED_COLORS[m[0].toLowerCase()]; if (named) return { color: { ...named, a: 1 }, start: m.index, end: m.index + m[0].length }; } return null; } // Extract the length values of a shadow layer in declaration order, with the // color token removed so its components aren't misread as lengths. Handles // computed-style px values AND authored unitless zeros ("0 0 20px"); rem/em // approximate at 16px. Result order is offset-x, offset-y, blur, [spread]. function extractShadowLengths(layer, colorStart, colorEnd) { const stripped = colorStart != null ? layer.slice(0, colorStart) + ' ' + layer.slice(colorEnd) : layer; const vals = []; const re = /(-?\d*\.?\d+)(px|rem|em)?/g; let m; while ((m = re.exec(stripped)) !== null) { let v = parseFloat(m[1]); if (m[2] === 'rem' || m[2] === 'em') v *= 16; vals.push(v); } return vals; } function checkGlow(opts) { const { boxShadow, textShadow, effectiveBg } = opts; const onDarkBg = effectiveBg ? relativeLuminance(effectiveBg) < 0.1 : false; // Scan one shadow list. Two glow tells, in any color format: // 1. Zero-offset chromatic halo (0 0 Npx ) — slop on ANY // background; the light radiates evenly outward, which is never how // real elevation shadows behave. Achromatic zero-offset shadows stay // legal (soft ambient elevation), as do focus rings (blur 0). // 2. Any chromatic shadow with real blur on a dark background — the // classic dark-mode glow accent. const scan = (value, prop) => { if (!value || value === 'none') return null; // Split multiple shadows (commas not inside parentheses) for (const layer of value.split(/,(?![^(]*\))/)) { const colorInfo = findShadowColor(layer); // No color token, or one we can't resolve (unresolved var(), exotic // color space): don't guess — skip rather than false-positive. if (!colorInfo || !colorInfo.color) continue; const color = colorInfo.color; if (!hasChroma(color, 30)) continue; const vals = extractShadowLengths(layer, colorInfo.start, colorInfo.end); // Third value is blur (offset-x, offset-y, blur, [spread]) if (vals.length < 3 || vals[2] <= 4) continue; if (vals[0] === 0 && vals[1] === 0) { return { id: 'dark-glow', snippet: `Zero-offset ${prop} glow (${colorToHex(color)})` }; } if (onDarkBg) { return { id: 'dark-glow', snippet: `Colored ${prop} glow (${colorToHex(color)}) on dark background` }; } } return null; }; const found = scan(boxShadow, 'box-shadow') || scan(textShadow, 'text-shadow'); return found ? [found] : []; } // Collect CSS custom property declarations from raw stylesheet/HTML text. // First declaration wins (:root declarations usually come first); good // enough for the single-level var() resolution the text engines need. function collectCssCustomProps(content) { const map = new Map(); const re = /(--[\w-]+)\s*:\s*([^;{}]+)/g; let m; while ((m = re.exec(content)) !== null) { if (!map.has(m[1])) map.set(m[1], m[2].trim()); } return map; } // Text-level glow scan shared by the regex engine and the page-level HTML // pattern pass. Resolves single-level var() refs against custom properties // collected from the same text, then applies the same two glow tells as // checkGlow: zero-offset chromatic halo (any background) and chromatic // blurred shadow when the page has a dark background. Returns // [{ index, snippet }] — index is the offset of the shadow declaration. // Dark-page heuristic for raw CSS/HTML text: dark hex/rgb literals, Tailwind // dark bg utilities, or a ROOT-scoped (body/html/:root or ) // background that resolves — via var() — to a dark color. The var/modern- // color extension is deliberately root-scoped: a light page with one dark // accent chip must not turn every tinted drop shadow into a "dark page" // signal. Shared by the glow and radial-halo text scanners. function cssTextHasDarkRootBg(content, customProps) { const darkBgRe = /background(?:-color)?\s*:\s*(?:#(?:0[0-9a-f]|1[0-9a-f]|2[0-3])[0-9a-f]{4}\b|#(?:0|1)[0-9a-f]{2}\b|rgb\(\s*(\d{1,2})\s*,\s*(\d{1,2})\s*,\s*(\d{1,2})\s*\))/i; const twDarkBg = /\bbg-(?:gray|slate|zinc|neutral|stone)-(?:9\d{2}|800)\b/; if (darkBgRe.test(content) || twDarkBg.test(content)) return true; const rootScopes = []; const blockRe = /(?:^|[}\s,;>])(?:body|html|:root)\s*(?:,[^{]*)?\{([^}]*)\}/gi; let sm; while ((sm = blockRe.exec(content)) !== null) rootScopes.push(sm[1]); const inlineBody = content.match(/]*\bstyle\s*=\s*"([^"]*)"/i); if (inlineBody) rootScopes.push(inlineBody[1]); for (const scope of rootScopes) { const bgRe = /background(?:-color)?\s*:\s*([^;{}]+)/gi; let bm; while ((bm = bgRe.exec(scope)) !== null) { const c = parseAnyColor(resolveVarRefs(bm[1].trim(), customProps)); if (c && (c.a ?? 1) > 0.5 && relativeLuminance(c) < 0.1) return true; } } return false; } function scanCssTextForGlow(content) { const customProps = collectCssCustomProps(content); const hasDarkBg = cssTextHasDarkRootBg(content, customProps); const results = []; const shadowRe = /\b(box-shadow|text-shadow)\s*:\s*([^;{}]+)/gi; let m; while ((m = shadowRe.exec(content)) !== null) { const prop = m[1].toLowerCase(); const value = resolveVarRefs(m[2].trim(), customProps); for (const layer of value.split(/,(?![^(]*\))/)) { const colorInfo = findShadowColor(layer); if (!colorInfo || !colorInfo.color || !hasChroma(colorInfo.color, 30)) continue; const vals = extractShadowLengths(layer, colorInfo.start, colorInfo.end); if (vals.length < 3 || vals[2] <= 4) continue; const zeroOffset = vals[0] === 0 && vals[1] === 0; if (!zeroOffset && !hasDarkBg) continue; results.push({ index: m.index, snippet: zeroOffset ? `Zero-offset ${prop} glow (${colorToHex(colorInfo.color)})` : `Colored ${prop} glow (${colorToHex(colorInfo.color)}) on dark page`, }); break; // one finding per declaration } } return results; } // Decorative grid or line-field backgrounds drawn with hairline // linear-gradient layers tiled by a fixed pixel cell. Shared by the HTML // pattern pass and the regex source engine so standalone CSS, component // styles, and inline styles receive the same coverage. Both signals must // co-occur in one declaration block; unrelated rules must not add up across // the file. Returns [{ index, snippet }], capped at one finding per source to // match the page-level HTML check's existing behavior. function scanCssTextForGridBackground(content) { const hairlineRe = /\b\d{1,3}px\s*,\s*transparent\s+\d{1,3}px/gi; const invertedHairlineRe = /transparent\s+calc\(100%\s*-\s*\d{1,3}px\)/gi; const sizeDeclPxRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\b/i; const sizeDeclPxPairRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\s+\d{1,3}px/i; const shorthandPxAnyRe = /\/\s*\d{1,3}px\b/; const shorthandPxPairRe = /\/\s*\d{1,3}px\s+\d{1,3}px/; const bgDeclRe = /\bbackground(?:-image)?\s*:\s*([^;{}"']*)/gi; const blockRe = /\{([^{}]*)\}|style\s*=\s*"([^"]*)"|style\s*=\s*'([^']*)'/gi; let blk; while ((blk = blockRe.exec(content)) !== null) { const block = blk[1] || blk[2] || blk[3] || ''; let hairlineCount = 0; let bgJoined = ''; let bm; bgDeclRe.lastIndex = 0; while ((bm = bgDeclRe.exec(block)) !== null) { hairlineCount += (bm[1].match(hairlineRe) || []).length; hairlineCount += (bm[1].match(invertedHairlineRe) || []).length; bgJoined += `${bm[1]};`; } if (hairlineCount === 0) continue; const hasPxCell = sizeDeclPxRe.test(block) || shorthandPxAnyRe.test(bgJoined); const hasPxPairCell = sizeDeclPxPairRe.test(block) || shorthandPxPairRe.test(bgJoined); if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) { return [{ index: blk.index, snippet: hairlineCount >= 2 ? 'two-axis grid-line gradient background' : 'px-tiled hairline line-field background', }]; } } return []; } // Decorative chromatic halo drawn as a radial-gradient background on a dark // page: a saturated center stop dissolving to transparent. The gradient // sibling of the dark-glow shadow tell. Mechanical gates, in order: // * page has a dark root background (shared heuristic with the glow scan) // * declaration has no url() layer (photographic imagery is exempt) // * the gradient's first color stop is chromatic (RGB spread >= 24) and // visible (alpha >= 0.7 — deliberately translucent light-scene washes // composite with content instead of painting a flat halo, and stay legal) // * the gradient's last stop is transparent / near-zero alpha // * no small pixel-sized stop positions (<= 24px = dot/texture patterns) // * not a repeating-* gradient // Achromatic vignettes fail the chroma gate; panel sheens that fade to an // opaque surface color fail the transparent-end gate. function scanCssTextForRadialHalo(content) { const customProps = collectCssCustomProps(content); if (!cssTextHasDarkRootBg(content, customProps)) return []; const findings = []; const seen = new Set(); const declRe = /background(?:-image)?\s*:\s*([^;{}]+)/gi; let m; while ((m = declRe.exec(content)) !== null) { const value = resolveVarRefs(m[1].trim(), customProps); if (/url\s*\(/i.test(value)) continue; const gradRe = /(repeating-)?radial-gradient\(/gi; let g; while ((g = gradRe.exec(value)) !== null) { if (g[1]) continue; // repeating-* = pattern, not halo // Balanced-paren capture of the gradient arguments. let depth = 0, end = -1; const open = value.indexOf('(', g.index); for (let i = open; i < value.length; i++) { if (value[i] === '(') depth++; else if (value[i] === ')') { depth--; if (depth === 0) { end = i; break; } } } if (end < 0) break; const args = splitTopLevelCommas(value.slice(open + 1, end)); if (args.length < 2) continue; // Optional prelude (shape / size / `at `) carries no color. const colorTokenRe = /(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color-mix)\([^)]*(?:\([^)]*\))?[^)]*\)|#[0-9a-f]{3,8}\b|\btransparent\b/i; const stops = args.filter(a => colorTokenRe.test(a)); if (stops.length < 2) continue; // Dot/texture exemption: px-sized stop positions mean a repeating // background-size pattern, not a page-scale halo. const pxStop = stops.some(s => { const pm = s.match(/(-?[\d.]+)px\b/); return pm && Math.abs(parseFloat(pm[1])) <= 24; }); if (pxStop) continue; const first = stops[0].match(colorTokenRe); const last = stops[stops.length - 1].match(colorTokenRe); if (!first || !last) continue; const lastColor = /^transparent$/i.test(last[0]) ? { r: 0, g: 0, b: 0, a: 0 } : parseAnyColor(last[0]); if (!lastColor || (lastColor.a ?? 1) > 0.05) continue; const firstColor = /^transparent$/i.test(first[0]) ? null : parseAnyColor(first[0]); if (!firstColor) continue; if ((firstColor.a ?? 1) < 0.7) continue; const spread = Math.max(firstColor.r, firstColor.g, firstColor.b) - Math.min(firstColor.r, firstColor.g, firstColor.b); if (spread < 24) continue; const snippet = `radial-gradient halo (${colorToHex(firstColor)} → transparent) on dark page`; if (seen.has(snippet)) continue; seen.add(snippet); findings.push({ index: m.index, snippet }); } } return findings; } // --------------------------------------------------------------------------- // Text-level CSS rule-block scanners (pseudo-element stripes, pulsing dots) // --------------------------------------------------------------------------- // Iterate `selector { declarations }` pairs in raw CSS/HTML text. The block // body excludes braces, so nested structures (@media, @keyframes) naturally // yield their innermost rules with the innermost selector text. Callers // create the regex locally — a shared /g instance is not re-entrant. const CSS_RULE_BLOCK_SOURCE = String.raw`([^{};]+)\{([^{}]*)\}`; // Parse a declaration block into a prop → value map (last declaration wins, // approximating the cascade inside one block). Values keep their raw text // with any !important suffix stripped. function parseCssDeclBlock(block) { const decls = new Map(); for (const part of String(block || '').split(';')) { const idx = part.indexOf(':'); if (idx <= 0) continue; const prop = part.slice(0, idx).trim().toLowerCase(); const value = part.slice(idx + 1).replace(/\s*!important\s*$/i, '').trim(); if (prop && value) decls.set(prop, value); } return decls; } function cssLengthToPx(value) { const m = String(value || '').trim().match(/^(-?[\d.]+)(px|rem|em)$/i); if (!m) return null; const n = parseFloat(m[1]); return m[2].toLowerCase() === 'px' ? n : n * 16; } function isZeroOffset(value) { return value != null && /^-?0(?:px|%|rem|em)?$/.test(String(value).trim()); } // Side-tab variant: the accent stripe drawn as an absolutely-positioned // ::before/::after pseudo-element (narrow colored box hugging a vertical // edge) instead of a border-left/right. The element-level border checks // never see it — pseudo-elements aren't part of the DOM the cascade walks — // so this scans stylesheet text directly, mirroring the border rule's // gates: >= 3px thick, chromatic fill, full height against a side edge. function scanCssTextForPseudoStripe(rawContent) { // Blank comment bodies byte-for-byte so commented-out rules are not // scanned as live CSS and every rule keeps its source offset (each // finding carries `index` so line-based callers can attribute it and // line-scoped inline ignores can match). const content = String(rawContent || '').replace(/\/\*[\s\S]*?\*\//g, (block) => block.replace(/[^\n]/g, ' ')); const customProps = collectCssCustomProps(content); const findings = []; const seen = new Set(); const ruleRe = new RegExp(CSS_RULE_BLOCK_SOURCE, 'g'); let m; while ((m = ruleRe.exec(content)) !== null) { const selector = m[1].trim(); if (!/::?(?:before|after)\b/i.test(selector)) continue; // Keep the border rule's prose exemptions (blockquote bars etc.). if (/\b(?:blockquote|pre|code|nav|hr)\b/i.test(selector)) continue; const decls = parseCssDeclBlock(m[2]); const position = decls.get('position'); if (position !== 'absolute' && position !== 'fixed') continue; const widthPx = cssLengthToPx(resolveVarRefs( decls.get('width') || decls.get('inline-size') || '', customProps)); const heightPx = cssLengthToPx(resolveVarRefs( decls.get('height') || decls.get('block-size') || '', customProps)); const verticalCandidate = widthPx != null && widthPx >= 3 && widthPx <= 12; // Horizontal variant (top/bottom stripe) carries extra exemptions: // link/button underline affordances, selected-state indicators // (aria-selected="true", aria-current, active/current/selected class // hints), and state-conditional (:hover/:focus/...) affordances are // not stripes. Tab-strip membership alone ([role=tab], .tabs, bare // [aria-selected]) is NOT exempt — a stripe on every tab in the // group is decoration; only the selected item's underline stays. const horizontalCandidate = heightPx != null && heightPx >= 3 && heightPx <= 12 && !/(?:^|[\s>+~,(])(?:a|button|summary|tr|td|th|table|li)(?![\w-])/i.test(selector) && !/\[aria-selected\s*[*^$|~]?=\s*["']?true/i.test(selector) && !/\[aria-current(?!\s*[*^$|~]?=\s*["']?false)/i.test(selector) && !/(?:^|[\s._[-])(?:active|current|selected|btn[\w-]*|button[\w-]*|link[\w-]*)(?![\w])/i.test(selector) && !/:(?:hover|focus|focus-visible|focus-within|active|checked)\b/i.test(selector); if (!verticalCandidate && !horizontalCandidate) continue; // Resolve edge offsets, letting an `inset` shorthand fill the gaps. const offsets = { top: decls.get('top'), right: decls.get('right'), bottom: decls.get('bottom'), left: decls.get('left'), }; const inset = decls.get('inset'); if (inset) { const p = inset.split(/\s+/); const [t, r, b, l] = p.length === 1 ? [p[0], p[0], p[0], p[0]] : p.length === 2 ? [p[0], p[1], p[0], p[1]] : p.length === 3 ? [p[0], p[1], p[2], p[1]] : p; if (offsets.top == null) offsets.top = t; if (offsets.right == null) offsets.right = r; if (offsets.bottom == null) offsets.bottom = b; if (offsets.left == null) offsets.left = l; } if (offsets.left == null) offsets.left = decls.get('inset-inline-start'); if (offsets.right == null) offsets.right = decls.get('inset-inline-end'); const heightValue = String(resolveVarRefs( decls.get('height') || decls.get('block-size') || '', customProps)).trim(); const widthValue = String(resolveVarRefs( decls.get('width') || decls.get('inline-size') || '', customProps)).trim(); let edge = null; let thicknessPx = null; if (verticalCandidate) { // Full-height stripes hug both corners; the "floating" variant backs // off each end by a small inset (top/bottom a few px) so the bar // clears the card's corners. Both read as the same side-tab accent — // corner treatment is styling, not a different pattern. const topPx = cssLengthToPx(resolveVarRefs(String(offsets.top ?? ''), customProps)); const bottomPx = cssLengthToPx(resolveVarRefs(String(offsets.bottom ?? ''), customProps)); const fullHeight = (isZeroOffset(offsets.top) && isZeroOffset(offsets.bottom)) || /^100(?:\.0*)?%$/.test(heightValue) || (topPx != null && bottomPx != null && topPx >= 0 && topPx <= 20 && bottomPx >= 0 && bottomPx <= 20); if (fullHeight) { edge = isZeroOffset(offsets.left) ? 'left' : isZeroOffset(offsets.right) ? 'right' : null; thicknessPx = widthPx; } } if (!edge && horizontalCandidate) { const fullWidth = (isZeroOffset(offsets.left) && isZeroOffset(offsets.right)) || /^100(?:\.0*)?%$/.test(widthValue); if (fullWidth) { edge = isZeroOffset(offsets.top) ? 'top' : isZeroOffset(offsets.bottom) ? 'bottom' : null; thicknessPx = heightPx; } } if (!edge) continue; // Chromatic fill only — a neutral hairline divider is not an accent // stripe. Unresolvable colors err toward detection, matching the // border rule's unknown-format default. const bg = String(resolveVarRefs( decls.get('background-color') || decls.get('background') || '', customProps)).trim(); if (!bg || /^(?:none|transparent|inherit|initial|unset|currentcolor)$/i.test(bg)) continue; const colorToken = bg.match(/(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\([^)]*\)|#[0-9a-f]{3,8}\b/i); const parsed = parseAnyColor(colorToken ? colorToken[0] : bg); if (parsed) { if ((parsed.a ?? 1) < 0.1) continue; const spread = Math.max(parsed.r, parsed.g, parsed.b) - Math.min(parsed.r, parsed.g, parsed.b); if (spread < 30) continue; } else if (/^(?:white|black|gray|grey|silver)$/i.test(bg)) { continue; } if (seen.has(selector)) continue; seen.add(selector); // The selector group absorbs whitespace trailing the previous rule; // advance past it so `index` points at the selector itself. const selectorStart = m.index + (m[1].length - m[1].trimStart().length); findings.push({ id: 'side-tab', snippet: `${selector} — absolute ${thicknessPx}px pseudo-element stripe (${edge}: 0)`, index: selectorStart, }); } return findings; } // Side-tab stripe drawn as a single-edge inset box-shadow // (x or y offset 3-12px, other axis 0, no blur/spread, chromatic color): // paints a bar along one edge with no border property involved, so the // element-level border checks never see it. Selection-state indicators // are exempt — an inset stripe on [aria-current] / .active / [role=tab] // marks the selected item; the same stripe unconditionally on every item // is decoration and flags. function scanCssTextForInsetStripe(content) { const customProps = collectCssCustomProps(content); const findings = []; const seen = new Set(); const ruleRe = new RegExp(CSS_RULE_BLOCK_SOURCE, 'g'); let m; while ((m = ruleRe.exec(content)) !== null) { const selector = m[1].trim(); // Selection-state contexts: current-item markers and interaction // states. Tab-strip membership alone ([role=tab], .tabs, bare // [aria-selected]) is NOT exempt — a stripe on every tab in the // group is decoration; only the selected item's indicator stays. if (/:(?:hover|focus|focus-visible|focus-within|active|checked|target)\b/i.test(selector)) continue; if (/\[aria-selected\s*[*^$|~]?=\s*["']?true/i.test(selector)) continue; if (/\[aria-current(?!\s*[*^$|~]?=\s*["']?false)/i.test(selector)) continue; if (/(?:^|[\s._[-])(?:active|current|selected)(?![\w])/i.test(selector)) continue; // Structural tags where a single-edge inset shadow is depth/quoting, // not an accent stripe. if (/(?:^|[\s>+~,(])(?:button|hr|tr|td|th|table|blockquote|pre|code)(?![\w-])/i.test(selector)) continue; const decls = parseCssDeclBlock(m[2]); const shadow = decls.get('box-shadow'); if (!shadow || !/\binset\b/i.test(shadow)) continue; // Narrow fixed-width elements (logo marks, icon glyphs) use inset // fills as artwork, not edge stripes. Stripe targets — cards, badges, // menu items — are wider or leave width to layout. const declaredWidth = cssLengthToPx(resolveVarRefs(decls.get('width') || decls.get('inline-size') || '', customProps)); if (declaredWidth != null && declaredWidth <= 40) continue; const value = resolveVarRefs(shadow, customProps); for (const layer of value.split(/,(?![^(]*\))/)) { if (!/\binset\b/i.test(layer)) continue; const colorInfo = findShadowColor(layer); // Unresolvable colors (currentColor, external vars): don't guess. if (!colorInfo || !colorInfo.color) continue; const c = colorInfo.color; if ((c.a ?? 1) < 0.1) continue; const chroma = Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b); if (chroma < 30) continue; const vals = extractShadowLengths(layer, colorInfo.start, colorInfo.end); const x = vals[0] || 0, y = vals[1] || 0, blur = vals[2] || 0, sp = vals[3] || 0; if (blur !== 0 || sp !== 0) continue; const ax = Math.abs(x), ay = Math.abs(y); const isStripe = (ax >= 3 && ax <= 12 && ay === 0) || (ay >= 3 && ay <= 12 && ax === 0); if (!isStripe) continue; if (seen.has(selector)) break; seen.add(selector); const edge = ay === 0 ? (x > 0 ? 'left' : 'right') : (y > 0 ? 'top' : 'bottom'); findings.push({ id: 'side-tab', snippet: `${selector} — inset box-shadow ${ay === 0 ? ax : ay}px stripe (${edge})`, }); break; } } return findings; } // Collect @keyframes names whose body travels horizontally — the marquee // loop. X travel is measured across every translateX/translate/translate3d // X component in the body: a centered element animating something else // keeps a constant -50% X (zero travel) and never qualifies, while a // ticker moves from its resting position to a large offset. Keyframes // with a single X sample that also vary scale/opacity read as pulses or // breathes, not marquees. function collectMarqueeKeyframes(content) { const names = new Set(); const re = /@(?:-webkit-)?keyframes\s+([\w-]+)\s*\{/g; let m; while ((m = re.exec(content)) !== null) { let depth = 1; let i = re.lastIndex; while (i < content.length && depth > 0) { const ch = content.charCodeAt(i); if (ch === 0x7b /* { */) depth++; else if (ch === 0x7d /* } */) depth--; i++; } const body = content.slice(re.lastIndex, Math.max(re.lastIndex, i - 1)); re.lastIndex = i; // Only percentage travel qualifies: a content marquee translates by a // fraction of its own (unknown) track width, so generated tickers use // -50% / -100%. Pixel-travel loops are bespoke product animations — // sweeping playheads, progress indicators — not marquees. const pct = []; const xRe = /\btranslate(?:X|3d)?\(\s*(-?[\d.]+)%/gi; let xm; while ((xm = xRe.exec(body)) !== null) pct.push(parseFloat(xm[1])); if (pct.length === 0) continue; if (pct.length === 1 && /\bscale\(|\bopacity\s*:/i.test(body)) continue; // Implicit start: a lone declared X animates from the element's // resting position, so its magnitude is the travel. const travelPct = pct.length > 1 ? Math.max(...pct) - Math.min(...pct) : Math.abs(pct[0]); if (travelPct >= 20) names.add(m[1]); } return names; } // Auto-scrolling marquee: a element, or an infinite animation // bound to a keyframe loop that travels a large horizontal distance. // Rotation/opacity animations never qualify (no X travel); JS-driven // carousels with user controls have no infinite CSS X-loop to match. // `content` is CSS-bearing text; `markup` (defaulting to the same string // for single-corpus callers) is where the tag itself lives. function scanCssTextForMarquee(content, markup = content) { const findings = []; if (/ element' }); } const marqueeKeyframes = collectMarqueeKeyframes(content); if (marqueeKeyframes.size === 0) return findings; const seen = new Set(); const ruleRe = new RegExp(CSS_RULE_BLOCK_SOURCE, 'g'); let m; while ((m = ruleRe.exec(content)) !== null) { const selector = m[1].trim(); const decls = parseCssDeclBlock(m[2]); for (const name of infiniteAnimationNames(decls)) { if (!marqueeKeyframes.has(name)) continue; const key = `${selector} ${name}`; if (seen.has(key)) continue; seen.add(key); findings.push({ id: 'marquee', snippet: `${selector} — infinite horizontal loop animation "${name}"` }); } } return findings; } // Collect @keyframes names and whether each one reads as a "pulse" — // i.e. it varies opacity, scale, or box-shadow. Rotation-only keyframes // (spinners) are explicitly not pulses. function collectPulseKeyframes(content) { const map = new Map(); const re = /@(?:-webkit-)?keyframes\s+([\w-]+)\s*\{/g; let m; while ((m = re.exec(content)) !== null) { let depth = 1; let i = re.lastIndex; while (i < content.length && depth > 0) { const ch = content.charCodeAt(i); if (ch === 0x7b /* { */) depth++; else if (ch === 0x7d /* } */) depth--; i++; } const body = content.slice(re.lastIndex, Math.max(re.lastIndex, i - 1)); const pulses = /\bopacity\s*:/i.test(body) || /\bbox-shadow\s*:/i.test(body) || /\btransform\s*:[^;{}]*\bscale/i.test(body); if (!map.has(m[1]) || pulses) map.set(m[1], pulses); re.lastIndex = i; } return map; } const ANIMATION_VALUE_KEYWORDS = new Set([ 'ease', 'ease-in', 'ease-out', 'ease-in-out', 'linear', 'infinite', 'alternate', 'alternate-reverse', 'normal', 'reverse', 'none', 'forwards', 'backwards', 'both', 'running', 'paused', 'step-start', 'step-end', 'inherit', 'initial', 'unset', ]); // Extract animation names that run with iteration-count: infinite from a // declaration block (shorthand layers or animation-name + iteration-count). function infiniteAnimationNames(decls) { const out = []; const shorthand = decls.get('animation'); if (shorthand) { for (const layer of shorthand.split(/,(?![^(]*\))/)) { if (!/\binfinite\b/i.test(layer)) continue; const name = layer.split(/\s+/).find(t => /^[a-zA-Z_-][\w-]*$/.test(t) && !ANIMATION_VALUE_KEYWORDS.has(t.toLowerCase())); if (name) out.push(name); } } const nameDecl = decls.get('animation-name'); if (nameDecl && /\binfinite\b/i.test(decls.get('animation-iteration-count') || '')) { for (const raw of nameDecl.split(',')) { const t = raw.trim(); if (t && t.toLowerCase() !== 'none') out.push(t); } } return out; } function isRoundDotRadius(radiusValue, w, h) { if (!radiusValue) return false; const first = String(radiusValue).trim().split(/\s+/)[0]; const pct = first.match(/^([\d.]+)%$/); if (pct) return parseFloat(pct[1]) >= 40; const px = cssLengthToPx(first); if (px == null) return false; return px >= 999 || px >= 0.4 * Math.min(w, h); } // Remove @media blocks whose condition is prefers-reduced-motion: reduce. // Those blocks describe the accessibility fallback, not the default // experience that ships — an `animation: none` reset inside one must not // mask the resting-state animation the page plays for everyone else. function stripReducedMotionBlocks(content) { const re = /@media[^{]*prefers-reduced-motion\s*:\s*reduce[^{]*\{/gi; let out = ''; let last = 0; let m; while ((m = re.exec(content)) !== null) { let depth = 1; let i = re.lastIndex; while (i < content.length && depth > 0) { const ch = content.charCodeAt(i); if (ch === 0x7b /* { */) depth++; else if (ch === 0x7d /* } */) depth--; i++; } out += content.slice(last, m.index); last = i; re.lastIndex = i; } return out + content.slice(last); } // Source-index ranges of and landmark elements in an HTML // string. Lets string-level scans decide whether a matched element sits in // the page chrome (the hero/nav region) without needing a DOM. function landmarkSourceRanges(content) { const ranges = []; for (const tag of ['header', 'nav']) { const re = new RegExp(`<${tag}\\b|${tag}\\s*>`, 'gi'); const stack = []; let m; while ((m = re.exec(content)) !== null) { if (m[0].charAt(1) === '/') { const start = stack.pop(); if (start != null) ranges.push([start, m.index]); } else { stack.push(m.index); } } } return ranges; } function indexInSourceRanges(index, ranges) { return ranges.some(([start, end]) => index >= start && index < end); } // Does any element targeted by the final compound of `selector` appear // inside a header/nav landmark range of the HTML source? Resolves the last // .class or #id token of the selector against class/id attributes; a // tag-only compound is never resolvable this way and returns false // (conservative: no promotion without placement evidence). function selectorHitsLandmark(content, selector, ranges) { if (!ranges || ranges.length === 0) return false; const last = selector.split(/[\s>+~]+/).filter(Boolean).pop() || ''; const idMatch = last.match(/#([A-Za-z_][\w-]*)/); const classMatch = last.match(/\.([A-Za-z_][\w-]*)/); let attrRe = null; if (idMatch) { const id = idMatch[1].replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); attrRe = new RegExp(`<[a-zA-Z][^>]*\\bid\\s*=\\s*["']${id}["']`, 'gi'); } else if (classMatch) { const cls = classMatch[1].replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); attrRe = new RegExp(`<[a-zA-Z][^>]*\\bclass\\s*=\\s*["'][^"']*(?= 40% or pill values), and an infinite animation whose // keyframes vary opacity/scale/box-shadow (or a pulse/blink/ping name when // the keyframes aren't in the scanned text). Rotation-only animations // (spinners) never flag. // // Declarations for one selector are merged across rule blocks before the // predicate runs: size in the base rule plus the animation added in a // second block (or inside a matching @media block) is the construction // that ships. prefers-reduced-motion: reduce overrides are stripped first // so their animation resets don't mask the default experience. A dot whose // element sits inside a header/nav landmark is the hero liveness cliché // and is promoted to error severity; occurrences elsewhere keep the // registry default severity. // // `content` is CSS-bearing text (rules and keyframes); `markup` — defaulting // to the same string for single-corpus callers like the regex source // engine — is where landmark ranges and Tailwind class attributes live. function scanCssTextForPulsingDot(content, markup = content) { const customProps = collectCssCustomProps(content); const keyframes = collectPulseKeyframes(content); const heroRanges = landmarkSourceRanges(markup); const findings = []; const seen = new Set(); // Merge declarations per selector across rule blocks, approximating the // cascade: later declarations for the same property win. Comma lists are // split so `.a, .b { … }` contributes to both selectors. Comments are // stripped first so they neither pollute selector keys nor smuggle a // comma into the selector-list split. const scanText = stripReducedMotionBlocks(content).replace(/\/\*[\s\S]*?\*\//g, ' '); const merged = new Map(); const ruleRe = new RegExp(CSS_RULE_BLOCK_SOURCE, 'g'); let m; while ((m = ruleRe.exec(scanText)) !== null) { const decls = parseCssDeclBlock(m[2]); if (decls.size === 0) continue; for (const rawSelector of m[1].split(',')) { const selector = rawSelector.trim(); if (!selector || selector.startsWith('@')) continue; let acc = merged.get(selector); if (!acc) { acc = new Map(); merged.set(selector, acc); } for (const [prop, value] of decls) acc.set(prop, value); } } for (const [selector, decls] of merged) { const names = infiniteAnimationNames(decls); if (names.length === 0) continue; const pulseName = names.find(n => { const known = keyframes.get(n); if (known != null) return known; return /pulse|blink|ping/i.test(n); }); if (!pulseName) continue; const w = cssLengthToPx(resolveVarRefs( decls.get('width') || decls.get('inline-size') || '', customProps)); const h = cssLengthToPx(resolveVarRefs( decls.get('height') || decls.get('block-size') || '', customProps)); if (w == null || h == null || w < 2 || h < 2 || w > 16 || h > 16) continue; const radius = resolveVarRefs(decls.get('border-radius') || '', customProps); if (!isRoundDotRadius(radius, w, h)) continue; if (seen.has(selector)) continue; seen.add(selector); const inLandmark = selectorHitsLandmark(markup, selector, heroRanges); findings.push({ id: 'pulsing-dot', snippet: `${selector} — ${w}x${h}px dot with infinite "${pulseName}" animation${inLandmark ? ' in header/nav' : ''}`, selector, ...(inLandmark ? { severity: 'error' } : {}), }); } // Tailwind utilities: animate-ping / animate-pulse on a tiny rounded-full // element declared entirely in the class attribute. Scanned in the markup // corpus so the match index lines up with the landmark ranges. const classRe = /class\s*=\s*(?:"([^"]*)"|'([^']*)')/gi; let cm; while ((cm = classRe.exec(markup)) !== null) { const cls = cm[1] || cm[2] || ''; const anim = cls.match(/\banimate-(ping|pulse)\b/); if (!anim) continue; if (!/\brounded-full\b/.test(cls)) continue; if (!/\b(?:w|h|size)-(?:1|1\.5|2|2\.5|3|3\.5|4)\b/.test(cls)) continue; const key = `tw:${cls}`; if (seen.has(key)) continue; seen.add(key); const inLandmark = indexInSourceRanges(cm.index, heroRanges); findings.push({ id: 'pulsing-dot', snippet: `animate-${anim[1]} on tiny rounded-full element${inLandmark ? ' in header/nav' : ''}`, ...(inLandmark ? { severity: 'error' } : {}), }); } return findings; } // Shape-assembled illustration: a large inline SVG composing a pictorial // scene from many primitive shapes (rect / circle / ellipse / polygon) in // several fill colors — the clip-art hero mascot. Gates keep the legitimate // SVG population out: // • icons and logos: intrinsic size gate (>= 200px on both axes, from // width/height attributes or the viewBox when no explicit size is set) // • charts / labeled diagrams: more than two / nodes exempts // the graphic (axis labels, callouts) // • line drawings / technical diagrams: primitive count < 8 or fewer // than 3 distinct fills never qualifies (stroke-only art has no fills) // • tiling background textures: any definition exempts function scanHtmlForShapeAssembledIllustration(html) { const findings = []; const svgRe = /]*>[\s\S]*?<\/svg>/gi; let m; while ((m = svgRe.exec(html)) !== null) { const block = m[0]; const openTag = (block.match(/^]*>/i) || [''])[0]; // Data-bearing or annotated graphics: axis labels and callout text // mark a chart or diagram, not a mascot. const textCount = (block.match(/<(?:text|tspan)\b/gi) || []).length; if (textCount > 2) continue; // Tiling texture definitions are decorative backgrounds, not scenes. if (/ { // (?}\s]+)/gi)) { const paint = fm[1].trim().toLowerCase(); if (!paint || ['none', 'transparent', 'currentcolor', 'inherit'].includes(paint)) continue; fills.add(paint); } if (fills.size < 3) continue; findings.push({ id: 'shape-assembled-illustration', snippet: `inline scene: ${primitives} primitive shapes, ~${Math.round(w)}x${Math.round(h)}px, ${fills.size} fill colors`, }); } return findings; } // Scoped scan corpora for the page-level pattern checks. CSS-property // regexes run over the whole source string fire on documentation ABOUT // css — `background-clip: text` prose, samples, HTML // comments — so the checks scan only the strings that actually style the // page: // styleText —
background-clip: text
samples, HTML // comments — so the checks scan only the strings that actually style the // page: // styleText —