import { BORDER_SAFE_TAGS, EM_DASH_CHARS_PER_DASH, EM_DASH_FLOOR, 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 s (own visible background) are a real stripe target // for the top/bottom variant — the inline-tag exemption exists to quiet // text-level borders, not chips. They skip the left/right arms below. const spanBadge = tag === 'span' && !!opts.badgeLike; if (BORDER_SAFE_TAGS.has(tag) && !spanBadge) return []; // A live status/alert region wears a colored single-edge border as a // severity accent (toast, snackbar, callout), not as the side-tab tell. if (opts.statusContext) return []; const findings = []; const sides = ['Top', 'Right', 'Bottom', 'Left']; for (const side of sides) { const w = widths[side]; if (w < 1 || isNeutralColor(colors[side])) continue; const otherSides = sides.filter(s => s !== side); const maxOther = Math.max(...otherSides.map(s => widths[s])); if (!(w >= 2 && (maxOther <= 1 || w >= maxOther * 2))) continue; const sn = side.toLowerCase(); const isSide = side === 'Left' || side === 'Right'; if (isSide) { if (spanBadge) continue; if (radius > 0) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` }); else if (w >= 3) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px` }); } else { if (radius > 0 && w >= 2) findings.push({ id: 'border-accent-on-rounded', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` }); // Horizontal variant of the side-tab stripe: a thick chromatic accent // riding the top or bottom edge of a card/badge/container. Same // dominant-edge + chroma gates as left/right, 3-12px band. Selected- // tab underlines are exempt via opts.tabContext (adapters look for // tablist/nav/tab ancestors and aria-selected); links, buttons, // table cells, and
never reach here (BORDER_SAFE_TAGS). else if (!opts.tabContext && w >= 3 && w <= 12) { findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px` }); } } } return findings; } // Returns true if the given text is composed entirely of emoji characters // (plus whitespace / variation selectors). Emojis render as multicolor glyphs // regardless of CSS `color`, so contrast checks against the element's text // color are meaningless for these nodes. const EMOJI_CHAR_RE = /[\u{1F1E6}-\u{1F1FF}\u{1F300}-\u{1F9FF}\u{1FA00}-\u{1FAFF}\u{2600}-\u{27BF}\u{2300}-\u{23FF}\u{FE0F}\u{200D}\u{1F3FB}-\u{1F3FF}]/u; const EMOJI_CHARS_GLOBAL = /[\u{1F1E6}-\u{1F1FF}\u{1F300}-\u{1F9FF}\u{1FA00}-\u{1FAFF}\u{2600}-\u{27BF}\u{2300}-\u{23FF}\u{FE0F}\u{200D}\u{1F3FB}-\u{1F3FF}]/gu; function isEmojiOnlyText(text) { if (!text) return false; if (!EMOJI_CHAR_RE.test(text)) return false; return text.replace(EMOJI_CHARS_GLOBAL, '').trim() === ''; } function checkColors(opts) { const { tag, textColor, bgColor, effectiveBg, effectiveBgStops, fontSize, fontWeight, hasDirectText, isEmojiOnly, bgClip, bgImage, classList } = opts; if (SAFE_TAGS.has(tag)) { // Exception for elements styled as controls or chips. SAFE_TAGS exists to // suppress contrast noise on inline links and unstyled spans, where the // element has no own background and the contrast against the ancestor // surface is already the intended visual. When the element paints its own // opaque background under direct text, it is a styled button, chip, or // badge regardless of tag, and contrast on its own surface is a real, // frequent bug worth flagging. (The shipped miss: a severity chip // whose white text lost a specificity fight and rendered muted-on-red at // 1.2:1; the old a/button-only exception never looked at it.) The 9px // font floor keeps sub-text decorations out. const isStyledControl = hasDirectText && ((bgColor && bgColor.a > 0.5) // A gradient painted on the element itself is an own surface the // same way a solid background is. Without this branch a nav CTA // built as `` with `background: linear-gradient(…)` and a text // color that fails against every stop sails through on the // SAFE_TAGS suppression (the shipped escape). || (bgImage && /gradient/i.test(bgImage))) && fontSize >= 9; if (!isStyledControl) return []; } const findings = []; if (hasDirectText && textColor && !isEmojiOnly) { // Gradient-clipped text (`background-clip: text`, typically with a // transparent text-fill) paints its glyphs *with* the element's own // gradient. The `color` value the cascade still reports is never painted, // and the gradient is the fill, not a backdrop — so measuring `color` // against that gradient (which resolveGradientStops picks up as the // element's own background-image) is a guaranteed false positive // (issue #409 Case A). Skip the backdrop-contrast checks; the gradient-text // rule below still flags the pattern itself. Skipping a rule beats a false // positive here — the true painted contrast can't be measured from `color`. const isGradientClippedText = bgClip === 'text'; // Run background-dependent checks against either a solid bg or, if the // ancestor is a gradient, against every gradient stop (use the worst case). const bgs = isGradientClippedText ? null : (effectiveBg ? [effectiveBg] : (effectiveBgStops && effectiveBgStops.length ? effectiveBgStops : null)); if (bgs) { // Gray on colored background — flag if every stop is chromatic const textLum = relativeLuminance(textColor); const isGray = !hasChroma(textColor, 20) && textLum > 0.05 && textLum < 0.85; if (isGray && bgs.every(b => hasChroma(b, 40))) { const bgLabel = effectiveBg ? colorToHex(effectiveBg) : `gradient(${bgs.map(colorToHex).join(', ')})`; findings.push({ id: 'gray-on-color', snippet: `text ${colorToHex(textColor)} on bg ${bgLabel}` }); } // Low contrast (WCAG AA) — worst case across all bg stops const ratios = bgs.map(b => contrastRatio(textColor, b)); let worstIdx = 0; for (let i = 1; i < ratios.length; i++) if (ratios[i] < ratios[worstIdx]) worstIdx = i; const ratio = ratios[worstIdx]; const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700); const threshold = isLargeText ? 3.0 : 4.5; if (ratio < threshold) { // Skip the false-positive class where text has alpha < 1 AND we // couldn't find an opaque ancestor (effectiveBg is null, we're // comparing against gradient-stop fallback). In jsdom mode the // detector can't resolve `var(--X)` color tokens, so a dark // section sitting between the text and the body's decorative // gradient is invisible to us — we end up measuring contrast // against the body's paper-grain noise instead of the real // local bg. Real low-contrast bugs use alpha=1 and have a // resolvable opaque ancestor; semi-transparent Tailwind tokens // like `text-paper/60` on `bg-ink` sections are the FP pattern. const isAlphaFallbackFP = !DETECTOR_IS_BROWSER && !effectiveBg && (textColor.a != null && textColor.a < 1); if (!isAlphaFallbackFP) { // Near-threshold ratios (e.g. 4.497) would round to the threshold // itself at one decimal and read as "4.5 needs 4.5" — show two // decimals there so the finding stays legible. const ratioLabel = ratio.toFixed(1) === threshold.toFixed(1) ? ratio.toFixed(2) : ratio.toFixed(1); findings.push({ id: 'low-contrast', snippet: `${ratioLabel}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(bgs[worstIdx])}` }); } } } // AI palette: purple/violet on headings if (hasChroma(textColor, 50)) { const hue = getHue(textColor); if (hue >= 260 && hue <= 310 && (['h1', 'h2', 'h3'].includes(tag) || fontSize >= 20)) { findings.push({ id: 'ai-color-palette', snippet: `Purple/violet text (${colorToHex(textColor)}) on heading` }); } } } // Gradient text if (bgClip === 'text' && bgImage && bgImage.includes('gradient')) { findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' }); } // Tailwind class checks if (classList) { const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' '); const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/); const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); if (grayMatch && colorBgMatch) { findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` }); } if (/\bbg-clip-text\b/.test(classStr) && /\bbg-gradient-to-/.test(classStr)) { findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' }); } const purpleText = classStr.match(/\btext-(?:purple|violet|indigo)-\d+\b/); if (purpleText && (['h1', 'h2', 'h3'].includes(tag) || /\btext-(?:[2-9]xl)\b/.test(classStr))) { findings.push({ id: 'ai-color-palette', snippet: `${purpleText[0]} on heading` }); } if (/\bfrom-(?:purple|violet|indigo)-\d+\b/.test(classStr) && /\bto-(?:purple|violet|indigo|blue|cyan|pink|fuchsia)-\d+\b/.test(classStr)) { findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet gradient (Tailwind)' }); } } return findings; } // WCAG contrast for the :hover state of an element whose hover rules change // its text color and/or background. The classic miss: a nav CTA whose // author-intended hover pair passes AA, but a broader selector (e.g. // `.nav-links a:hover`) wins the specificity fight and swaps in a color // that fails. Only fires on elements that present as styled controls — // direct text plus an opaque-ish own background in either state — so plain // inline links keep the same suppression they get in checkColors. function checkHoverContrast(opts) { const { tag, textColor, bg, ownBgAlpha, fontSize, fontWeight, hasDirectText, isEmojiOnly } = opts; if (!hasDirectText || isEmojiOnly || !textColor || !bg) return []; if (SAFE_TAGS.has(tag) && !(ownBgAlpha != null && ownBgAlpha > 0.5)) return []; const ratio = contrastRatio(textColor, bg); const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700); const threshold = isLargeText ? 3.0 : 4.5; if (ratio >= threshold) return []; return [{ id: 'low-contrast', snippet: `:hover state ${ratio.toFixed(1)}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(bg)}`, }]; } function isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg) { if (!hasShadow && !hasBorder) return false; return hasRadius || hasBg; } const HEADING_TAGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']); // Pure check: given a heading and metrics about its previousElementSibling, // decide if the sibling is the canonical "icon-tile-stacked-above-heading" shape. // // Triggers when ALL of the following hold for the sibling: // • size 32–128px on both axes (not too small, not a hero image) // • aspect ratio 0.7–1.4 (squarish — excludes wide thumbnails / pill badges) // • has a non-transparent background-color, background-image, OR a visible border // (covers solid colors, white-with-border, gradients — anything that visually // defines a tile) // • border-radius < width/2 (excludes round avatars; rounded squares pass) // • contains an or icon-class element that's smaller than the tile // • the tile sits above the heading (its bottom is above the heading's top) function checkIconTile(opts) { const { headingTag, headingText, headingTop, siblingTag, siblingWidth, siblingHeight, siblingBottom, siblingBgColor, siblingBgImage, siblingBorderWidth, siblingBorderRadius, hasIconChild, iconChildWidth } = opts; if (!HEADING_TAGS.has(headingTag)) return []; if (!siblingTag) return []; // Don't recurse into nested headings (e.g. h2 above h3 in a section header) if (HEADING_TAGS.has(siblingTag)) return []; // Size window: 32–128px on each axis if (!(siblingWidth >= 32 && siblingWidth <= 128)) return []; if (!(siblingHeight >= 32 && siblingHeight <= 128)) return []; // Squarish aspect ratio const ratio = siblingWidth / siblingHeight; if (ratio < 0.7 || ratio > 1.4) return []; // Must have something that visually defines the tile const bgVisible = (siblingBgColor && siblingBgColor.a > 0.1) || (siblingBgImage && siblingBgImage !== 'none' && siblingBgImage !== ''); const borderVisible = siblingBorderWidth > 0; if (!bgVisible && !borderVisible) return []; // Exclude circles (avatars). Rounded squares pass. if (siblingBorderRadius >= siblingWidth / 2) return []; // Must contain an icon element smaller than the tile if (!hasIconChild) return []; if (iconChildWidth && iconChildWidth >= siblingWidth * 0.95) return []; // Vertical stacking: tile must end above where the heading starts. // (Allow the check to skip when both top/bottom are 0 — jsdom layout case.) if (headingTop && siblingBottom && siblingBottom > headingTop + 4) return []; const text = (headingText || '').trim().slice(0, 60); return [{ id: 'icon-tile-stack', snippet: `${Math.round(siblingWidth)}x${Math.round(siblingHeight)}px icon tile above ${headingTag} "${text}"`, }]; } // Resolve the primary (non-generic) face from a font-family string and return // whether the resolved primary is serif. Two paths: // 1. Primary face is in KNOWN_SERIF_FONTS → serif. // 2. Primary face is unknown but the stack ends in the generic `serif` // token → treat as serif. Authors who declare `font-family: 'X', serif` // almost always have a serif primary; a sans declared with a serif // fallback is a code smell, not the common case. // Returns { primary, isSerif } so the snippet can name the face. function resolveSerif(fontFamily) { if (!fontFamily) return { primary: null, isSerif: false }; const tokens = fontFamily.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase()); const primary = tokens.find(f => f && !GENERIC_FONTS.has(f)) || null; if (!primary) return { primary: null, isSerif: false }; if (KNOWN_SERIF_FONTS.has(primary)) return { primary, isSerif: true }; if (tokens.includes('serif')) return { primary, isSerif: true }; return { primary, isSerif: false }; } function checkItalicSerif(opts) { const { tag, fontStyle, fontFamily, fontSize, headingText } = opts; if (fontStyle !== 'italic') return []; // Anchor the rule on hero-scale text. h1 is the canonical hero element; // h2 ≥ 48px catches the cases where the design demotes the visual hero // to an h2 but keeps the size. if (tag !== 'h1' && !(tag === 'h2' && fontSize >= 48)) return []; if (fontSize < 48) return []; const { primary, isSerif } = resolveSerif(fontFamily); if (!isSerif) return []; const text = (headingText || '').trim().slice(0, 60); return [{ id: 'italic-serif-display', snippet: `italic serif ${tag} (${primary || 'serif'}) at ${Math.round(fontSize)}px "${text}"`, }]; } // Color saturation check. Returns true when the color has visible // chroma — i.e., it's an "accent color" rather than near-neutral. // Handles rgb()/rgba(), #hex, oklch(), and hsl(). var() refs are // expected to be pre-resolved by the caller. function isAccentColor(cssColor) { if (!cssColor) return false; const s = String(cssColor).trim(); // rgb / rgba — direct channel-distance check. const rgbM = /rgba?\(\s*(\d+)\s*,?\s+|\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(s.replace(/rgba?\(\s*/, 'rgb(').replace(/,/g, ', ')); const rgbStrict = /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(s); if (rgbStrict) { const r = +rgbStrict[1], g = +rgbStrict[2], b = +rgbStrict[3]; return (Math.max(r, g, b) - Math.min(r, g, b)) >= 40; } // #hex — 3, 4, 6, or 8 digit. const hexM = /^#([0-9a-f]{3,8})\b/i.exec(s); if (hexM) { let h = hexM[1]; if (h.length === 3 || h.length === 4) h = h.split('').map((c) => c + c).join('').slice(0, 6); else h = h.slice(0, 6); if (h.length === 6) { const r = parseInt(h.slice(0, 2), 16); const g = parseInt(h.slice(2, 4), 16); const b = parseInt(h.slice(4, 6), 16); return (Math.max(r, g, b) - Math.min(r, g, b)) >= 40; } } // oklch(L C H) — chroma C is what matters. Typical neutral grays // have C < 0.02; visible accents are 0.05+. CSS minification can // collapse spaces between L% and C ("oklch(43%.15 34)"), so we // extract all numbers and take the second rather than matching a // strict L-then-whitespace-then-C pattern. if (/^oklch\(/i.test(s)) { const nums = s.match(/\d*\.\d+|\d+/g); if (nums && nums.length >= 2) { const c = parseFloat(nums[1]); return !Number.isNaN(c) && c >= 0.05; } } // hsl(H, S%, L%) — saturation > 20% reads as accent. const hslM = /hsla?\(\s*[\d.]+\s*,\s*([\d.]+)%/i.exec(s); if (hslM) { const sat = parseFloat(hslM[1]); return !Number.isNaN(sat) && sat >= 20; } return false; } function resolveHeroHeadingSizePx(value) { const input = String(value || '').trim().toLowerCase(); if (!input) return 0; const simpleLengthPx = (token) => { const match = /^(-?\d*\.?\d+)\s*(px|rem|em|%)?$/.exec(String(token || '').trim()); if (!match) return null; const amount = Number(match[1]); if (!Number.isFinite(amount)) return null; if (match[2] === 'rem' || match[2] === 'em') return amount * 16; if (match[2] === '%') return amount * 0.16; return amount; }; const direct = simpleLengthPx(input); if (direct !== null) return direct; // Static CSS engines cannot resolve viewport units, but clamp's min/max // bounds still tell us whether the heading can ever reach hero scale. const clamp = /^clamp\((.*)\)$/.exec(input); if (clamp) { const parts = clamp[1].split(','); if (parts.length === 3) { const bounds = [simpleLengthPx(parts[0]), simpleLengthPx(parts[2])] .filter((candidate) => candidate !== null); if (bounds.length > 0) return Math.max(...bounds); } } return 0; } // Sibling-relationship rule. Anchor on a hero-scale h1, look at the // previousElementSibling, and gate on EITHER the classic tracked- // uppercase eyebrow OR the modern accent-colored bold eyebrow. function checkHeroEyebrow(opts) { const { headingTag, headingText, headingFontSize, headingInApplicationContext, siblingTag, siblingText, siblingTextTransform, siblingFontSize, siblingLetterSpacing, siblingFontWeight, siblingColor, siblingHasAccentDashPseudo, } = opts; if (headingTag !== 'h1') return []; // This is specifically a marketing-hero cliché, not a ban on compact // context labels in product UI (for example, a station name inside a tab // panel). Browser-computed sizes are reliable; the static adapter also // resolves ordinary px/rem/em and clamp() bounds before reaching here. if (headingInApplicationContext) return []; if (!(headingFontSize >= 48)) return []; if (!siblingTag) return []; // An h2 above an h1 is a different anti-pattern (heading hierarchy / dual // headings) — never an eyebrow. if (HEADING_TAGS.has(siblingTag)) return []; const text = (siblingText || '').trim(); if (text.length < 2 || text.length > 60) return []; if (!(siblingFontSize > 0 && siblingFontSize <= 14)) return []; // Branch A: classic tracked-uppercase eyebrow. const isUppercased = siblingTextTransform === 'uppercase' || (/[A-Z]/.test(text) && !/[a-z]/.test(text)); const isClassicTracked = isUppercased && siblingLetterSpacing >= 1.6; // Branch B: modern accent-bold eyebrow — sentence case, low // tracking, but bold + accent-colored. The style choices changed; // the pattern is the same kicker-above-headline anti-pattern. const weight = Number(siblingFontWeight) || 400; const isAccentBold = weight >= 700 && isAccentColor(siblingColor || ''); // Branch C: dash-prefix eyebrow — sentence case, low tracking, regular // weight, but announced by a short chromatic ::before/::after bar // (the kicker dash). Same label-above-headline pattern, third styling. const isDashPrefixed = !!siblingHasAccentDashPseudo; if (!isClassicTracked && !isAccentBold && !isDashPrefixed) return []; const headingTextSnippet = (headingText || '').trim().slice(0, 60); const eyebrowSnippet = text.slice(0, 40); const style = isClassicTracked ? 'tracked-caps' : isAccentBold ? 'accent-bold' : 'dash-prefix'; return [{ id: 'hero-eyebrow-chip', snippet: `eyebrow chip (${style}) "${eyebrowSnippet}" above ${headingTag} "${headingTextSnippet}"`, }]; } // Outright ban: one kicker is one too many, so every collected candidate is // a finding. The judgment lives in the candidate gate (isKickerCandidate) and // the collector's context skips, not in a repetition count. function checkKickerAboveHeading(opts) { const { candidates } = opts; if (!Array.isArray(candidates)) return []; return candidates.map(candidate => ({ id: 'kicker-above-heading', snippet: `kicker "${candidate.kickerText}" above ${candidate.headingTag} "${candidate.headingText}"`, })); } const LAYOUT_TRANSITION_PROPS = new Set([ 'width', 'height', 'padding', 'margin', 'max-height', 'max-width', 'min-height', 'min-width', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left', ]); function checkMotion(opts) { const { tag, transitionProperty, animationName, timingFunctions, classList } = opts; if (SAFE_TAGS.has(tag)) return []; const findings = []; // --- Bounce/elastic easing --- if (animationName && animationName !== 'none' && /bounce|elastic|wobble|jiggle|spring/i.test(animationName)) { findings.push({ id: 'bounce-easing', snippet: `animation: ${animationName}` }); } if (classList && /\banimate-bounce\b/.test(classList)) { findings.push({ id: 'bounce-easing', snippet: 'animate-bounce (Tailwind)' }); } // Check timing functions for overshoot cubic-bezier (y values outside [0, 1]) if (timingFunctions) { const bezierRe = /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g; let m; while ((m = bezierRe.exec(timingFunctions)) !== null) { const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]); if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) { findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${m[1]}, ${m[2]}, ${m[3]}, ${m[4]})` }); break; } } } // --- Layout property transition --- if (transitionProperty && transitionProperty !== 'all' && transitionProperty !== 'none') { const props = transitionProperty.split(',').map(p => p.trim().toLowerCase()); const layoutFound = props.filter(p => LAYOUT_TRANSITION_PROPS.has(p)); if (layoutFound.length > 0) { findings.push({ id: 'layout-transition', snippet: `transition: ${layoutFound.join(', ')}` }); } } return findings; } // Locate the color token in a single shadow layer. Returns // { color, start, end } where color is the parsed {r,g,b,a} (null when the // token exists but can't be parsed — e.g. an unresolved var() or an exotic // color space), or null when no color token is present at all. Handles both // serialization orders: computed style puts the color first // ("rgb(…) 0px 0px 20px"), authored CSS usually puts it last // ("0 0 20px #3b82f6"). function findShadowColor(layer) { const fn = layer.match(/(?:rgba?|hsla?|hwb|oklch|oklab|lch|lab|color)\([^)]*\)/i); if (fn) return { color: parseAnyColor(fn[0]), start: fn.index, end: fn.index + fn[0].length }; const hex = layer.match(/#[0-9a-fA-F]{3,8}\b/); if (hex) return { color: parseAnyColor(hex[0]), start: hex.index, end: hex.index + hex[0].length }; const wordRe = /[a-zA-Z][a-zA-Z]*/g; let m; while ((m = wordRe.exec(layer)) !== null) { const named = CSS_NAMED_COLORS[m[0].toLowerCase()]; if (named) return { color: { ...named, a: 1 }, start: m.index, end: m.index + m[0].length }; } return null; } // Extract the length values of a shadow layer in declaration order, with the // color token removed so its components aren't misread as lengths. Handles // computed-style px values AND authored unitless zeros ("0 0 20px"); rem/em // approximate at 16px. Result order is offset-x, offset-y, blur, [spread]. function extractShadowLengths(layer, colorStart, colorEnd) { const stripped = colorStart != null ? layer.slice(0, colorStart) + ' ' + layer.slice(colorEnd) : layer; const vals = []; const re = /(-?\d*\.?\d+)(px|rem|em)?/g; let m; while ((m = re.exec(stripped)) !== null) { let v = parseFloat(m[1]); if (m[2] === 'rem' || m[2] === 'em') v *= 16; vals.push(v); } return vals; } function checkGlow(opts) { const { boxShadow, textShadow, effectiveBg } = opts; const onDarkBg = effectiveBg ? relativeLuminance(effectiveBg) < 0.1 : false; // Scan one shadow list. Two glow tells, in any color format: // 1. Zero-offset chromatic halo (0 0 Npx ) — slop on ANY // background; the light radiates evenly outward, which is never how // real elevation shadows behave. Achromatic zero-offset shadows stay // legal (soft ambient elevation), as do focus rings (blur 0). // 2. Any chromatic shadow with real blur on a dark background — the // classic dark-mode glow accent. const scan = (value, prop) => { if (!value || value === 'none') return null; // Split multiple shadows (commas not inside parentheses) for (const layer of value.split(/,(?![^(]*\))/)) { const colorInfo = findShadowColor(layer); // No color token, or one we can't resolve (unresolved var(), exotic // color space): don't guess — skip rather than false-positive. if (!colorInfo || !colorInfo.color) continue; const color = colorInfo.color; if (!hasChroma(color, 30)) continue; const vals = extractShadowLengths(layer, colorInfo.start, colorInfo.end); // Third value is blur (offset-x, offset-y, blur, [spread]) if (vals.length < 3 || vals[2] <= 4) continue; if (vals[0] === 0 && vals[1] === 0) { return { id: 'dark-glow', snippet: `Zero-offset ${prop} glow (${colorToHex(color)})` }; } if (onDarkBg) { return { id: 'dark-glow', snippet: `Colored ${prop} glow (${colorToHex(color)}) on dark background` }; } } return null; }; const found = scan(boxShadow, 'box-shadow') || scan(textShadow, 'text-shadow'); return found ? [found] : []; } // Collect CSS custom property declarations from raw stylesheet/HTML text. // First declaration wins (:root declarations usually come first); good // enough for the single-level var() resolution the text engines need. function collectCssCustomProps(content) { const map = new Map(); const re = /(--[\w-]+)\s*:\s*([^;{}]+)/g; let m; while ((m = re.exec(content)) !== null) { if (!map.has(m[1])) map.set(m[1], m[2].trim()); } return map; } // Text-level glow scan shared by the regex engine and the page-level HTML // pattern pass. Resolves single-level var() refs against custom properties // collected from the same text, then applies the same two glow tells as // checkGlow: zero-offset chromatic halo (any background) and chromatic // blurred shadow when the page has a dark background. Returns // [{ index, snippet }] — index is the offset of the shadow declaration. // Dark-page heuristic for raw CSS/HTML text: dark hex/rgb literals, Tailwind // dark bg utilities, or a ROOT-scoped (body/html/:root or ) // background that resolves — via var() — to a dark color. The var/modern- // color extension is deliberately root-scoped: a light page with one dark // accent chip must not turn every tinted drop shadow into a "dark page" // signal. Shared by the glow and radial-halo text scanners. function cssTextHasDarkRootBg(content, customProps) { const darkBgRe = /background(?:-color)?\s*:\s*(?:#(?:0[0-9a-f]|1[0-9a-f]|2[0-3])[0-9a-f]{4}\b|#(?:0|1)[0-9a-f]{2}\b|rgb\(\s*(\d{1,2})\s*,\s*(\d{1,2})\s*,\s*(\d{1,2})\s*\))/i; const twDarkBg = /\bbg-(?:gray|slate|zinc|neutral|stone)-(?:9\d{2}|800)\b/; if (darkBgRe.test(content) || twDarkBg.test(content)) return true; const rootScopes = []; const blockRe = /(?:^|[}\s,;>])(?:body|html|:root)\s*(?:,[^{]*)?\{([^}]*)\}/gi; let sm; while ((sm = blockRe.exec(content)) !== null) rootScopes.push(sm[1]); const inlineBody = content.match(/]*\bstyle\s*=\s*"([^"]*)"/i); if (inlineBody) rootScopes.push(inlineBody[1]); for (const scope of rootScopes) { const bgRe = /background(?:-color)?\s*:\s*([^;{}]+)/gi; let bm; while ((bm = bgRe.exec(scope)) !== null) { const c = parseAnyColor(resolveVarRefs(bm[1].trim(), customProps)); if (c && (c.a ?? 1) > 0.5 && relativeLuminance(c) < 0.1) return true; } } return false; } function scanCssTextForGlow(content) { const customProps = collectCssCustomProps(content); const hasDarkBg = cssTextHasDarkRootBg(content, customProps); const results = []; const shadowRe = /\b(box-shadow|text-shadow)\s*:\s*([^;{}]+)/gi; let m; while ((m = shadowRe.exec(content)) !== null) { const prop = m[1].toLowerCase(); const value = resolveVarRefs(m[2].trim(), customProps); for (const layer of value.split(/,(?![^(]*\))/)) { const colorInfo = findShadowColor(layer); if (!colorInfo || !colorInfo.color || !hasChroma(colorInfo.color, 30)) continue; const vals = extractShadowLengths(layer, colorInfo.start, colorInfo.end); if (vals.length < 3 || vals[2] <= 4) continue; const zeroOffset = vals[0] === 0 && vals[1] === 0; if (!zeroOffset && !hasDarkBg) continue; results.push({ index: m.index, snippet: zeroOffset ? `Zero-offset ${prop} glow (${colorToHex(colorInfo.color)})` : `Colored ${prop} glow (${colorToHex(colorInfo.color)}) on dark page`, }); break; // one finding per declaration } } return results; } // Decorative grid or line-field backgrounds drawn with hairline // linear-gradient layers tiled by a fixed pixel cell. Shared by the HTML // pattern pass and the regex source engine so standalone CSS, component // styles, and inline styles receive the same coverage. Both signals must // co-occur in one declaration block; unrelated rules must not add up across // the file. Returns [{ index, snippet }], capped at one finding per source to // match the page-level HTML check's existing behavior. function scanCssTextForGridBackground(content) { const hairlineRe = /\b\d{1,3}px\s*,\s*transparent\s+\d{1,3}px/gi; const invertedHairlineRe = /transparent\s+calc\(100%\s*-\s*\d{1,3}px\)/gi; const sizeDeclPxRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\b/i; const sizeDeclPxPairRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\s+\d{1,3}px/i; const shorthandPxAnyRe = /\/\s*\d{1,3}px\b/; const shorthandPxPairRe = /\/\s*\d{1,3}px\s+\d{1,3}px/; const bgDeclRe = /\bbackground(?:-image)?\s*:\s*([^;{}"']*)/gi; const blockRe = /\{([^{}]*)\}|style\s*=\s*"([^"]*)"|style\s*=\s*'([^']*)'/gi; let blk; while ((blk = blockRe.exec(content)) !== null) { const block = blk[1] || blk[2] || blk[3] || ''; let hairlineCount = 0; let bgJoined = ''; let bm; bgDeclRe.lastIndex = 0; while ((bm = bgDeclRe.exec(block)) !== null) { hairlineCount += (bm[1].match(hairlineRe) || []).length; hairlineCount += (bm[1].match(invertedHairlineRe) || []).length; bgJoined += `${bm[1]};`; } if (hairlineCount === 0) continue; const hasPxCell = sizeDeclPxRe.test(block) || shorthandPxAnyRe.test(bgJoined); const hasPxPairCell = sizeDeclPxPairRe.test(block) || shorthandPxPairRe.test(bgJoined); if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) { return [{ index: blk.index, snippet: hairlineCount >= 2 ? 'two-axis grid-line gradient background' : 'px-tiled hairline line-field background', }]; } } return []; } // Decorative chromatic halo drawn as a radial-gradient background on a dark // page: a saturated center stop dissolving to transparent. The gradient // sibling of the dark-glow shadow tell. Mechanical gates, in order: // * page has a dark root background (shared heuristic with the glow scan) // * declaration has no url() layer (photographic imagery is exempt) // * the gradient's first color stop is chromatic (RGB spread >= 24) and // visible (alpha >= 0.7 — deliberately translucent light-scene washes // composite with content instead of painting a flat halo, and stay legal) // * the gradient's last stop is transparent / near-zero alpha // * no small pixel-sized stop positions (<= 24px = dot/texture patterns) // * not a repeating-* gradient // Achromatic vignettes fail the chroma gate; panel sheens that fade to an // opaque surface color fail the transparent-end gate. function scanCssTextForRadialHalo(content) { const customProps = collectCssCustomProps(content); if (!cssTextHasDarkRootBg(content, customProps)) return []; const findings = []; const seen = new Set(); const declRe = /background(?:-image)?\s*:\s*([^;{}]+)/gi; let m; while ((m = declRe.exec(content)) !== null) { const value = resolveVarRefs(m[1].trim(), customProps); if (/url\s*\(/i.test(value)) continue; const gradRe = /(repeating-)?radial-gradient\(/gi; let g; while ((g = gradRe.exec(value)) !== null) { if (g[1]) continue; // repeating-* = pattern, not halo // Balanced-paren capture of the gradient arguments. let depth = 0, end = -1; const open = value.indexOf('(', g.index); for (let i = open; i < value.length; i++) { if (value[i] === '(') depth++; else if (value[i] === ')') { depth--; if (depth === 0) { end = i; break; } } } if (end < 0) break; const args = splitTopLevelCommas(value.slice(open + 1, end)); if (args.length < 2) continue; // Optional prelude (shape / size / `at `) carries no color. const colorTokenRe = /(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color-mix)\([^)]*(?:\([^)]*\))?[^)]*\)|#[0-9a-f]{3,8}\b|\btransparent\b/i; const stops = args.filter(a => colorTokenRe.test(a)); if (stops.length < 2) continue; // Dot/texture exemption: px-sized stop positions mean a repeating // background-size pattern, not a page-scale halo. const pxStop = stops.some(s => { const pm = s.match(/(-?[\d.]+)px\b/); return pm && Math.abs(parseFloat(pm[1])) <= 24; }); if (pxStop) continue; const first = stops[0].match(colorTokenRe); const last = stops[stops.length - 1].match(colorTokenRe); if (!first || !last) continue; const lastColor = /^transparent$/i.test(last[0]) ? { r: 0, g: 0, b: 0, a: 0 } : parseAnyColor(last[0]); if (!lastColor || (lastColor.a ?? 1) > 0.05) continue; const firstColor = /^transparent$/i.test(first[0]) ? null : parseAnyColor(first[0]); if (!firstColor) continue; if ((firstColor.a ?? 1) < 0.7) continue; const spread = Math.max(firstColor.r, firstColor.g, firstColor.b) - Math.min(firstColor.r, firstColor.g, firstColor.b); if (spread < 24) continue; const snippet = `radial-gradient halo (${colorToHex(firstColor)} → transparent) on dark page`; if (seen.has(snippet)) continue; seen.add(snippet); findings.push({ index: m.index, snippet }); } } return findings; } // --------------------------------------------------------------------------- // Text-level CSS rule-block scanners (pseudo-element stripes, pulsing dots) // --------------------------------------------------------------------------- // Iterate `selector { declarations }` pairs in raw CSS/HTML text. The block // body excludes braces, so nested structures (@media, @keyframes) naturally // yield their innermost rules with the innermost selector text. Callers // create the regex locally — a shared /g instance is not re-entrant. const CSS_RULE_BLOCK_SOURCE = String.raw`([^{};]+)\{([^{}]*)\}`; // Parse a declaration block into a prop → value map (last declaration wins, // approximating the cascade inside one block). Values keep their raw text // with any !important suffix stripped. function parseCssDeclBlock(block) { const decls = new Map(); for (const part of String(block || '').split(';')) { const idx = part.indexOf(':'); if (idx <= 0) continue; const prop = part.slice(0, idx).trim().toLowerCase(); const value = part.slice(idx + 1).replace(/\s*!important\s*$/i, '').trim(); if (prop && value) decls.set(prop, value); } return decls; } function cssLengthToPx(value) { const m = String(value || '').trim().match(/^(-?[\d.]+)(px|rem|em)$/i); if (!m) return null; const n = parseFloat(m[1]); return m[2].toLowerCase() === 'px' ? n : n * 16; } function isZeroOffset(value) { return value != null && /^-?0(?:px|%|rem|em)?$/.test(String(value).trim()); } // Side-tab variant: the accent stripe drawn as an absolutely-positioned // ::before/::after pseudo-element (narrow colored box hugging a vertical // edge) instead of a border-left/right. The element-level border checks // never see it — pseudo-elements aren't part of the DOM the cascade walks — // so this scans stylesheet text directly, mirroring the border rule's // gates: >= 3px thick, chromatic fill, full height against a side edge. function scanCssTextForPseudoStripe(rawContent) { // Blank comment bodies byte-for-byte so commented-out rules are not // scanned as live CSS and every rule keeps its source offset (each // finding carries `index` so line-based callers can attribute it and // line-scoped inline ignores can match). const content = String(rawContent || '').replace(/\/\*[\s\S]*?\*\//g, (block) => block.replace(/[^\n]/g, ' ')); const customProps = collectCssCustomProps(content); const findings = []; const seen = new Set(); const ruleRe = new RegExp(CSS_RULE_BLOCK_SOURCE, 'g'); let m; while ((m = ruleRe.exec(content)) !== null) { const selector = m[1].trim(); if (!/::?(?:before|after)\b/i.test(selector)) continue; // Keep the border rule's prose exemptions (blockquote bars etc.). if (/\b(?:blockquote|pre|code|nav|hr)\b/i.test(selector)) continue; const decls = parseCssDeclBlock(m[2]); const position = decls.get('position'); if (position !== 'absolute' && position !== 'fixed') continue; const widthPx = cssLengthToPx(resolveVarRefs( decls.get('width') || decls.get('inline-size') || '', customProps)); const heightPx = cssLengthToPx(resolveVarRefs( decls.get('height') || decls.get('block-size') || '', customProps)); const verticalCandidate = widthPx != null && widthPx >= 3 && widthPx <= 12; // Horizontal variant (top/bottom stripe) carries extra exemptions: // link/button underline affordances, selected-state indicators // (aria-selected="true", aria-current, active/current/selected class // hints), and state-conditional (:hover/:focus/...) affordances are // not stripes. Tab-strip membership alone ([role=tab], .tabs, bare // [aria-selected]) is NOT exempt — a stripe on every tab in the // group is decoration; only the selected item's underline stays. const horizontalCandidate = heightPx != null && heightPx >= 3 && heightPx <= 12 && !/(?:^|[\s>+~,(])(?:a|button|summary|tr|td|th|table|li)(?![\w-])/i.test(selector) && !/\[aria-selected\s*[*^$|~]?=\s*["']?true/i.test(selector) && !/\[aria-current(?!\s*[*^$|~]?=\s*["']?false)/i.test(selector) && !/(?:^|[\s._[-])(?:active|current|selected|btn[\w-]*|button[\w-]*|link[\w-]*)(?![\w])/i.test(selector) && !/:(?:hover|focus|focus-visible|focus-within|active|checked)\b/i.test(selector); if (!verticalCandidate && !horizontalCandidate) continue; // Resolve edge offsets, letting an `inset` shorthand fill the gaps. const offsets = { top: decls.get('top'), right: decls.get('right'), bottom: decls.get('bottom'), left: decls.get('left'), }; const inset = decls.get('inset'); if (inset) { const p = inset.split(/\s+/); const [t, r, b, l] = p.length === 1 ? [p[0], p[0], p[0], p[0]] : p.length === 2 ? [p[0], p[1], p[0], p[1]] : p.length === 3 ? [p[0], p[1], p[2], p[1]] : p; if (offsets.top == null) offsets.top = t; if (offsets.right == null) offsets.right = r; if (offsets.bottom == null) offsets.bottom = b; if (offsets.left == null) offsets.left = l; } if (offsets.left == null) offsets.left = decls.get('inset-inline-start'); if (offsets.right == null) offsets.right = decls.get('inset-inline-end'); const heightValue = String(resolveVarRefs( decls.get('height') || decls.get('block-size') || '', customProps)).trim(); const widthValue = String(resolveVarRefs( decls.get('width') || decls.get('inline-size') || '', customProps)).trim(); let edge = null; let thicknessPx = null; if (verticalCandidate) { // Full-height stripes hug both corners; the "floating" variant backs // off each end by a small inset (top/bottom a few px) so the bar // clears the card's corners. Both read as the same side-tab accent — // corner treatment is styling, not a different pattern. const topPx = cssLengthToPx(resolveVarRefs(String(offsets.top ?? ''), customProps)); const bottomPx = cssLengthToPx(resolveVarRefs(String(offsets.bottom ?? ''), customProps)); const fullHeight = (isZeroOffset(offsets.top) && isZeroOffset(offsets.bottom)) || /^100(?:\.0*)?%$/.test(heightValue) || (topPx != null && bottomPx != null && topPx >= 0 && topPx <= 20 && bottomPx >= 0 && bottomPx <= 20); if (fullHeight) { edge = isZeroOffset(offsets.left) ? 'left' : isZeroOffset(offsets.right) ? 'right' : null; thicknessPx = widthPx; } } if (!edge && horizontalCandidate) { const fullWidth = (isZeroOffset(offsets.left) && isZeroOffset(offsets.right)) || /^100(?:\.0*)?%$/.test(widthValue); if (fullWidth) { edge = isZeroOffset(offsets.top) ? 'top' : isZeroOffset(offsets.bottom) ? 'bottom' : null; thicknessPx = heightPx; } } if (!edge) continue; // Chromatic fill only — a neutral hairline divider is not an accent // stripe. Unresolvable colors err toward detection, matching the // border rule's unknown-format default. const bg = String(resolveVarRefs( decls.get('background-color') || decls.get('background') || '', customProps)).trim(); if (!bg || /^(?:none|transparent|inherit|initial|unset|currentcolor)$/i.test(bg)) continue; const colorToken = bg.match(/(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\([^)]*\)|#[0-9a-f]{3,8}\b/i); const parsed = parseAnyColor(colorToken ? colorToken[0] : bg); if (parsed) { if ((parsed.a ?? 1) < 0.1) continue; const spread = Math.max(parsed.r, parsed.g, parsed.b) - Math.min(parsed.r, parsed.g, parsed.b); if (spread < 30) continue; } else if (/^(?:white|black|gray|grey|silver)$/i.test(bg)) { continue; } if (seen.has(selector)) continue; seen.add(selector); // The selector group absorbs whitespace trailing the previous rule; // advance past it so `index` points at the selector itself. const selectorStart = m.index + (m[1].length - m[1].trimStart().length); findings.push({ id: 'side-tab', snippet: `${selector} — absolute ${thicknessPx}px pseudo-element stripe (${edge}: 0)`, index: selectorStart, }); } return findings; } // Side-tab stripe drawn as a single-edge inset box-shadow // (x or y offset 3-12px, other axis 0, no blur/spread, chromatic color): // paints a bar along one edge with no border property involved, so the // element-level border checks never see it. Selection-state indicators // are exempt — an inset stripe on [aria-current] / .active / [role=tab] // marks the selected item; the same stripe unconditionally on every item // is decoration and flags. function scanCssTextForInsetStripe(content) { const customProps = collectCssCustomProps(content); const findings = []; const seen = new Set(); const ruleRe = new RegExp(CSS_RULE_BLOCK_SOURCE, 'g'); let m; while ((m = ruleRe.exec(content)) !== null) { const selector = m[1].trim(); // Selection-state contexts: current-item markers and interaction // states. Tab-strip membership alone ([role=tab], .tabs, bare // [aria-selected]) is NOT exempt — a stripe on every tab in the // group is decoration; only the selected item's indicator stays. if (/:(?:hover|focus|focus-visible|focus-within|active|checked|target)\b/i.test(selector)) continue; if (/\[aria-selected\s*[*^$|~]?=\s*["']?true/i.test(selector)) continue; if (/\[aria-current(?!\s*[*^$|~]?=\s*["']?false)/i.test(selector)) continue; if (/(?:^|[\s._[-])(?:active|current|selected)(?![\w])/i.test(selector)) continue; // Structural tags where a single-edge inset shadow is depth/quoting, // not an accent stripe. if (/(?:^|[\s>+~,(])(?:button|hr|tr|td|th|table|blockquote|pre|code)(?![\w-])/i.test(selector)) continue; const decls = parseCssDeclBlock(m[2]); const shadow = decls.get('box-shadow'); if (!shadow || !/\binset\b/i.test(shadow)) continue; // Narrow fixed-width elements (logo marks, icon glyphs) use inset // fills as artwork, not edge stripes. Stripe targets — cards, badges, // menu items — are wider or leave width to layout. const declaredWidth = cssLengthToPx(resolveVarRefs(decls.get('width') || decls.get('inline-size') || '', customProps)); if (declaredWidth != null && declaredWidth <= 40) continue; const value = resolveVarRefs(shadow, customProps); for (const layer of value.split(/,(?![^(]*\))/)) { if (!/\binset\b/i.test(layer)) continue; const colorInfo = findShadowColor(layer); // Unresolvable colors (currentColor, external vars): don't guess. if (!colorInfo || !colorInfo.color) continue; const c = colorInfo.color; if ((c.a ?? 1) < 0.1) continue; const chroma = Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b); if (chroma < 30) continue; const vals = extractShadowLengths(layer, colorInfo.start, colorInfo.end); const x = vals[0] || 0, y = vals[1] || 0, blur = vals[2] || 0, sp = vals[3] || 0; if (blur !== 0 || sp !== 0) continue; const ax = Math.abs(x), ay = Math.abs(y); const isStripe = (ax >= 3 && ax <= 12 && ay === 0) || (ay >= 3 && ay <= 12 && ax === 0); if (!isStripe) continue; if (seen.has(selector)) break; seen.add(selector); const edge = ay === 0 ? (x > 0 ? 'left' : 'right') : (y > 0 ? 'top' : 'bottom'); findings.push({ id: 'side-tab', snippet: `${selector} — inset box-shadow ${ay === 0 ? ax : ay}px stripe (${edge})`, }); break; } } return findings; } // Collect @keyframes names whose body travels horizontally — the marquee // loop. X travel is measured across every translateX/translate/translate3d // X component in the body: a centered element animating something else // keeps a constant -50% X (zero travel) and never qualifies, while a // ticker moves from its resting position to a large offset. Keyframes // with a single X sample that also vary scale/opacity read as pulses or // breathes, not marquees. function collectMarqueeKeyframes(content) { const names = new Set(); const re = /@(?:-webkit-)?keyframes\s+([\w-]+)\s*\{/g; let m; while ((m = re.exec(content)) !== null) { let depth = 1; let i = re.lastIndex; while (i < content.length && depth > 0) { const ch = content.charCodeAt(i); if (ch === 0x7b /* { */) depth++; else if (ch === 0x7d /* } */) depth--; i++; } const body = content.slice(re.lastIndex, Math.max(re.lastIndex, i - 1)); re.lastIndex = i; // Only percentage travel qualifies: a content marquee translates by a // fraction of its own (unknown) track width, so generated tickers use // -50% / -100%. Pixel-travel loops are bespoke product animations — // sweeping playheads, progress indicators — not marquees. const pct = []; const xRe = /\btranslate(?:X|3d)?\(\s*(-?[\d.]+)%/gi; let xm; while ((xm = xRe.exec(body)) !== null) pct.push(parseFloat(xm[1])); if (pct.length === 0) continue; if (pct.length === 1 && /\bscale\(|\bopacity\s*:/i.test(body)) continue; // Implicit start: a lone declared X animates from the element's // resting position, so its magnitude is the travel. const travelPct = pct.length > 1 ? Math.max(...pct) - Math.min(...pct) : Math.abs(pct[0]); if (travelPct >= 20) names.add(m[1]); } return names; } // Auto-scrolling marquee: a element, or an infinite animation // bound to a keyframe loop that travels a large horizontal distance. // Rotation/opacity animations never qualify (no X travel); JS-driven // carousels with user controls have no infinite CSS X-loop to match. // `content` is CSS-bearing text; `markup` (defaulting to the same string // for single-corpus callers) is where the tag itself lives. function scanCssTextForMarquee(content, markup = content) { const findings = []; if (/ element' }); } const marqueeKeyframes = collectMarqueeKeyframes(content); if (marqueeKeyframes.size === 0) return findings; const seen = new Set(); const ruleRe = new RegExp(CSS_RULE_BLOCK_SOURCE, 'g'); let m; while ((m = ruleRe.exec(content)) !== null) { const selector = m[1].trim(); const decls = parseCssDeclBlock(m[2]); for (const name of infiniteAnimationNames(decls)) { if (!marqueeKeyframes.has(name)) continue; const key = `${selector} ${name}`; if (seen.has(key)) continue; seen.add(key); findings.push({ id: 'marquee', snippet: `${selector} — infinite horizontal loop animation "${name}"` }); } } return findings; } // Collect @keyframes names and whether each one reads as a "pulse" — // i.e. it varies opacity, scale, or box-shadow. Rotation-only keyframes // (spinners) are explicitly not pulses. function collectPulseKeyframes(content) { const map = new Map(); const re = /@(?:-webkit-)?keyframes\s+([\w-]+)\s*\{/g; let m; while ((m = re.exec(content)) !== null) { let depth = 1; let i = re.lastIndex; while (i < content.length && depth > 0) { const ch = content.charCodeAt(i); if (ch === 0x7b /* { */) depth++; else if (ch === 0x7d /* } */) depth--; i++; } const body = content.slice(re.lastIndex, Math.max(re.lastIndex, i - 1)); const pulses = /\bopacity\s*:/i.test(body) || /\bbox-shadow\s*:/i.test(body) || /\btransform\s*:[^;{}]*\bscale/i.test(body); if (!map.has(m[1]) || pulses) map.set(m[1], pulses); re.lastIndex = i; } return map; } const ANIMATION_VALUE_KEYWORDS = new Set([ 'ease', 'ease-in', 'ease-out', 'ease-in-out', 'linear', 'infinite', 'alternate', 'alternate-reverse', 'normal', 'reverse', 'none', 'forwards', 'backwards', 'both', 'running', 'paused', 'step-start', 'step-end', 'inherit', 'initial', 'unset', ]); // Extract animation names that run with iteration-count: infinite from a // declaration block (shorthand layers or animation-name + iteration-count). function infiniteAnimationNames(decls) { const out = []; const shorthand = decls.get('animation'); if (shorthand) { for (const layer of shorthand.split(/,(?![^(]*\))/)) { if (!/\binfinite\b/i.test(layer)) continue; const name = layer.split(/\s+/).find(t => /^[a-zA-Z_-][\w-]*$/.test(t) && !ANIMATION_VALUE_KEYWORDS.has(t.toLowerCase())); if (name) out.push(name); } } const nameDecl = decls.get('animation-name'); if (nameDecl && /\binfinite\b/i.test(decls.get('animation-iteration-count') || '')) { for (const raw of nameDecl.split(',')) { const t = raw.trim(); if (t && t.toLowerCase() !== 'none') out.push(t); } } return out; } function isRoundDotRadius(radiusValue, w, h) { if (!radiusValue) return false; const first = String(radiusValue).trim().split(/\s+/)[0]; const pct = first.match(/^([\d.]+)%$/); if (pct) return parseFloat(pct[1]) >= 40; const px = cssLengthToPx(first); if (px == null) return false; return px >= 999 || px >= 0.4 * Math.min(w, h); } // Remove @media blocks whose condition is prefers-reduced-motion: reduce. // Those blocks describe the accessibility fallback, not the default // experience that ships — an `animation: none` reset inside one must not // mask the resting-state animation the page plays for everyone else. function stripReducedMotionBlocks(content) { const re = /@media[^{]*prefers-reduced-motion\s*:\s*reduce[^{]*\{/gi; let out = ''; let last = 0; let m; while ((m = re.exec(content)) !== null) { let depth = 1; let i = re.lastIndex; while (i < content.length && depth > 0) { const ch = content.charCodeAt(i); if (ch === 0x7b /* { */) depth++; else if (ch === 0x7d /* } */) depth--; i++; } out += content.slice(last, m.index); last = i; re.lastIndex = i; } return out + content.slice(last); } // Source-index ranges of
and