diff --git a/cli/engine/browser/injected/index.mjs b/cli/engine/browser/injected/index.mjs index 63590867e..1eb350e74 100644 --- a/cli/engine/browser/injected/index.mjs +++ b/cli/engine/browser/injected/index.mjs @@ -1283,10 +1283,17 @@ if (IS_BROWSER) { function selectorNodesForLiveDom(root, selector) { const raw = String(selector || '').trim(); if (!raw) return null; - try { - const exact = Array.from(root.querySelectorAll(raw)); - if (exact.length > 0) return exact; - } catch { /* Dynamic/unsupported pseudos get the fallback below. */ } + + const hasPseudoElement = ( + /::[a-zA-Z-]+(?:\([^)]*\))?|:(?:before|after|first-letter|first-line)\b/i.test(raw) + ); + if (!hasPseudoElement) { + // An empty result from a valid full selector is authoritative. In + // particular, do not broaden inactive :hover/:focus/:not() rules to + // their host element by stripping pseudo-classes. + try { return Array.from(root.querySelectorAll(raw)); } + catch { return null; } + } // Resolve pseudo-elements to their originating live elements. An attached // pseudo-element (`.card::before`) belongs to the element before it, while @@ -1296,9 +1303,8 @@ if (IS_BROWSER) { // invalid selector `main >` and makes absent hosts indistinguishable from // selectors the DOM API cannot parse. const fallback = raw - .replace(/(^|[\s>+~,])::[a-zA-Z-]+(\([^)]*\))?/g, '$1*') - .replace(/::[a-zA-Z-]+(\([^)]*\))?/g, '') - .replace(/:[a-zA-Z-]+(\([^)]*\))?/g, '') + .replace(/(^|[\s>+~,])(?:::[a-zA-Z-]+(?:\([^)]*\))?|:(?:before|after|first-letter|first-line)\b)/gi, '$1*') + .replace(/::[a-zA-Z-]+(?:\([^)]*\))?|:(?:before|after|first-letter|first-line)\b/gi, '') .trim() .replace(/,\s*(?=,|$)/g, ''); if (!fallback || /^[,\s]*$/.test(fallback)) return null; @@ -1408,7 +1414,10 @@ if (IS_BROWSER) { appendRules(nested, requiresAppliedMatch || isContainerCssRule(rule)); continue; } - if (cssText) parts.push(cssText); + // Selector-less leaf at-rules (notably @keyframes) cannot be tied to a + // rendered node. Their activating selector declarations are already + // retained above, while admitting the leaf text would let unused or + // conditionally inactive CSS create page-level findings. } }; const appendSheet = (sheet) => { diff --git a/cli/engine/detect-antipatterns-browser.js b/cli/engine/detect-antipatterns-browser.js index 1cbcf068e..82c211984 100644 --- a/cli/engine/detect-antipatterns-browser.js +++ b/cli/engine/detect-antipatterns-browser.js @@ -8187,10 +8187,17 @@ if (IS_BROWSER) { function selectorNodesForLiveDom(root, selector) { const raw = String(selector || '').trim(); if (!raw) return null; - try { - const exact = Array.from(root.querySelectorAll(raw)); - if (exact.length > 0) return exact; - } catch { /* Dynamic/unsupported pseudos get the fallback below. */ } + + const hasPseudoElement = ( + /::[a-zA-Z-]+(?:\([^)]*\))?|:(?:before|after|first-letter|first-line)\b/i.test(raw) + ); + if (!hasPseudoElement) { + // An empty result from a valid full selector is authoritative. In + // particular, do not broaden inactive :hover/:focus/:not() rules to + // their host element by stripping pseudo-classes. + try { return Array.from(root.querySelectorAll(raw)); } + catch { return null; } + } // Resolve pseudo-elements to their originating live elements. An attached // pseudo-element (`.card::before`) belongs to the element before it, while @@ -8200,9 +8207,8 @@ if (IS_BROWSER) { // invalid selector `main >` and makes absent hosts indistinguishable from // selectors the DOM API cannot parse. const fallback = raw - .replace(/(^|[\s>+~,])::[a-zA-Z-]+(\([^)]*\))?/g, '$1*') - .replace(/::[a-zA-Z-]+(\([^)]*\))?/g, '') - .replace(/:[a-zA-Z-]+(\([^)]*\))?/g, '') + .replace(/(^|[\s>+~,])(?:::[a-zA-Z-]+(?:\([^)]*\))?|:(?:before|after|first-letter|first-line)\b)/gi, '$1*') + .replace(/::[a-zA-Z-]+(?:\([^)]*\))?|:(?:before|after|first-letter|first-line)\b/gi, '') .trim() .replace(/,\s*(?=,|$)/g, ''); if (!fallback || /^[,\s]*$/.test(fallback)) return null; @@ -8312,7 +8318,10 @@ if (IS_BROWSER) { appendRules(nested, requiresAppliedMatch || isContainerCssRule(rule)); continue; } - if (cssText) parts.push(cssText); + // Selector-less leaf at-rules (notably @keyframes) cannot be tied to a + // rendered node. Their activating selector declarations are already + // retained above, while admitting the leaf text would let unused or + // conditionally inactive CSS create page-level findings. } }; const appendSheet = (sheet) => { diff --git a/tests/detect-antipatterns-browser.test.mjs b/tests/detect-antipatterns-browser.test.mjs index d67228d3b..9f5131eb3 100644 --- a/tests/detect-antipatterns-browser.test.mjs +++ b/tests/detect-antipatterns-browser.test.mjs @@ -1408,8 +1408,10 @@ describe('detectUrl — browser-only fixtures', () => { const containerBackgrounds = await linkedPage.evaluate(() => ({ inactive: getComputedStyle(document.querySelector('.inactive-container-stripes')).backgroundImage, active: getComputedStyle(document.querySelector('.active-container-halo')).backgroundImage, + pseudoClass: getComputedStyle(document.querySelector('.inactive-pseudo-stripes')).backgroundImage, })); assert.equal(containerBackgrounds.inactive, 'none'); + assert.equal(containerBackgrounds.pseudoClass, 'none'); assert.match(containerBackgrounds.active, /radial-gradient/i); const grids = linkedFindings.filter(finding => finding.type === 'codex-grid-background'); assert.equal(grids.length, 1, JSON.stringify({ linkedFindings, linkedCssom })); @@ -1421,7 +1423,16 @@ describe('detectUrl — browser-only fixtures', () => { JSON.stringify({ linkedFindings, linkedCssom }), ); assert.equal(linkedFindings.some(finding => finding.type === 'radial-halo'), true); - assert.equal(linkedFindings.some(finding => finding.type === 'repeating-stripes-gradient'), false); + assert.equal( + linkedFindings.some(finding => finding.type === 'repeating-stripes-gradient'), + false, + JSON.stringify({ linkedFindings, linkedCssom, containerBackgrounds }), + ); + assert.equal( + linkedFindings.some(finding => finding.type === 'gradient-text'), + false, + JSON.stringify({ linkedFindings, linkedCssom, containerBackgrounds }), + ); assert.equal(linkedFindings.some(finding => finding.type === 'layout-transition'), false); await linkedPage.close(); diff --git a/tests/fixtures/antipatterns/linked-url-patterns.css b/tests/fixtures/antipatterns/linked-url-patterns.css index bcccf003a..36afdbc65 100644 --- a/tests/fixtures/antipatterns/linked-url-patterns.css +++ b/tests/fixtures/antipatterns/linked-url-patterns.css @@ -45,6 +45,11 @@ main > ::before { } } +/* The host exists, but the complete pseudo-class selector is inactive. */ +.inactive-pseudo-stripes:not(.active) { + background: repeating-linear-gradient(45deg, #eee, #eee 10px, #fafafa 10px, #fafafa 20px); +} + .container-query-host { container-type: inline-size; width: 240px; @@ -66,6 +71,18 @@ main > ::before { } } +/* Non-selector at-rules in an inactive container must not enter page-level + pattern scans just because their CSSOM text is readable. */ +@container (width > 2000px) { + @keyframes inactive-container-gradient-text { + from { + background: linear-gradient(90deg, #111, #999); + background-clip: text; + } + to { background: none; } + } +} + .unused-linked-transition { transition: width 200ms ease; } diff --git a/tests/fixtures/antipatterns/linked-url-patterns.html b/tests/fixtures/antipatterns/linked-url-patterns.html index 440c37c34..8fdf9f975 100644 --- a/tests/fixtures/antipatterns/linked-url-patterns.html +++ b/tests/fixtures/antipatterns/linked-url-patterns.html @@ -11,6 +11,7 @@