mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 06:06:37 +03:00
Resolve effective linked keyframes
AI assistance disclosure: Codex helped implement and verify this fix under maintainer direction.
This commit is contained in:
@@ -1520,6 +1520,41 @@ if (IS_BROWSER) {
|
||||
return normalizeAnimationName(rule?.name || match?.[1] || '');
|
||||
}
|
||||
|
||||
function cssPropertyName(property) {
|
||||
if (property.startsWith('--')) return property;
|
||||
return property.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
|
||||
}
|
||||
|
||||
function resolvedAnimationKeyframes(candidateNames) {
|
||||
if (typeof document.getAnimations !== 'function') return null;
|
||||
let animations;
|
||||
try { animations = document.getAnimations(); }
|
||||
catch { return null; }
|
||||
|
||||
const resolved = new Map();
|
||||
const metadata = new Set(['offset', 'computedOffset', 'easing', 'composite']);
|
||||
for (const animation of animations) {
|
||||
const name = normalizeAnimationName(animation?.animationName || '');
|
||||
if (!name || !candidateNames.has(name) || resolved.has(name)) continue;
|
||||
let frames;
|
||||
try { frames = animation.effect?.getKeyframes?.() || []; }
|
||||
catch { continue; }
|
||||
const blocks = [];
|
||||
for (const frame of frames) {
|
||||
const rawOffset = Number.isFinite(frame.computedOffset) ? frame.computedOffset : frame.offset;
|
||||
if (!Number.isFinite(rawOffset)) continue;
|
||||
const offset = Math.round(rawOffset * 1000000) / 10000;
|
||||
const declarations = Object.entries(frame)
|
||||
.filter(([property, value]) => !metadata.has(property) && value != null && value !== '')
|
||||
.map(([property, value]) => `${cssPropertyName(property)}: ${value};`);
|
||||
if (declarations.length === 0) continue;
|
||||
blocks.push(`${offset}% { ${declarations.join(' ')} }`);
|
||||
}
|
||||
if (blocks.length > 0) resolved.set(name, `@keyframes ${name} { ${blocks.join(' ')} }`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// Read CSS that is absent from document.outerHTML. Inline <style> blocks are
|
||||
// already present in the HTML pattern corpus, so limit this walk to linked
|
||||
// stylesheets. Flatten grouping rules so each declaration keeps its selector,
|
||||
@@ -1597,16 +1632,21 @@ if (IS_BROWSER) {
|
||||
if (!/\bstylesheet\b/i.test(owner.getAttribute?.('rel') || '')) continue;
|
||||
appendSheet(sheet);
|
||||
}
|
||||
// Motion checks need the body of a live animation's keyframes. Retain only
|
||||
// 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.values()) {
|
||||
if (!animationNames.has(candidate.name)) continue;
|
||||
parts.push(candidate.cssText);
|
||||
// Motion checks need the effective body of a live animation's keyframes.
|
||||
// Let the browser resolve duplicate names across source order, imports,
|
||||
// conditional groups, and cascade layers, then serialize those computed
|
||||
// frames back into the pattern corpus. Browsers also make container-nested
|
||||
// keyframes globally available, so lexical grouping is not a reliable
|
||||
// activity signal. When the Web Animations API is unavailable, fall back to
|
||||
// the last source-order definition referenced by a retained linked rule.
|
||||
const resolvedKeyframes = resolvedAnimationKeyframes(new Set(keyframeCandidates.keys()));
|
||||
if (resolvedKeyframes) {
|
||||
parts.push(...resolvedKeyframes.values());
|
||||
} else {
|
||||
for (const candidate of keyframeCandidates.values()) {
|
||||
if (!animationNames.has(candidate.name)) continue;
|
||||
parts.push(candidate.cssText);
|
||||
}
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
@@ -8424,6 +8424,41 @@ if (IS_BROWSER) {
|
||||
return normalizeAnimationName(rule?.name || match?.[1] || '');
|
||||
}
|
||||
|
||||
function cssPropertyName(property) {
|
||||
if (property.startsWith('--')) return property;
|
||||
return property.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
|
||||
}
|
||||
|
||||
function resolvedAnimationKeyframes(candidateNames) {
|
||||
if (typeof document.getAnimations !== 'function') return null;
|
||||
let animations;
|
||||
try { animations = document.getAnimations(); }
|
||||
catch { return null; }
|
||||
|
||||
const resolved = new Map();
|
||||
const metadata = new Set(['offset', 'computedOffset', 'easing', 'composite']);
|
||||
for (const animation of animations) {
|
||||
const name = normalizeAnimationName(animation?.animationName || '');
|
||||
if (!name || !candidateNames.has(name) || resolved.has(name)) continue;
|
||||
let frames;
|
||||
try { frames = animation.effect?.getKeyframes?.() || []; }
|
||||
catch { continue; }
|
||||
const blocks = [];
|
||||
for (const frame of frames) {
|
||||
const rawOffset = Number.isFinite(frame.computedOffset) ? frame.computedOffset : frame.offset;
|
||||
if (!Number.isFinite(rawOffset)) continue;
|
||||
const offset = Math.round(rawOffset * 1000000) / 10000;
|
||||
const declarations = Object.entries(frame)
|
||||
.filter(([property, value]) => !metadata.has(property) && value != null && value !== '')
|
||||
.map(([property, value]) => `${cssPropertyName(property)}: ${value};`);
|
||||
if (declarations.length === 0) continue;
|
||||
blocks.push(`${offset}% { ${declarations.join(' ')} }`);
|
||||
}
|
||||
if (blocks.length > 0) resolved.set(name, `@keyframes ${name} { ${blocks.join(' ')} }`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// Read CSS that is absent from document.outerHTML. Inline <style> blocks are
|
||||
// already present in the HTML pattern corpus, so limit this walk to linked
|
||||
// stylesheets. Flatten grouping rules so each declaration keeps its selector,
|
||||
@@ -8501,16 +8536,21 @@ if (IS_BROWSER) {
|
||||
if (!/\bstylesheet\b/i.test(owner.getAttribute?.('rel') || '')) continue;
|
||||
appendSheet(sheet);
|
||||
}
|
||||
// Motion checks need the body of a live animation's keyframes. Retain only
|
||||
// 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.values()) {
|
||||
if (!animationNames.has(candidate.name)) continue;
|
||||
parts.push(candidate.cssText);
|
||||
// Motion checks need the effective body of a live animation's keyframes.
|
||||
// Let the browser resolve duplicate names across source order, imports,
|
||||
// conditional groups, and cascade layers, then serialize those computed
|
||||
// frames back into the pattern corpus. Browsers also make container-nested
|
||||
// keyframes globally available, so lexical grouping is not a reliable
|
||||
// activity signal. When the Web Animations API is unavailable, fall back to
|
||||
// the last source-order definition referenced by a retained linked rule.
|
||||
const resolvedKeyframes = resolvedAnimationKeyframes(new Set(keyframeCandidates.keys()));
|
||||
if (resolvedKeyframes) {
|
||||
parts.push(...resolvedKeyframes.values());
|
||||
} else {
|
||||
for (const candidate of keyframeCandidates.values()) {
|
||||
if (!animationNames.has(candidate.name)) continue;
|
||||
parts.push(candidate.cssText);
|
||||
}
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
@@ -1409,10 +1409,12 @@ describe('detectUrl — browser-only fixtures', () => {
|
||||
const activeAnimation = document.querySelector('.active-container-animation-reference');
|
||||
const inactiveAnimation = document.querySelector('.inactive-container-animation-reference');
|
||||
const overriddenAnimation = document.querySelector('.overridden-keyframes-animation');
|
||||
const layeredAnimation = document.querySelector('.layered-keyframes-animation');
|
||||
const before = {
|
||||
active: getComputedStyle(activeAnimation).transform,
|
||||
inactive: getComputedStyle(inactiveAnimation).transform,
|
||||
overridden: getComputedStyle(overriddenAnimation).transform,
|
||||
layered: getComputedStyle(layeredAnimation).transform,
|
||||
};
|
||||
await new Promise(resolve => setTimeout(resolve, 120));
|
||||
return {
|
||||
@@ -1422,6 +1424,10 @@ describe('detectUrl — browser-only fixtures', () => {
|
||||
activeTransforms: [before.active, getComputedStyle(activeAnimation).transform],
|
||||
inactiveTransforms: [before.inactive, getComputedStyle(inactiveAnimation).transform],
|
||||
overriddenTransforms: [before.overridden, getComputedStyle(overriddenAnimation).transform],
|
||||
layeredTransforms: [before.layered, getComputedStyle(layeredAnimation).transform],
|
||||
activeAnimationKeyframes: activeAnimation.getAnimations()[0]?.effect?.getKeyframes() || [],
|
||||
overriddenAnimationKeyframes: overriddenAnimation.getAnimations()[0]?.effect?.getKeyframes() || [],
|
||||
layeredAnimationKeyframes: layeredAnimation.getAnimations()[0]?.effect?.getKeyframes() || [],
|
||||
};
|
||||
});
|
||||
assert.equal(containerBackgrounds.inactive, 'none');
|
||||
@@ -1438,6 +1444,26 @@ describe('detectUrl — browser-only fixtures', () => {
|
||||
containerBackgrounds.overriddenTransforms[1],
|
||||
JSON.stringify(containerBackgrounds),
|
||||
);
|
||||
assert.equal(
|
||||
containerBackgrounds.activeAnimationKeyframes.some(frame => /translateX\([^)]*%\)/i.test(frame.transform || '')),
|
||||
true,
|
||||
JSON.stringify(containerBackgrounds.activeAnimationKeyframes),
|
||||
);
|
||||
assert.equal(
|
||||
containerBackgrounds.overriddenAnimationKeyframes.some(frame => frame.transform && frame.transform !== 'none'),
|
||||
false,
|
||||
JSON.stringify(containerBackgrounds.overriddenAnimationKeyframes),
|
||||
);
|
||||
assert.notEqual(
|
||||
containerBackgrounds.layeredTransforms[0],
|
||||
containerBackgrounds.layeredTransforms[1],
|
||||
JSON.stringify(containerBackgrounds),
|
||||
);
|
||||
assert.equal(
|
||||
containerBackgrounds.layeredAnimationKeyframes.some(frame => /translateX\([^)]*%\)/i.test(frame.transform || '')),
|
||||
true,
|
||||
JSON.stringify(containerBackgrounds.layeredAnimationKeyframes),
|
||||
);
|
||||
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');
|
||||
@@ -1448,13 +1474,14 @@ describe('detectUrl — browser-only fixtures', () => {
|
||||
JSON.stringify({ linkedFindings, linkedCssom }),
|
||||
);
|
||||
const marquees = linkedFindings.filter(finding => finding.type === 'marquee');
|
||||
assert.equal(marquees.length, 3, JSON.stringify({ linkedFindings, linkedCssom }));
|
||||
assert.equal(marquees.length, 4, 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',
|
||||
'.layered-keyframes-animation',
|
||||
]),
|
||||
);
|
||||
const pulsingDots = linkedFindings.filter(finding => finding.type === 'pulsing-dot');
|
||||
|
||||
@@ -61,6 +61,26 @@ main > ::before {
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
@layer linked-keyframes-low, linked-keyframes-high;
|
||||
|
||||
.layered-keyframes-animation {
|
||||
animation: layered-horizontal-loop 2s linear infinite;
|
||||
}
|
||||
|
||||
@layer linked-keyframes-high {
|
||||
@keyframes layered-horizontal-loop {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(-50%); }
|
||||
}
|
||||
}
|
||||
|
||||
/* Lower layer appears later in source, but does not override the high layer. */
|
||||
@layer linked-keyframes-low {
|
||||
@keyframes layered-horizontal-loop {
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
}
|
||||
|
||||
.linked-pulse-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<div class="::before">Escaped identifier selector</div>
|
||||
<div class="linked-marquee">Rendered linked marquee animation</div>
|
||||
<div class="overridden-keyframes-animation">Overridden linked keyframes</div>
|
||||
<div class="layered-keyframes-animation">Layer-priority linked keyframes</div>
|
||||
<div class="linked-pulse-dot"></div>
|
||||
<div class="inactive-media-stripes">Inactive media stripes</div>
|
||||
<div class="inactive-supports-stripes">Inactive supports stripes</div>
|
||||
|
||||
Reference in New Issue
Block a user