Handle grouped linked keyframes

AI assistance disclosure: Codex helped implement and verify this fix under maintainer direction.
This commit is contained in:
Paul Bakaus
2026-09-02 11:55:26 -07:00
parent cf19c53d32
commit 7139130f99
5 changed files with 65 additions and 36 deletions
+9 -13
View File
@@ -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');
+9 -13
View File
@@ -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');
+31 -7
View File
@@ -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/);
+14 -2
View File
@@ -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 {
+2 -1
View File
@@ -13,15 +13,16 @@
<div class="::before">Escaped identifier selector</div>
<div class="linked-marquee">Rendered linked marquee animation</div>
<div class="linked-pulse-dot"></div>
<div class="inactive-container-animation-reference">Inactive container keyframes reference</div>
<div class="inactive-media-stripes">Inactive media stripes</div>
<div class="inactive-supports-stripes">Inactive supports stripes</div>
<div class="inactive-pseudo-stripes active">Inactive pseudo-class stripes</div>
<div class="container-query-host">
<div class="inactive-container-stripes">Inactive container-query stripes</div>
<div class="inactive-container-animation-reference">False-container keyframes reference</div>
</div>
<div class="container-query-host container-query-host-active">
<div class="active-container-halo">Active container-query halo</div>
<div class="active-container-animation-reference">Active container keyframes reference</div>
</div>
</main>
</body>