diff --git a/cli/engine/browser/injected/index.mjs b/cli/engine/browser/injected/index.mjs index 40bc1d155..8d18d8a3a 100644 --- a/cli/engine/browser/injected/index.mjs +++ b/cli/engine/browser/injected/index.mjs @@ -530,7 +530,11 @@ if (IS_BROWSER) { function generateSelector(el) { if (el === document.body) return 'body'; if (el === document.documentElement) return 'html'; - if (el.id) return '#' + CSS.escape(el.id); + // Read via getAttribute when `el.id` is not a string — a
with a + // named control (e.g. ) shadows the builtin getter and + // returns the element, producing a garbage `#[object …]` selector (#407). + const elId = typeof el.id === 'string' ? el.id : (el.getAttribute('id') || ''); + if (elId) return '#' + CSS.escape(elId); const parts = []; let current = el; @@ -1467,8 +1471,11 @@ if (IS_BROWSER) { for (const el of document.querySelectorAll('*')) { // Skip impeccable's own elements and any descendants (overlays, labels, banner, nav buttons) if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; - // Skip browser extension elements (Claude, etc.) - const elId = el.id || ''; + // Skip browser extension elements (Claude, etc.). Use getAttribute when + // `el.id` is not a string: a with a named control like + // shadows the builtin `id` getter and returns the + // element, whose `.startsWith` throws (issue #407). + const elId = typeof el.id === 'string' ? el.id : (el.getAttribute('id') || ''); if (elId.startsWith('claude-') || elId.startsWith('cic-')) continue; // Skip the impeccable live-mode overlay (highlight, tooltip, bar, picker, toast). // These are inspector chrome, not part of the user's design. diff --git a/cli/engine/detect-antipatterns-browser.js b/cli/engine/detect-antipatterns-browser.js index 4e3e44b8e..0fdc460ff 100644 --- a/cli/engine/detect-antipatterns-browser.js +++ b/cli/engine/detect-antipatterns-browser.js @@ -902,9 +902,21 @@ function checkColors(opts) { 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 = effectiveBg ? [effectiveBg] : (effectiveBgStops && effectiveBgStops.length ? effectiveBgStops : null); + 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); @@ -2462,29 +2474,54 @@ function resolveBackground(el, win, customPropMap) { // 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) { +function resolveGradientStops(el, win, customPropMap) { let current = el; while (current && current.nodeType === 1) { const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current); const bgImage = style.backgroundImage || ''; + let stops = null; if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) { - const stops = parseGradientColors(bgImage); - if (stops.length > 0) return stops; + const parsed = parseGradientColors(bgImage); + if (parsed.length > 0) stops = parsed; } - if (!DETECTOR_IS_BROWSER) { + if (!stops && !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; + const parsed = parseGradientColors(bgMatch[1]); + if (parsed.length > 0) stops = parsed; } } + if (stops) return compositeGradientStops(stops, current, win, customPropMap); current = current.parentElement; } return null; } +// A translucent gradient stop (e.g. a faint `rgba(52,192,168,0.09)` accent +// glow) paints over whatever surface sits beneath the gradient — the browser +// composites it, so its effective color is far closer to the base than to the +// full-opacity accent. Treating the stop as opaque flags every text child of a +// softly-glowing section as low-contrast (issue #409 Case B). Composite each +// alpha stop over the resolved surface beneath the gradient element. When that +// surface isn't resolvable (another gradient above, no opaque ancestor), drop +// the translucent stop rather than guess: a dropped stop can't manufacture a +// false finding, and skipping beats a wrong ratio. +function compositeGradientStops(stops, gradientEl, win, customPropMap) { + const hasAlpha = stops.some(s => (s.a ?? 1) < 0.99); + if (!hasAlpha) return stops; + const base = resolveBackground(gradientEl.parentElement || gradientEl, win, customPropMap); + const out = []; + for (const s of stops) { + const a = s.a ?? 1; + if (a >= 0.99) { out.push(s); continue; } + if (base) out.push(compositeColorOver(s, base)); + // else: unresolvable base — drop the translucent stop (skip, don't guess). + } + return out.length ? out : 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. @@ -3664,6 +3701,34 @@ function isVisuallyHidden(el, style) { return false; } +// Elements whose text is never painted: document metadata and script/style +// payloads. Their JS / CSS / JSON-LD text satisfies `hasDirectText`, and on +// sites that set `html { font-size: 62.5% }` their inherited computed size is +// 10px — so the text-size floors flag them as tiny body copy even though +// nothing renders (issue #408: dozens of phantom "10px body text" findings on +// every Shopify page). Exclude them, plus anything the cascade resolves to +// display:none / visibility:hidden. The jsdom path can't lay out, so the +// tag/attribute-based exclusions carry the weight there; the display checks are +// computed-style reads that resolve without layout in both adapters. +const NON_RENDERED_TAGS = new Set([ + 'script', 'style', 'title', 'noscript', 'template', 'head', + 'meta', 'link', 'base', 'param', 'source', 'track', 'datalist', + 'col', 'colgroup', 'map', 'area', +]); +function isNonRenderedText(el, tag, style) { + const t = (tag || '').toLowerCase(); + if (NON_RENDERED_TAGS.has(t)) return true; + // Descendants of never render even when the tag itself would + // (some sites nest