mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-13 06:36:26 +03:00
Four gaps from human review of gpt-5.6 eval artifacts: 1. codex-grid-background variants: the block scan now also matches the inverted end-of-tile hairline form (transparent calc(100% - Npx)) and reads the tile cell from the background shorthand's `/ Npx Npx` slot, not just background-size declarations. A single hairline layer qualifies when tiled by a px pair cell (page-scale line field); percent-tiled single hairlines (background-size: 25% 100% rules on data-viz tracks/graphs) stay legal. 2. hero-eyebrow-chip branch C (dash-prefix): sentence-case, regular- weight microlabels above the h1 announced by a short chromatic ::before/::after bar (8-80px x 1-6px, accent fill). Static cascade marks dash-pseudo targets during rule collection; the browser path reads getComputedStyle(el, '::before'/'::after'). 3. New `marquee` slop rule: <marquee> elements, and infinite animations bound to keyframes with >= 20 percentage points of X travel. Percent travel only — px-travel loops are bespoke product animations (waveform playheads, progress sweeps). Centered elements animating other properties (constant -50% X), non-infinite slide-ins, rotations, and pulses never qualify. 4. side-tab inset box-shadow variant: single-edge inset shadows (3-12px offset on one axis, no blur/spread, chromatic) drawn as stripes on cards/badges/menu items. Selection-state indicators ([aria-current], [aria-selected], [role=tab], active/current/selected hints, interaction states) stay exempt; the same stripe repeated unconditionally on every item flags. Narrow fixed-width glyphs (logo marks) are exempt. isTabContextElement narrowed to match: bare nav ancestry no longer blanket-exempts top/bottom border stripes — only explicit tab semantics or state markers do. Browser bundle regenerated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
6269 lines
260 KiB
JavaScript
6269 lines
260 KiB
JavaScript
/**
|
||
* 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: <script src="detect-antipatterns-browser.js"></script>
|
||
* 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 <label> 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);
|
||
|
||
// 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: 'single-font',
|
||
category: 'slop',
|
||
scopes: ['type'],
|
||
name: 'Single font without hierarchy',
|
||
description:
|
||
'Only one font family is used for the entire page. A single family can work when weight and size contrast carry the hierarchy; otherwise pair a distinctive display font with a refined body font.',
|
||
skillSection: 'Typography',
|
||
skillGuideline: 'only one font family for the entire page',
|
||
},
|
||
{
|
||
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: '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: '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: 'repeated-section-kickers',
|
||
category: 'slop',
|
||
scopes: ['type'],
|
||
severity: 'advisory',
|
||
name: 'Repeated section kicker labels',
|
||
description:
|
||
'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.',
|
||
skillSection: 'Typography',
|
||
skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding',
|
||
},
|
||
{
|
||
id: 'numbered-section-markers',
|
||
category: 'slop',
|
||
scopes: ['layout'],
|
||
severity: 'advisory',
|
||
name: 'Numbered section markers (01 / 02 / 03)',
|
||
description:
|
||
'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.',
|
||
skillSection: 'Layout & Space',
|
||
skillGuideline: 'numbered section markers',
|
||
},
|
||
{
|
||
id: 'em-dash-overuse',
|
||
category: 'slop',
|
||
name: 'Em-dash overuse',
|
||
description:
|
||
'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.',
|
||
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:
|
||
'<img> 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: '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: '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: '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: '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',
|
||
},
|
||
|
||
// ── Provider tells: opt-in via --gpt / --gemini (gated off by default) ──
|
||
{
|
||
id: 'gpt-thin-border-wide-shadow',
|
||
category: 'slop',
|
||
severity: 'advisory',
|
||
gated: 'gpt',
|
||
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',
|
||
gated: 'gpt',
|
||
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',
|
||
gated: 'gpt',
|
||
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',
|
||
gated: 'gpt',
|
||
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',
|
||
gated: 'gemini',
|
||
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 <span>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 [];
|
||
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 <hr> 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 <a> and <button> elements styled as buttons. SAFE_TAGS
|
||
// exists to suppress contrast noise on inline links and unstyled controls,
|
||
// where the element has no own background and the contrast against the
|
||
// ancestor surface is already the intended visual. When the element has
|
||
// its own opaque background and direct text, it is a styled button — and
|
||
// contrast on its own surface is a real, frequent bug worth flagging.
|
||
const isStyledButton = (tag === 'a' || tag === 'button')
|
||
&& hasDirectText
|
||
&& bgColor && bgColor.a > 0.5;
|
||
if (!isStyledButton) return [];
|
||
}
|
||
const findings = [];
|
||
|
||
if (hasDirectText && textColor && !isEmojiOnly) {
|
||
// 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 = 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) {
|
||
findings.push({ id: 'low-contrast', snippet: `${ratio.toFixed(1)}: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 <svg> or icon-class <i> 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;
|
||
}
|
||
|
||
// 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,
|
||
siblingTag, siblingText, siblingTextTransform,
|
||
siblingFontSize, siblingLetterSpacing,
|
||
siblingFontWeight, siblingColor,
|
||
siblingHasAccentDashPseudo,
|
||
} = opts;
|
||
if (headingTag !== 'h1') return [];
|
||
// We previously gated on headingFontSize >= 48 to anchor "hero scale".
|
||
// But modern hero h1s use clamp() / vw / var(--text-*), none of which
|
||
// jsdom can resolve — the computed value comes back as "2em" or
|
||
// "var(--text-9xl)" and parseFloat returns 2 or NaN. The gate fails
|
||
// on virtually every Tailwind v4 / framework build. The other gates
|
||
// (sibling text 2-60 chars, font-size ≤ 14px, accent-bold OR
|
||
// tracked-caps) are tight enough to avoid false positives on non-
|
||
// hero h1s — a tiny tan label directly above any h1 is the
|
||
// antipattern regardless of how big the h1 ends up.
|
||
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}"`,
|
||
}];
|
||
}
|
||
|
||
function checkRepeatedSectionKickers(opts) {
|
||
const { candidates, minCount = 3 } = opts;
|
||
if (!Array.isArray(candidates) || candidates.length < minCount) return [];
|
||
return candidates.map(candidate => ({
|
||
id: 'repeated-section-kickers',
|
||
snippet: `repeated section kicker "${candidate.kickerText}" before ${candidate.headingTag} "${candidate.headingText}" (${candidates.length} on page)`,
|
||
}));
|
||
}
|
||
|
||
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 <color>) — 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 <body style>)
|
||
// 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(/<body[^>]*\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 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 <pos>`) 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(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();
|
||
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, tab strips, selected states, and
|
||
// state-conditional (:hover/:focus/...) affordances are not stripes.
|
||
const horizontalCandidate = heightPx != null && heightPx >= 3 && heightPx <= 12
|
||
&& !/(?:^|[\s>+~,(])(?:a|button|summary|tr|td|th|table|li)(?![\w-])/i.test(selector)
|
||
&& !/\[role=["']?tab|\[aria-selected/i.test(selector)
|
||
&& !/(?:^|[\s._[-])(?:tabs?|tablist|tab-[\w-]*|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) {
|
||
const fullHeight = (isZeroOffset(offsets.top) && isZeroOffset(offsets.bottom))
|
||
|| /^100(?:\.0*)?%$/.test(heightValue);
|
||
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);
|
||
findings.push({
|
||
id: 'side-tab',
|
||
snippet: `${selector} — absolute ${thicknessPx}px pseudo-element stripe (${edge}: 0)`,
|
||
});
|
||
}
|
||
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();
|
||
// State/selection contexts: current-item markers, interaction states,
|
||
// explicit tab semantics.
|
||
if (/:(?:hover|focus|focus-visible|focus-within|active|checked|target)\b/i.test(selector)) continue;
|
||
if (/\[aria-(?:current|selected)/i.test(selector)) continue;
|
||
if (/\[role=["']?tab/i.test(selector)) continue;
|
||
if (/(?:^|[\s._[-])(?:active|current|selected|tabs?)(?![\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 <marquee> 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.
|
||
function scanCssTextForMarquee(content) {
|
||
const findings = [];
|
||
if (/<marquee\b/i.test(content)) {
|
||
findings.push({ id: 'marquee', snippet: '<marquee> 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} |