diff --git a/cli/engine/browser/injected/index.mjs b/cli/engine/browser/injected/index.mjs index f1e90178e..63590867e 100644 --- a/cli/engine/browser/injected/index.mjs +++ b/cli/engine/browser/injected/index.mjs @@ -1306,6 +1306,45 @@ if (IS_BROWSER) { catch { return null; } } + let containerProbeSequence = 0; + + function isContainerCssRule(rule) { + return rule?.constructor?.name === 'CSSContainerRule' + || /^\s*@container\b/i.test(rule?.cssText || ''); + } + + function styleRuleAppliesToLiveMatches(rule, matches) { + const style = rule?.style; + if (!style || !matches?.length || typeof getComputedStyle !== 'function') return false; + const sequence = ++containerProbeSequence; + const property = `--impeccable-container-probe-${sequence}-${Math.random().toString(36).slice(2)}`; + const value = `impeccable-container-active-${sequence}`; + const previousValue = style.getPropertyValue(property); + const previousPriority = style.getPropertyPriority(property); + try { + style.setProperty(property, value, 'important'); + } catch { + return false; + } + + const pseudoElements = [...new Set( + String(rule.selectorText || '').match(/::[a-zA-Z-]+(?:\([^)]*\))?/g) || [], + )]; + try { + return matches.some(el => [null, ...pseudoElements].some(pseudo => { + try { + const computed = pseudo ? getComputedStyle(el, pseudo) : getComputedStyle(el); + return computed.getPropertyValue(property).trim() === value; + } catch { + return false; + } + })); + } finally { + if (previousValue) style.setProperty(property, previousValue, previousPriority); + else style.removeProperty(property); + } + } + function conditionalCssRuleIsActive(rule) { const type = Number(rule?.type); const constructorName = rule?.constructor?.name || ''; @@ -1321,13 +1360,6 @@ if (IS_BROWSER) { try { return CSS.supports(condition); } catch { return true; } } - if (constructorName === 'CSSContainerRule' || /^\s*@container\b/i.test(rule?.cssText || '')) { - // The platform exposes no matchMedia-equivalent for container queries, - // and selector matches do not prove that the element's query container - // satisfies the condition. Omit the group rather than report styles that - // are inactive for the current layout. - return false; - } return true; } @@ -1341,7 +1373,7 @@ if (IS_BROWSER) { function linkedStylesheetText() { const parts = []; const seen = new Set(); - const appendRules = (rules) => { + const appendRules = (rules, requiresAppliedMatch = false) => { for (const rule of rules) { if (rule.styleSheet) { appendSheet(rule.styleSheet); @@ -1353,7 +1385,12 @@ if (IS_BROWSER) { // Only declarations with a resolvable live host enter the corpus. // Unresolvable selectors are uncertain, not evidence that a pattern // rendered, and retaining them would leak unused CSS into findings. - if (matches?.length > 0) parts.push(cssText); + if ( + matches?.length > 0 + && (!requiresAppliedMatch || styleRuleAppliesToLiveMatches(rule, matches)) + ) { + parts.push(cssText); + } continue; } let nested = []; @@ -1368,7 +1405,7 @@ if (IS_BROWSER) { const isKeyframes = /^\s*@(?:-webkit-)?keyframes\b/i.test(cssText); if (hasNestedRules && !isKeyframes) { if (!conditionalCssRuleIsActive(rule)) continue; - appendRules(nested); + appendRules(nested, requiresAppliedMatch || isContainerCssRule(rule)); continue; } if (cssText) parts.push(cssText); diff --git a/cli/engine/detect-antipatterns-browser.js b/cli/engine/detect-antipatterns-browser.js index d882944c1..1cbcf068e 100644 --- a/cli/engine/detect-antipatterns-browser.js +++ b/cli/engine/detect-antipatterns-browser.js @@ -8210,6 +8210,45 @@ if (IS_BROWSER) { catch { return null; } } + let containerProbeSequence = 0; + + function isContainerCssRule(rule) { + return rule?.constructor?.name === 'CSSContainerRule' + || /^\s*@container\b/i.test(rule?.cssText || ''); + } + + function styleRuleAppliesToLiveMatches(rule, matches) { + const style = rule?.style; + if (!style || !matches?.length || typeof getComputedStyle !== 'function') return false; + const sequence = ++containerProbeSequence; + const property = `--impeccable-container-probe-${sequence}-${Math.random().toString(36).slice(2)}`; + const value = `impeccable-container-active-${sequence}`; + const previousValue = style.getPropertyValue(property); + const previousPriority = style.getPropertyPriority(property); + try { + style.setProperty(property, value, 'important'); + } catch { + return false; + } + + const pseudoElements = [...new Set( + String(rule.selectorText || '').match(/::[a-zA-Z-]+(?:\([^)]*\))?/g) || [], + )]; + try { + return matches.some(el => [null, ...pseudoElements].some(pseudo => { + try { + const computed = pseudo ? getComputedStyle(el, pseudo) : getComputedStyle(el); + return computed.getPropertyValue(property).trim() === value; + } catch { + return false; + } + })); + } finally { + if (previousValue) style.setProperty(property, previousValue, previousPriority); + else style.removeProperty(property); + } + } + function conditionalCssRuleIsActive(rule) { const type = Number(rule?.type); const constructorName = rule?.constructor?.name || ''; @@ -8225,13 +8264,6 @@ if (IS_BROWSER) { try { return CSS.supports(condition); } catch { return true; } } - if (constructorName === 'CSSContainerRule' || /^\s*@container\b/i.test(rule?.cssText || '')) { - // The platform exposes no matchMedia-equivalent for container queries, - // and selector matches do not prove that the element's query container - // satisfies the condition. Omit the group rather than report styles that - // are inactive for the current layout. - return false; - } return true; } @@ -8245,7 +8277,7 @@ if (IS_BROWSER) { function linkedStylesheetText() { const parts = []; const seen = new Set(); - const appendRules = (rules) => { + const appendRules = (rules, requiresAppliedMatch = false) => { for (const rule of rules) { if (rule.styleSheet) { appendSheet(rule.styleSheet); @@ -8257,7 +8289,12 @@ if (IS_BROWSER) { // Only declarations with a resolvable live host enter the corpus. // Unresolvable selectors are uncertain, not evidence that a pattern // rendered, and retaining them would leak unused CSS into findings. - if (matches?.length > 0) parts.push(cssText); + if ( + matches?.length > 0 + && (!requiresAppliedMatch || styleRuleAppliesToLiveMatches(rule, matches)) + ) { + parts.push(cssText); + } continue; } let nested = []; @@ -8272,7 +8309,7 @@ if (IS_BROWSER) { const isKeyframes = /^\s*@(?:-webkit-)?keyframes\b/i.test(cssText); if (hasNestedRules && !isKeyframes) { if (!conditionalCssRuleIsActive(rule)) continue; - appendRules(nested); + appendRules(nested, requiresAppliedMatch || isContainerCssRule(rule)); continue; } if (cssText) parts.push(cssText); diff --git a/tests/detect-antipatterns-browser.test.mjs b/tests/detect-antipatterns-browser.test.mjs index bc814115d..d67228d3b 100644 --- a/tests/detect-antipatterns-browser.test.mjs +++ b/tests/detect-antipatterns-browser.test.mjs @@ -1405,11 +1405,12 @@ describe('detectUrl — browser-only fixtures', () => { }))); const linkedFindings = await linkedPage.evaluate(() => window.impeccableDetect({ serialize: true }) .flatMap(group => group.findings || [])); - const inactiveContainerBackground = await linkedPage.$eval( - '.inactive-container-stripes', - element => getComputedStyle(element).backgroundImage, - ); - assert.equal(inactiveContainerBackground, 'none'); + const containerBackgrounds = await linkedPage.evaluate(() => ({ + inactive: getComputedStyle(document.querySelector('.inactive-container-stripes')).backgroundImage, + active: getComputedStyle(document.querySelector('.active-container-halo')).backgroundImage, + })); + assert.equal(containerBackgrounds.inactive, '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 })); assert.equal(grids[0].severity, 'advisory'); @@ -1419,6 +1420,7 @@ describe('detectUrl — browser-only fixtures', () => { true, 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 === '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 a5cffc1fd..bcccf003a 100644 --- a/tests/fixtures/antipatterns/linked-url-patterns.css +++ b/tests/fixtures/antipatterns/linked-url-patterns.css @@ -7,6 +7,11 @@ } } +body { + background: #111; + color: #fff; +} + /* A valid hostless pseudo-element selector cannot be queried through the DOM selector API. It must remain in the corpus rather than count as unused. */ main > ::before { @@ -45,10 +50,20 @@ main > ::before { width: 240px; } +.container-query-host-active { + width: 960px; +} + @container (width > 900px) { .inactive-container-stripes { background: repeating-linear-gradient(45deg, #eee, #eee 10px, #fafafa 10px, #fafafa 20px); } + + .active-container-halo { + width: 640px; + height: 400px; + background: radial-gradient(circle, rgba(80, 111, 255, 0.85), transparent 70%); + } } .unused-linked-transition { diff --git a/tests/fixtures/antipatterns/linked-url-patterns.html b/tests/fixtures/antipatterns/linked-url-patterns.html index 7c1cfbe2c..440c37c34 100644 --- a/tests/fixtures/antipatterns/linked-url-patterns.html +++ b/tests/fixtures/antipatterns/linked-url-patterns.html @@ -14,6 +14,9 @@
Inactive container-query stripes
+
+
Active container-query halo
+