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 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
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 && fontSize >= 9; if (!isStyledControl) 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) { // 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; } // 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 ) — 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 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(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, 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); 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(); // 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. function scanCssTextForMarquee(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); } // Small circular indicator bound to an infinite pulse animation — the // decorative "live" dot. Gates: tiny (<= 16px square-ish), round // (border-radius >= 40% or pill values), and an infinite animation whose // keyframes vary opacity/scale/box-shadow (or a pulse/blink/ping name when // the keyframes aren't in the scanned text). Rotation-only animations // (spinners) never flag. function scanCssTextForPulsingDot(content) { const customProps = collectCssCustomProps(content); const keyframes = collectPulseKeyframes(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(); const decls = parseCssDeclBlock(m[2]); const names = infiniteAnimationNames(decls); if (names.length === 0) continue; const pulseName = names.find(n => { const known = keyframes.get(n); if (known != null) return known; return /pulse|blink|ping/i.test(n); }); if (!pulseName) continue; const w = cssLengthToPx(resolveVarRefs( decls.get('width') || decls.get('inline-size') || '', customProps)); const h = cssLengthToPx(resolveVarRefs( decls.get('height') || decls.get('block-size') || '', customProps)); if (w == null || h == null || w < 2 || h < 2 || w > 16 || h > 16) continue; const radius = resolveVarRefs(decls.get('border-radius') || '', customProps); if (!isRoundDotRadius(radius, w, h)) continue; if (seen.has(selector)) continue; seen.add(selector); findings.push({ id: 'pulsing-dot', snippet: `${selector} — ${w}x${h}px dot with infinite "${pulseName}" animation`, }); } // Tailwind utilities: animate-ping / animate-pulse on a tiny rounded-full // element declared entirely in the class attribute. const classRe = /class\s*=\s*(?:"([^"]*)"|'([^']*)')/gi; let cm; while ((cm = classRe.exec(content)) !== null) { const cls = cm[1] || cm[2] || ''; const anim = cls.match(/\banimate-(ping|pulse)\b/); if (!anim) continue; if (!/\brounded-full\b/.test(cls)) continue; if (!/\b(?:w|h|size)-(?:1|1\.5|2|2\.5|3|3\.5|4)\b/.test(cls)) continue; const key = `tw:${cls}`; if (seen.has(key)) continue; seen.add(key); findings.push({ id: 'pulsing-dot', snippet: `animate-${anim[1]} on tiny rounded-full element`, }); } return findings; } /** * Regex-on-HTML checks shared between browser and Node page-level detection. * These don't need DOM access, just the raw HTML string. */ function checkHtmlPatterns(html) { const findings = []; // --- Color --- // AI color palette: purple/violet const purpleHexRe = /#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9|6366f1|764ba2|667eea)\b/gi; if (purpleHexRe.test(html)) { const purpleTextRe = /(?:(?:^|;)\s*color\s*:\s*(?:.*?)(?:#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9))|gradient.*?#(?:7c3aed|8b5cf6|a855f7|764ba2|667eea))/gi; if (purpleTextRe.test(html)) { findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected' }); } } // Gradient text (background-clip: text + gradient) const gradientRe = /(?:-webkit-)?background-clip\s*:\s*text/gi; let gm; while ((gm = gradientRe.exec(html)) !== null) { const start = Math.max(0, gm.index - 200); const context = html.substring(start, gm.index + gm[0].length + 200); if (/gradient/i.test(context)) { findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' }); break; } } if (/\bbg-clip-text\b/.test(html) && /\bbg-gradient-to-/.test(html)) { findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' }); } // --- Borders --- // Side-tab accent stripe drawn as an absolutely-positioned pseudo-element // (no border property involved, so the element-level border checks and // the border-left regexes never see it). findings.push(...scanCssTextForPseudoStripe(html)); // Side-tab accent stripe drawn as a single-edge inset box-shadow. findings.push(...scanCssTextForInsetStripe(html)); // --- Layout --- // Monotonous spacing const spacingValues = []; const spacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*(\d+)px/gi; let sm; while ((sm = spacingRe.exec(html)) !== null) { const v = parseInt(sm[1], 10); if (v > 0 && v < 200) spacingValues.push(v); } const gapRe = /gap\s*:\s*(\d+)px/gi; while ((sm = gapRe.exec(html)) !== null) { spacingValues.push(parseInt(sm[1], 10)); } const twSpaceRe = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+)\b/g; while ((sm = twSpaceRe.exec(html)) !== null) { spacingValues.push(parseInt(sm[1], 10) * 4); } const remSpacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*([\d.]+)rem/gi; while ((sm = remSpacingRe.exec(html)) !== null) { const v = Math.round(parseFloat(sm[1]) * 16); if (v > 0 && v < 200) spacingValues.push(v); } const roundedSpacing = spacingValues.map(v => Math.round(v / 4) * 4); if (roundedSpacing.length >= 10) { const counts = {}; for (const v of roundedSpacing) counts[v] = (counts[v] || 0) + 1; const maxCount = Math.max(...Object.values(counts)); const dominantPct = maxCount / roundedSpacing.length; const unique = [...new Set(roundedSpacing)].filter(v => v > 0); if (dominantPct > 0.6 && unique.length <= 3) { const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0]; findings.push({ id: 'monotonous-spacing', snippet: `~${dominant}px used ${maxCount}/${roundedSpacing.length} times (${Math.round(dominantPct * 100)}%)`, }); } } // --- Motion --- // Bounce/elastic animation names const bounceRe = /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi; const bounceMatch = bounceRe.exec(html); if (bounceMatch) { const animationToken = bounceMatch[1] .split(/[,\s]+/) .find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part)); findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}` }); } // Overshoot cubic-bezier const bezierRe = /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g; let bm; while ((bm = bezierRe.exec(html)) !== null) { const y1 = parseFloat(bm[2]), y2 = parseFloat(bm[4]); if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) { findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})` }); break; } } // Layout property transitions const transRe = /transition(?:-property)?\s*:\s*([^;{}]+)/gi; let tm; while ((tm = transRe.exec(html)) !== null) { const val = tm[1].toLowerCase(); if (/\ball\b/.test(val)) continue; const found = val.match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi); if (found) { findings.push({ id: 'layout-transition', snippet: `transition: ${found.join(', ')}` }); break; } } // Pulsing status dots (tiny circular elements on infinite pulse animations) findings.push(...scanCssTextForPulsingDot(html)); // Auto-scrolling marquees ( or infinite horizontal loop animations) findings.push(...scanCssTextForMarquee(html)); // --- Dark glow / chromatic halo shadows --- const glowHits = scanCssTextForGlow(html); if (glowHits.length > 0) { findings.push({ id: 'dark-glow', snippet: glowHits[0].snippet }); } // Radial-gradient background halo (gradient-drawn sibling of dark-glow) const haloHits = scanCssTextForRadialHalo(html); if (haloHits.length > 0) { findings.push({ id: 'radial-halo', snippet: haloHits[0].snippet }); } // --- Provider tells (gated): repeating-gradient stripes (GPT) --- if (/repeating-(?:linear|radial|conic)-gradient\s*\(/i.test(html)) { findings.push({ id: 'repeating-stripes-gradient', snippet: 'repeating-gradient decorative stripes' }); } // --- Provider tells (gated): two-axis grid-line background (Codex/GPT) --- // The Codex grid tell is two hairline `linear-gradient(... 1px, // transparent 1px)` layers (one per axis) tiled by a repeating // `background-size` cell. Both signals must co-occur in the SAME style block // (a CSS rule body or one inline `style="..."`): two hairline stops WITHOUT a // tiling background-size is a fixed crosshair, not a grid, and a single // hairline is a legitimate ruled line. Scoping to one block also stops // unrelated single-axis rules on separate elements from adding up across the // page. Count hairlines only inside `background`/`background-image` values so // a hairline in an unrelated property (mask-image, border-image) can't stand // in for the second axis. Colors like `oklch(96% 0.012 82 / 0.055)` carry // nested parens, so match the hairline stop directly rather than parsing // whole gradient layers. { // Hairline stop shapes: the classic leading form (` 1px, // transparent 1px`) and the inverted end-of-tile form // (`transparent calc(100% - 1px), 1px`). 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; // Tiling cell: a background-size declaration with px values, or the // background shorthand's `/ ` slot (only matched inside // background values so border-radius slash syntax can't stand in). 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(html)) !== 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); // Two hairline layers + any px tile = the classic two-axis grid. // A single hairline layer only counts when tiled by a px pair cell // (e.g. `/ 40px 40px`) — a page-scale repeating line field. Single // hairlines tiled by percentage cells (`background-size: 25% 100%`) // are structural rules on data-viz tracks/graphs and stay legal. if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) { findings.push({ id: 'codex-grid-background', snippet: hairlineCount >= 2 ? 'two-axis grid-line gradient background' : 'px-tiled hairline line-field background', }); break; } } } // --- Provider tells (gated): "X theater" framing copy (GPT) --- // Lives here (regex-on-HTML) rather than in the text-content analyzers so it // runs in the bundled browser path too, not just the CLI/static path. { const bodyText = html .replace(/]*>[\s\S]*?<\/script>/gi, ' ') .replace(/]*>[\s\S]*?<\/style>/gi, ' ') .replace(/<[^>]+>/g, ' '); const tm = /\b(\w+)\s+theater\b/i.exec(bodyText); if (tm) findings.push({ id: 'theater-slop-phrase', snippet: `"${tm[0].trim()}"` }); } // --- Provider tells (gated): image hover transform (Gemini) --- // A CSS `img...:hover { transform: ... }` rule, or a Tailwind hover:scale / // hover:rotate / hover:translate utility on an . Each distinct // mechanism is its own finding. const imgHoverCss = /\bimg\b[^,{}]*:hover\b[^{}]*\{[^}]*\btransform\s*:\s*(?:scale|rotate|translate|matrix|skew)/i; if (imgHoverCss.test(html)) { findings.push({ id: 'image-hover-transform', snippet: 'img:hover { transform } rule' }); } const imgTagRe = /]*\bclass\s*=\s*"([^"]*)"/gi; let im; while ((im = imgTagRe.exec(html)) !== null) { if (/\bhover:(?:scale|rotate|translate|skew)-/.test(im[1])) { findings.push({ id: 'image-hover-transform', snippet: 'Tailwind hover transform on ' }); } } return findings; } // ─── Section 4: resolveBackground (unified) ───────────────────────────────── // Read the element's own background color, computed-style first, with a // jsdom-friendly fallback that parses the inline `background:` shorthand // from the raw style attribute. jsdom (~v29) does not decompose the // shorthand into `backgroundColor`, so without this fallback the CLI silently // returns null for any element styled via `background: rgb(...)` or // `background: #abc`. Real browsers always decompose, so the fallback is // a no-op there. function readOwnBackgroundColor(el, computedStyle) { // Real browsers keep wide-gamut/computed color functions (oklch(), oklab(), // color-mix() results) in getComputedStyle output, which plain parseRgb // misses — a flat oklch button background would silently skip every // contrast check without the parseAnyColor fallback. const bg = parseRgb(computedStyle.backgroundColor) || parseAnyColor(computedStyle.backgroundColor); if (DETECTOR_IS_BROWSER || (bg && bg.a >= 0.1)) return bg; const rawStyle = el.getAttribute?.('style') || ''; const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i); const inlineBg = bgMatch ? bgMatch[1].trim() : ''; if (!inlineBg) return bg; if (/gradient/i.test(inlineBg) || /url\s*\(/i.test(inlineBg)) return bg; const fromRgb = parseRgb(inlineBg); if (fromRgb) return fromRgb; const hexMatch = inlineBg.match(/#([0-9a-f]{6}|[0-9a-f]{3})\b/i); if (hexMatch) { const h = hexMatch[1]; if (h.length === 6) { return { r: parseInt(h.slice(0, 2), 16), g: parseInt(h.slice(2, 4), 16), b: parseInt(h.slice(4, 6), 16), a: 1 }; } return { r: parseInt(h[0] + h[0], 16), g: parseInt(h[1] + h[1], 16), b: parseInt(h[2] + h[2], 16), a: 1 }; } return bg; } function resolveBackground(el, win, customPropMap) { let current = el; // Translucent layers (0.1 < a < 1) found on the way down to an opaque // base. A browser composites these over the base; the old behavior // either returned them as-if-opaque (browser mode) or skipped them // entirely (static mode), both of which misstate the effective surface // for contrast checks (e.g. `background: color-mix(in oklab, var(--hot) // 16%, transparent)` chips on dark pages). const overlays = []; const flatten = (base) => { let acc = base; for (let i = overlays.length - 1; i >= 0; i--) acc = compositeColorOver(overlays[i], acc); return acc; }; while (current && current.nodeType === 1) { const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current); const bgImage = style.backgroundImage || ''; const hasGradientOrUrl = bgImage && bgImage !== 'none' && (/gradient/i.test(bgImage) || /url\s*\(/i.test(bgImage)); // Try the solid bg-color FIRST. If the element has both a solid color // and a gradient/url overlay (a common pattern: `background: var(--paper) // radial-gradient(...)` for paper-grain texture), the solid color is the // dominant visible surface for contrast purposes; the overlay is // decorative. The old behavior bailed on any gradient ancestor, which // caused massive false-positive contrast findings on grain-textured // body backgrounds. // Real browsers serialize wide-gamut computed values as oklab()/oklch() // (e.g. any color-mix() result), which plain parseRgb misses. let bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor); if (!DETECTOR_IS_BROWSER && (!bg || bg.a < 0.1)) { // jsdom returns literal "var(--X)" / "oklch(...)" strings. Resolve // through customPropMap so Tailwind v4 color tokens become RGB. if (customPropMap) { bg = parseColorResolved(style.backgroundColor, customPropMap); } if (!bg || bg.a < 0.1) { // Inline-style fallback. jsdom doesn't decompose background // shorthand, so colors set via inline style are otherwise invisible. const rawStyle = current.getAttribute?.('style') || ''; const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i); const inlineBg = bgMatch ? bgMatch[1].trim() : ''; if (inlineBg && !/gradient/i.test(inlineBg) && !/url\s*\(/i.test(inlineBg)) { bg = parseColorResolved(inlineBg, customPropMap) || parseAnyColor(inlineBg); } } } if (bg && bg.a > 0.1) { if (bg.a >= 0.99) return flatten(bg); overlays.push(bg); } // No solid bg-color at this level. If THIS level has a gradient/url // with no underlying solid color we can read: // • on body/html: assume white. Body-level gradients are almost // always decorative texture (paper grain, noise) on top of a // solid bg-color the page set via `background: var(--paper)` // shorthand — which jsdom can't decompose into bg-color. The // downstream gradient-stops fallback path produces catastrophic // false positives in this case (gradient noise stops have // accidental browns/blacks that look like card backgrounds). // • on other elements: bail to null and let the caller fall back // to gradient stops (gradient buttons / hero sections are real // bgs worth checking against). if (hasGradientOrUrl) { if (current.tagName === 'BODY' || current.tagName === 'HTML') { return flatten({ r: 255, g: 255, b: 255, a: 1 }); } return null; } current = current.parentElement; } return flatten({ r: 255, g: 255, b: 255, a: 1 }); } // Walk parents looking for a gradient background and return its color stops. // Used as a fallback when resolveBackground() returns null because the // effective background is a gradient (no single solid color to compare against). function resolveGradientStops(el, win) { let current = el; while (current && current.nodeType === 1) { const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current); const bgImage = style.backgroundImage || ''; if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) { const stops = parseGradientColors(bgImage); if (stops.length > 0) return stops; } if (!DETECTOR_IS_BROWSER) { // jsdom doesn't decompose `background:` shorthand — peek at the raw inline style const rawStyle = current.getAttribute?.('style') || ''; const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i); if (bgMatch && /gradient/i.test(bgMatch[1])) { const stops = parseGradientColors(bgMatch[1]); if (stops.length > 0) return stops; } } current = current.parentElement; } return null; } // Parse a single CSS length token to pixels. Accepts "12px", "50%", a // shorthand like "12px 4px" (uses the first value), or empty / null. // Returns the pixel value, or null when the input is unparseable. // Percentages convert against `widthPx` when one is supplied. Without a // usable width (jsdom returns "auto" for many real-world elements, // which parseFloat collapses to 0), fall back to the raw percentage // number so callers gating on `> 0` (border-accent-on-rounded, // isCardLike's hasRadius) still see a positive value, matching the // original parseFloat("50%") === 50 behavior. function parseRadiusToPx(value, widthPx) { if (!value || typeof value !== 'string') return null; const trimmed = value.trim(); if (!trimmed) return null; const first = trimmed.split(/\s+/)[0]; const num = parseFloat(first); if (Number.isNaN(num)) return null; if (/%$/.test(first)) { if (widthPx && widthPx > 0) return (num / 100) * widthPx; return num; } return num; } function resolveBorderRadiusPx(el, style, widthPx, win) { const fromComputed = parseRadiusToPx(style.borderRadius, widthPx); if (fromComputed !== null) return fromComputed; return 0; } // ─── Section 5: Element Adapters ──────────────────────────────────────────── // Browser adapters — call getComputedStyle/getBoundingClientRect on live DOM // Selected-state context for accent stripes. Only an actual selection // marker exempts the stripe as the standard active-item indicator: // aria-selected="true", aria-current (any non-false value), or an // active/current/selected class hint. Tab-strip MEMBERSHIP alone // ([role=tablist]/[role=tab]/.tabs ancestry, aria-selected="false") // deliberately does not — a chromatic stripe repeated on every tab in // the group, or on every menu item, is decoration, not state; the // selected item's own underline stays legal. function isTabContextElement(el) { if (!el) return false; try { if (el.closest?.('[aria-selected="true"], [aria-current]:not([aria-current="false"])')) return true; } catch { /* selector engine differences — fall through to class scan */ } let cur = el, depth = 0; while (cur && cur.nodeType === 1 && depth < 6) { const cls = String(cur.getAttribute?.('class') || cur.className || ''); if (/(?:^|[\s_-])(?:active|current|selected)(?:$|[\s_-])/i.test(cls)) return true; cur = cur.parentElement; depth++; } return false; } function checkElementBordersDOM(el) { const tag = el.tagName.toLowerCase(); if (BORDER_SAFE_TAGS.has(tag)) return []; const rect = el.getBoundingClientRect(); if (rect.width < 20 || rect.height < 20) return []; const style = getComputedStyle(el); const sides = ['Top', 'Right', 'Bottom', 'Left']; const widths = {}, colors = {}; for (const s of sides) { widths[s] = parseFloat(style[`border${s}Width`]) || 0; colors[s] = style[`border${s}Color`] || ''; } const ownBg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor); return checkBorders(tag, widths, colors, parseFloat(style.borderRadius) || 0, { tabContext: isTabContextElement(el), badgeLike: !!(ownBg && (ownBg.a ?? 1) > 0.1), }); } // Browser-side twin of scanCssTextForPseudoStripe. The text scanner reads // stylesheet source, so a stripe whose color only exists at runtime (an // inline per-card custom property, a JS-assigned var) or whose geometry // resolves in layout never matches it. In a real browser the pseudo-element's // computed style carries the actual used color and px geometry — check those // directly. Gates mirror the text scanner: 3-12px thick, chromatic fill, // spanning (nearly) the full edge; corner rounding on the host card is // irrelevant. Exemptions stay narrow: structural/prose tags, real selection // markers (isTabContextElement), and button/link affordances for the // horizontal variant. function checkElementPseudoStripeDOM(el) { const tag = el.tagName.toLowerCase(); if (BORDER_SAFE_TAGS.has(tag) || tag === 'summary') return []; if (el.closest?.('nav, blockquote, pre')) return []; if (!isRenderedForBrowserRule(el)) return []; const rect = el.getBoundingClientRect(); if (rect.width < 40 || rect.height < 20) return []; if (isTabContextElement(el)) return []; const findings = []; for (const which of ['::before', '::after']) { let ps; try { ps = getComputedStyle(el, which); } catch { continue; } if (!ps || ps.content === 'none' || ps.content === '') continue; if (ps.position !== 'absolute' && ps.position !== 'fixed') continue; if ((parseFloat(ps.opacity) || 0) <= 0.01 || ps.display === 'none') continue; const w = parseFloat(ps.width) || 0; const h = parseFloat(ps.height) || 0; if (!(w > 0 && h > 0)) continue; // Used values: for absolutely-positioned boxes the browser resolves // both edge offsets after layout, so left/right (and top/bottom) are // real distances, never "auto". const left = parseFloat(ps.left); const right = parseFloat(ps.right); const top = parseFloat(ps.top); const bottom = parseFloat(ps.bottom); const hugs = (v) => Number.isFinite(v) && v >= -2 && v <= 2; let edge = null; let thickness = null; // Vertical stripe: narrow box spanning (nearly) the full height of the // host, hugging its left or right edge. "Nearly" tolerates the floating // variant that backs off each end by a small inset. if (w >= 3 && w <= 12 && h >= rect.height - 44 && h >= rect.height * 0.5) { edge = hugs(left) ? 'left' : hugs(right) ? 'right' : null; thickness = w; } // Horizontal stripe riding the top or bottom edge. Button/link-styled // hosts keep their underline affordances. if (!edge && h >= 3 && h <= 12 && w >= rect.width - 44 && w >= rect.width * 0.5) { const cls = String(el.getAttribute?.('class') || el.className || ''); if (!/(?:^|[\s_-])(?:btn|button|link)(?:$|[\s\w_-])/i.test(cls)) { edge = hugs(top) ? 'top' : hugs(bottom) ? 'bottom' : null; thickness = h; } } if (!edge) continue; const bg = parseRgb(ps.backgroundColor) || parseAnyColor(ps.backgroundColor); if (!bg || (bg.a ?? 1) < 0.1) continue; if (Math.max(bg.r, bg.g, bg.b) - Math.min(bg.r, bg.g, bg.b) < 30) continue; findings.push({ id: 'side-tab', snippet: `${classSelector(el)}${which} — absolute ${thickness}px pseudo-element stripe (${edge})`, }); } return findings; } function checkElementColorsDOM(el) { const tag = el.tagName.toLowerCase(); // No early SAFE_TAGS bail here — checkColors() does its own gating that // includes the styled-button exception for /