mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 14:16:28 +03:00
Restore live linked keyframes
AI assistance disclosure: Codex helped implement and verify this fix under maintainer direction.
This commit is contained in:
@@ -1445,6 +1445,81 @@ if (IS_BROWSER) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function splitCssCommaList(value) {
|
||||
const parts = [];
|
||||
let current = '';
|
||||
let quote = '';
|
||||
let escaped = false;
|
||||
for (const char of String(value || '')) {
|
||||
if (escaped) {
|
||||
current += char;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (char === '\\') {
|
||||
current += char;
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (quote) {
|
||||
current += char;
|
||||
if (char === quote) quote = '';
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === "'") {
|
||||
quote = char;
|
||||
current += char;
|
||||
continue;
|
||||
}
|
||||
if (char === ',') {
|
||||
parts.push(current);
|
||||
current = '';
|
||||
continue;
|
||||
}
|
||||
current += char;
|
||||
}
|
||||
parts.push(current);
|
||||
return parts;
|
||||
}
|
||||
|
||||
function normalizeAnimationName(value) {
|
||||
const name = String(value || '').trim();
|
||||
if (name.length >= 2 && name[0] === name[name.length - 1] && (name[0] === '"' || name[0] === "'")) {
|
||||
return name.slice(1, -1);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function animationNamesDeclaredByRule(rule) {
|
||||
const style = rule?.style;
|
||||
if (!style) return [];
|
||||
let value = '';
|
||||
try {
|
||||
value = style.animationName
|
||||
|| style.getPropertyValue?.('animation-name')
|
||||
|| style.webkitAnimationName
|
||||
|| style.getPropertyValue?.('-webkit-animation-name')
|
||||
|| '';
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return splitCssCommaList(value)
|
||||
.map(normalizeAnimationName)
|
||||
.filter(name => name && name.toLowerCase() !== 'none');
|
||||
}
|
||||
|
||||
function keyframesRuleName(rule, cssText) {
|
||||
const constructorName = rule?.constructor?.name || '';
|
||||
const type = Number(rule?.type);
|
||||
const isKeyframes = constructorName === 'CSSKeyframesRule'
|
||||
|| constructorName === 'WebKitCSSKeyframesRule'
|
||||
|| type === 7
|
||||
|| /^\s*@(?:-webkit-)?keyframes\b/i.test(cssText);
|
||||
if (!isKeyframes) return '';
|
||||
const match = String(cssText || '').match(/^\s*@(?:-webkit-)?keyframes\s+([^\s{]+)/i);
|
||||
return normalizeAnimationName(rule?.name || match?.[1] || '');
|
||||
}
|
||||
|
||||
// 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,
|
||||
@@ -1455,7 +1530,9 @@ if (IS_BROWSER) {
|
||||
function linkedStylesheetText() {
|
||||
const parts = [];
|
||||
const seen = new Set();
|
||||
const appendRules = (rules, requiresAppliedMatch = false) => {
|
||||
const animationNames = new Set();
|
||||
const keyframeCandidates = [];
|
||||
const appendRules = (rules, containerStates = []) => {
|
||||
for (const rule of rules) {
|
||||
if (rule.styleSheet) {
|
||||
appendSheet(rule.styleSheet);
|
||||
@@ -1469,9 +1546,11 @@ if (IS_BROWSER) {
|
||||
// rendered, and retaining them would leak unused CSS into findings.
|
||||
if (
|
||||
matches?.length > 0
|
||||
&& (!requiresAppliedMatch || styleRuleAppliesToLiveMatches(rule, matches))
|
||||
&& (containerStates.length === 0 || styleRuleAppliesToLiveMatches(rule, matches))
|
||||
) {
|
||||
parts.push(cssText);
|
||||
for (const name of animationNamesDeclaredByRule(rule)) animationNames.add(name);
|
||||
for (const state of containerStates) state.active = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -1484,16 +1563,24 @@ if (IS_BROWSER) {
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const isKeyframes = /^\s*@(?:-webkit-)?keyframes\b/i.test(cssText);
|
||||
if (hasNestedRules && !isKeyframes) {
|
||||
if (!conditionalCssRuleIsActive(rule)) continue;
|
||||
appendRules(nested, requiresAppliedMatch || isContainerCssRule(rule));
|
||||
const keyframesName = keyframesRuleName(rule, cssText);
|
||||
if (keyframesName) {
|
||||
keyframeCandidates.push({
|
||||
name: keyframesName,
|
||||
cssText,
|
||||
containerStates: [...containerStates],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// 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.
|
||||
if (hasNestedRules) {
|
||||
if (!conditionalCssRuleIsActive(rule)) continue;
|
||||
const nextContainerStates = isContainerCssRule(rule)
|
||||
? [...containerStates, { active: false }]
|
||||
: containerStates;
|
||||
appendRules(nested, nextContainerStates);
|
||||
continue;
|
||||
}
|
||||
// Other selector-less leaf at-rules cannot be tied to a rendered node.
|
||||
}
|
||||
};
|
||||
const appendSheet = (sheet) => {
|
||||
@@ -1513,6 +1600,16 @@ 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, 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.
|
||||
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');
|
||||
}
|
||||
|
||||
|
||||
@@ -8349,6 +8349,81 @@ if (IS_BROWSER) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function splitCssCommaList(value) {
|
||||
const parts = [];
|
||||
let current = '';
|
||||
let quote = '';
|
||||
let escaped = false;
|
||||
for (const char of String(value || '')) {
|
||||
if (escaped) {
|
||||
current += char;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (char === '\\') {
|
||||
current += char;
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (quote) {
|
||||
current += char;
|
||||
if (char === quote) quote = '';
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === "'") {
|
||||
quote = char;
|
||||
current += char;
|
||||
continue;
|
||||
}
|
||||
if (char === ',') {
|
||||
parts.push(current);
|
||||
current = '';
|
||||
continue;
|
||||
}
|
||||
current += char;
|
||||
}
|
||||
parts.push(current);
|
||||
return parts;
|
||||
}
|
||||
|
||||
function normalizeAnimationName(value) {
|
||||
const name = String(value || '').trim();
|
||||
if (name.length >= 2 && name[0] === name[name.length - 1] && (name[0] === '"' || name[0] === "'")) {
|
||||
return name.slice(1, -1);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function animationNamesDeclaredByRule(rule) {
|
||||
const style = rule?.style;
|
||||
if (!style) return [];
|
||||
let value = '';
|
||||
try {
|
||||
value = style.animationName
|
||||
|| style.getPropertyValue?.('animation-name')
|
||||
|| style.webkitAnimationName
|
||||
|| style.getPropertyValue?.('-webkit-animation-name')
|
||||
|| '';
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return splitCssCommaList(value)
|
||||
.map(normalizeAnimationName)
|
||||
.filter(name => name && name.toLowerCase() !== 'none');
|
||||
}
|
||||
|
||||
function keyframesRuleName(rule, cssText) {
|
||||
const constructorName = rule?.constructor?.name || '';
|
||||
const type = Number(rule?.type);
|
||||
const isKeyframes = constructorName === 'CSSKeyframesRule'
|
||||
|| constructorName === 'WebKitCSSKeyframesRule'
|
||||
|| type === 7
|
||||
|| /^\s*@(?:-webkit-)?keyframes\b/i.test(cssText);
|
||||
if (!isKeyframes) return '';
|
||||
const match = String(cssText || '').match(/^\s*@(?:-webkit-)?keyframes\s+([^\s{]+)/i);
|
||||
return normalizeAnimationName(rule?.name || match?.[1] || '');
|
||||
}
|
||||
|
||||
// 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,
|
||||
@@ -8359,7 +8434,9 @@ if (IS_BROWSER) {
|
||||
function linkedStylesheetText() {
|
||||
const parts = [];
|
||||
const seen = new Set();
|
||||
const appendRules = (rules, requiresAppliedMatch = false) => {
|
||||
const animationNames = new Set();
|
||||
const keyframeCandidates = [];
|
||||
const appendRules = (rules, containerStates = []) => {
|
||||
for (const rule of rules) {
|
||||
if (rule.styleSheet) {
|
||||
appendSheet(rule.styleSheet);
|
||||
@@ -8373,9 +8450,11 @@ if (IS_BROWSER) {
|
||||
// rendered, and retaining them would leak unused CSS into findings.
|
||||
if (
|
||||
matches?.length > 0
|
||||
&& (!requiresAppliedMatch || styleRuleAppliesToLiveMatches(rule, matches))
|
||||
&& (containerStates.length === 0 || styleRuleAppliesToLiveMatches(rule, matches))
|
||||
) {
|
||||
parts.push(cssText);
|
||||
for (const name of animationNamesDeclaredByRule(rule)) animationNames.add(name);
|
||||
for (const state of containerStates) state.active = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -8388,16 +8467,24 @@ if (IS_BROWSER) {
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const isKeyframes = /^\s*@(?:-webkit-)?keyframes\b/i.test(cssText);
|
||||
if (hasNestedRules && !isKeyframes) {
|
||||
if (!conditionalCssRuleIsActive(rule)) continue;
|
||||
appendRules(nested, requiresAppliedMatch || isContainerCssRule(rule));
|
||||
const keyframesName = keyframesRuleName(rule, cssText);
|
||||
if (keyframesName) {
|
||||
keyframeCandidates.push({
|
||||
name: keyframesName,
|
||||
cssText,
|
||||
containerStates: [...containerStates],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// 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.
|
||||
if (hasNestedRules) {
|
||||
if (!conditionalCssRuleIsActive(rule)) continue;
|
||||
const nextContainerStates = isContainerCssRule(rule)
|
||||
? [...containerStates, { active: false }]
|
||||
: containerStates;
|
||||
appendRules(nested, nextContainerStates);
|
||||
continue;
|
||||
}
|
||||
// Other selector-less leaf at-rules cannot be tied to a rendered node.
|
||||
}
|
||||
};
|
||||
const appendSheet = (sheet) => {
|
||||
@@ -8417,6 +8504,16 @@ 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, 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.
|
||||
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');
|
||||
}
|
||||
|
||||
|
||||
@@ -1422,6 +1422,12 @@ describe('detectUrl — browser-only fixtures', () => {
|
||||
true,
|
||||
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/);
|
||||
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/);
|
||||
assert.equal(linkedFindings.some(finding => finding.type === 'radial-halo'), true);
|
||||
assert.equal(
|
||||
linkedFindings.some(finding => finding.type === 'repeating-stripes-gradient'),
|
||||
|
||||
@@ -38,6 +38,39 @@ main > ::before {
|
||||
50% { transform: translateY(2px); }
|
||||
}
|
||||
|
||||
.linked-marquee {
|
||||
animation: linked-horizontal-loop 8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes linked-horizontal-loop {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(-50%); }
|
||||
}
|
||||
|
||||
.linked-pulse-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
animation: linked-signal-cycle 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes linked-signal-cycle {
|
||||
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. */
|
||||
.inactive-container-animation-reference {
|
||||
animation: inactive-container-horizontal-loop 8s linear infinite;
|
||||
}
|
||||
|
||||
@container (width > 2000px) {
|
||||
@keyframes inactive-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 {
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
<div class="flag-linked-grid" data-token="::before">Rendered decorative grid</div>
|
||||
<div data-decoy="">Empty attribute-value decoy</div>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user