diff --git a/cli/engine/browser/injected/index.mjs b/cli/engine/browser/injected/index.mjs index d242a31c1..6a0b024af 100644 --- a/cli/engine/browser/injected/index.mjs +++ b/cli/engine/browser/injected/index.mjs @@ -1532,7 +1532,7 @@ if (IS_BROWSER) { const seen = new Set(); const animationNames = new Set(); const keyframeCandidates = []; - const appendRules = (rules, containerStates = []) => { + const appendRules = (rules, requiresAppliedMatch = false) => { for (const rule of rules) { if (rule.styleSheet) { appendSheet(rule.styleSheet); @@ -1546,11 +1546,10 @@ if (IS_BROWSER) { // rendered, and retaining them would leak unused CSS into findings. if ( matches?.length > 0 - && (containerStates.length === 0 || styleRuleAppliesToLiveMatches(rule, matches)) + && (!requiresAppliedMatch || styleRuleAppliesToLiveMatches(rule, matches)) ) { parts.push(cssText); for (const name of animationNamesDeclaredByRule(rule)) animationNames.add(name); - for (const state of containerStates) state.active = true; } continue; } @@ -1568,16 +1567,12 @@ if (IS_BROWSER) { keyframeCandidates.push({ name: keyframesName, cssText, - containerStates: [...containerStates], }); continue; } if (hasNestedRules) { if (!conditionalCssRuleIsActive(rule)) continue; - const nextContainerStates = isContainerCssRule(rule) - ? [...containerStates, { active: false }] - : containerStates; - appendRules(nested, nextContainerStates); + appendRules(nested, requiresAppliedMatch || isContainerCssRule(rule)); continue; } // Other selector-less leaf at-rules cannot be tied to a rendered node. @@ -1601,13 +1596,14 @@ if (IS_BROWSER) { appendSheet(sheet); } // Motion checks need the body of a live animation's keyframes. Retain only - // definitions referenced by a retained selector rule, and only when every - // enclosing container query was proven active by a declaration applying - // to a live match. This preserves linked marquee/pulse detection without - // letting unused or inactive keyframe bodies feed page-level checks. + // definitions referenced by a retained selector rule. Browsers make nested + // keyframes globally available even when a surrounding container condition + // is currently false, so lexical grouping cannot decide whether the named + // animation renders. The live reference is the useful gate: it preserves + // linked marquee/pulse detection without letting unreferenced keyframe + // bodies feed page-level checks. for (const candidate of keyframeCandidates) { if (!animationNames.has(candidate.name)) continue; - if (candidate.containerStates.some(state => !state.active)) continue; parts.push(candidate.cssText); } return parts.join('\n'); diff --git a/cli/engine/detect-antipatterns-browser.js b/cli/engine/detect-antipatterns-browser.js index 1a91a6db4..39eed8ff2 100644 --- a/cli/engine/detect-antipatterns-browser.js +++ b/cli/engine/detect-antipatterns-browser.js @@ -8436,7 +8436,7 @@ if (IS_BROWSER) { const seen = new Set(); const animationNames = new Set(); const keyframeCandidates = []; - const appendRules = (rules, containerStates = []) => { + const appendRules = (rules, requiresAppliedMatch = false) => { for (const rule of rules) { if (rule.styleSheet) { appendSheet(rule.styleSheet); @@ -8450,11 +8450,10 @@ if (IS_BROWSER) { // rendered, and retaining them would leak unused CSS into findings. if ( matches?.length > 0 - && (containerStates.length === 0 || styleRuleAppliesToLiveMatches(rule, matches)) + && (!requiresAppliedMatch || styleRuleAppliesToLiveMatches(rule, matches)) ) { parts.push(cssText); for (const name of animationNamesDeclaredByRule(rule)) animationNames.add(name); - for (const state of containerStates) state.active = true; } continue; } @@ -8472,16 +8471,12 @@ if (IS_BROWSER) { keyframeCandidates.push({ name: keyframesName, cssText, - containerStates: [...containerStates], }); continue; } if (hasNestedRules) { if (!conditionalCssRuleIsActive(rule)) continue; - const nextContainerStates = isContainerCssRule(rule) - ? [...containerStates, { active: false }] - : containerStates; - appendRules(nested, nextContainerStates); + appendRules(nested, requiresAppliedMatch || isContainerCssRule(rule)); continue; } // Other selector-less leaf at-rules cannot be tied to a rendered node. @@ -8505,13 +8500,14 @@ if (IS_BROWSER) { appendSheet(sheet); } // Motion checks need the body of a live animation's keyframes. Retain only - // definitions referenced by a retained selector rule, and only when every - // enclosing container query was proven active by a declaration applying - // to a live match. This preserves linked marquee/pulse detection without - // letting unused or inactive keyframe bodies feed page-level checks. + // definitions referenced by a retained selector rule. Browsers make nested + // keyframes globally available even when a surrounding container condition + // is currently false, so lexical grouping cannot decide whether the named + // animation renders. The live reference is the useful gate: it preserves + // linked marquee/pulse detection without letting unreferenced keyframe + // bodies feed page-level checks. for (const candidate of keyframeCandidates) { if (!animationNames.has(candidate.name)) continue; - if (candidate.containerStates.some(state => !state.active)) continue; parts.push(candidate.cssText); } return parts.join('\n'); diff --git a/tests/detect-antipatterns-browser.test.mjs b/tests/detect-antipatterns-browser.test.mjs index 23ef7812f..463300a5c 100644 --- a/tests/detect-antipatterns-browser.test.mjs +++ b/tests/detect-antipatterns-browser.test.mjs @@ -1405,14 +1405,31 @@ describe('detectUrl — browser-only fixtures', () => { }))); const linkedFindings = await linkedPage.evaluate(() => window.impeccableDetect({ serialize: true }) .flatMap(group => group.findings || [])); - 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, - })); + const containerBackgrounds = await linkedPage.evaluate(async () => { + const activeAnimation = document.querySelector('.active-container-animation-reference'); + const inactiveAnimation = document.querySelector('.inactive-container-animation-reference'); + const before = { + active: getComputedStyle(activeAnimation).transform, + inactive: getComputedStyle(inactiveAnimation).transform, + }; + await new Promise(resolve => setTimeout(resolve, 120)); + return { + inactive: getComputedStyle(document.querySelector('.inactive-container-stripes')).backgroundImage, + active: getComputedStyle(document.querySelector('.active-container-halo')).backgroundImage, + pseudoClass: getComputedStyle(document.querySelector('.inactive-pseudo-stripes')).backgroundImage, + activeTransforms: [before.active, getComputedStyle(activeAnimation).transform], + inactiveTransforms: [before.inactive, getComputedStyle(inactiveAnimation).transform], + }; + }); assert.equal(containerBackgrounds.inactive, 'none'); assert.equal(containerBackgrounds.pseudoClass, 'none'); assert.match(containerBackgrounds.active, /radial-gradient/i); + assert.notEqual(containerBackgrounds.activeTransforms[0], containerBackgrounds.activeTransforms[1]); + assert.notEqual( + containerBackgrounds.inactiveTransforms[0], + containerBackgrounds.inactiveTransforms[1], + JSON.stringify(containerBackgrounds), + ); 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'); @@ -1423,8 +1440,15 @@ describe('detectUrl — browser-only fixtures', () => { JSON.stringify({ linkedFindings, linkedCssom }), ); const marquees = linkedFindings.filter(finding => finding.type === 'marquee'); - assert.equal(marquees.length, 1, JSON.stringify({ linkedFindings, linkedCssom })); - assert.match(marquees[0].detail, /\.linked-marquee/); + assert.equal(marquees.length, 3, JSON.stringify({ linkedFindings, linkedCssom })); + assert.deepEqual( + new Set(marquees.map(finding => finding.detail.match(/^\S+/)?.[0])), + new Set([ + '.linked-marquee', + '.active-container-animation-reference', + '.inactive-container-animation-reference', + ]), + ); const pulsingDots = linkedFindings.filter(finding => finding.type === 'pulsing-dot'); assert.equal(pulsingDots.length, 1, JSON.stringify({ linkedFindings, linkedCssom })); assert.match(pulsingDots[0].detail, /\.linked-pulse-dot/); diff --git a/tests/fixtures/antipatterns/linked-url-patterns.css b/tests/fixtures/antipatterns/linked-url-patterns.css index c95d6c3a1..fb895350a 100644 --- a/tests/fixtures/antipatterns/linked-url-patterns.css +++ b/tests/fixtures/antipatterns/linked-url-patterns.css @@ -58,8 +58,8 @@ main > ::before { 50% { opacity: 0.35; } } -/* A live selector can name keyframes that only exist under an inactive - container query. The inaccessible body must not create a marquee hit. */ +/* Chromium makes nested keyframes globally available even while the enclosing + container condition is false, so this live reference must still scan. */ .inactive-container-animation-reference { animation: inactive-container-horizontal-loop 8s linear infinite; } @@ -71,6 +71,18 @@ main > ::before { } } +.active-container-animation-reference { + animation: active-container-horizontal-loop 8s linear infinite; +} + +/* The declaration is intentionally outside the container group. */ +@container (width > 900px) { + @keyframes active-container-horizontal-loop { + from { transform: translateX(0); } + to { transform: translateX(-50%); } + } +} + /* A pseudo-element whose originating element is absent must remain outside live URL findings instead of being retained as an unresolvable selector. */ .absent > ::before { diff --git a/tests/fixtures/antipatterns/linked-url-patterns.html b/tests/fixtures/antipatterns/linked-url-patterns.html index bdf1df2d5..fb2bb056c 100644 --- a/tests/fixtures/antipatterns/linked-url-patterns.html +++ b/tests/fixtures/antipatterns/linked-url-patterns.html @@ -13,15 +13,16 @@