mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 22:26:38 +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>
3713 lines
158 KiB
JavaScript
3713 lines
158 KiB
JavaScript
import {
|
||
BORDER_SAFE_TAGS,
|
||
GENERIC_FONTS,
|
||
KNOWN_SERIF_FONTS,
|
||
OVERUSED_FONTS,
|
||
SAFE_TAGS,
|
||
WCAG_LARGE_BOLD_TEXT_PX,
|
||
WCAG_LARGE_TEXT_PX,
|
||
isBrandFontOnOwnDomain,
|
||
} from '../shared/constants.mjs';
|
||
import {
|
||
colorToHex,
|
||
contrastRatio,
|
||
getHue,
|
||
hasChroma,
|
||
isNeutralColor,
|
||
parseGradientColors,
|
||
parseRgb,
|
||
relativeLuminance,
|
||
} from '../shared/color.mjs';
|
||
import { extractGoogleFontFamilies } from '../shared/fonts.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} |