diff --git a/cli/engine/engines/regex/detect-text.mjs b/cli/engine/engines/regex/detect-text.mjs index 1affdda43..ab2ed562a 100644 --- a/cli/engine/engines/regex/detect-text.mjs +++ b/cli/engine/engines/regex/detect-text.mjs @@ -43,23 +43,94 @@ function firstOverusedGoogleFont(text) { return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || ''; } +// CSS named colors whose channels are equal (achromatic). Anything outside +// this set falls through to the format parsers, and an unrecognized spelling +// stays non-neutral so a real accent is never skipped. +const NEUTRAL_COLOR_KEYWORDS = new Set([ + 'transparent', 'currentcolor', + 'black', 'white', 'gray', 'grey', 'silver', + 'dimgray', 'dimgrey', 'darkgray', 'darkgrey', 'lightgray', 'lightgrey', + 'gainsboro', 'whitesmoke', +]); + +function hexChannels(color) { + const long = color.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})(?:[0-9a-f]{2})?$/i); + if (long) return [parseInt(long[1], 16), parseInt(long[2], 16), parseInt(long[3], 16)]; + const short = color.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])(?:[0-9a-f])?$/i); + if (short) return [1, 2, 3].map((i) => parseInt(short[i] + short[i], 16)); + return null; +} + +/** + * Split one box-shadow layer into top-level tokens. + * + * Whitespace inside parens does not separate tokens: `rgb(0 0 0)` and + * `var(--x, 4px)` are each a single value, and splitting them on spaces would + * read their innards as separate lengths. + */ +function tokenizeShadowLayer(layer) { + const tokens = []; + let depth = 0; + let current = ''; + for (const char of String(layer || '')) { + if (char === '(') depth++; + else if (char === ')') depth--; + else if (depth === 0 && /\s/.test(char)) { + if (current) tokens.push(current); + current = ''; + continue; + } + current += char; + } + if (current) tokens.push(current); + return tokens; +} + +function lastMatch(text, re) { + const all = [...String(text || '').matchAll(re)]; + return all.length ? all[all.length - 1] : null; +} + +function isShadowLength(token) { + return /^-?\d*\.?\d+(?:px)?$/i.test(String(token || '')); +} + +/** + * Neutrality test for colors as written in source CSS. + * + * shared/color.mjs's isNeutralColor only parses the computed function forms a + * browser or jsdom emits (rgb/oklch/lab/...) and deliberately reports every + * other spelling as chromatic so an unknown format is never silently skipped. + * That default is wrong for authored CSS, where `#000` and `black` are the + * normal spellings: calling it directly reports a plain black hairline as a + * colored stripe. Handle hex and named neutrals here, then defer. + */ +function isNeutralAuthoredColor(rawColor) { + const c = String(rawColor || '').trim().toLowerCase(); + if (!c) return false; + if (NEUTRAL_COLOR_KEYWORDS.has(c)) return true; + // Modern rgb() takes space-separated channels (`rgb(0 0 0)`). shared/color.mjs + // parses only the comma form a browser's getComputedStyle emits, so authored + // space-separated neutrals fell through it and reported as chromatic — the + // exemption this function exists for, missed. Normalize before delegating. + if (/^rgba?\(/i.test(c)) { + const channels = c.match(/^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/i); + if (channels) { + const values = [1, 2, 3].map((i) => Number(channels[i])); + return (Math.max(...values) - Math.min(...values)) < 30; + } + return isNeutralColor(c); + } + if (/^(?:hsla?|oklch|oklab|lab|lch|hwb)\(/i.test(c)) return isNeutralColor(c); + const channels = hexChannels(c); + if (channels) return (Math.max(...channels) - Math.min(...channels)) < 30; + return false; +} + function isNeutralBorderColor(str) { const m = str.match(/solid\s+((?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color)\([^)]*\)|#[0-9a-f]{3,8}\b|[a-z]+)/i); if (!m) return false; - const c = m[1].toLowerCase(); - if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true; - if (/^(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\(/i.test(c)) return isNeutralColor(c); - const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/); - if (hex) { - const [r, g, b] = [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)]; - return (Math.max(r, g, b) - Math.min(r, g, b)) < 30; - } - const shex = c.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/); - if (shex) { - const [r, g, b] = [parseInt(shex[1] + shex[1], 16), parseInt(shex[2] + shex[2], 16), parseInt(shex[3] + shex[3], 16)]; - return (Math.max(r, g, b) - Math.min(r, g, b)) < 30; - } - return false; + return isNeutralAuthoredColor(m[1]); } const REGEX_MATCHERS = [ @@ -345,12 +416,120 @@ const REGEX_ANALYZERS = [ ]; // --------------------------------------------------------------------------- -// Style block extraction (Vue/Svelte