fix: only count CSS paints that match rendered elements

This commit is contained in:
Marco Beretta 2026-06-23 16:43:27 +02:00
parent 840b06695f
commit ef93643a16
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
2 changed files with 57 additions and 5 deletions

View file

@ -222,6 +222,24 @@ describe('isMonochromeSvg', () => {
'<svg viewBox="0 0 24 24"><style>.a{color:#e00;fill:currentColor}</style><path class="a" d="M4 4h16v16H4z" /></svg>';
expect(isMonochromeSvg(svg)).toBe(false);
});
it('ignores an unused CSS paint rule that matches no element', () => {
const svg =
'<svg viewBox="0 0 24 24"><style>.unused{fill:#f00}.icon{fill:#333}</style><path class="icon" d="M4 4h16v16H4z" /></svg>';
expect(isMonochromeSvg(svg)).toBe(true);
});
it('ignores a CSS paint that only targets a hidden element', () => {
const svg =
'<svg viewBox="0 0 24 24"><style>.ghost{fill:#f00;display:none}.icon{fill:#333}</style><path class="ghost" d="M0 0h4v4H0z" /><path class="icon" d="M6 6h12v12H6z" /></svg>';
expect(isMonochromeSvg(svg)).toBe(true);
});
it('preserves a chromatic fill applied to the root via CSS', () => {
const svg =
'<svg viewBox="0 0 24 24"><style>svg{fill:#f00}</style><path d="M4 4h16v16H4z" /></svg>';
expect(isMonochromeSvg(svg)).toBe(false);
});
});
describe('default fills alongside <style> rules', () => {

View file

@ -77,9 +77,9 @@ const RESOLVED_DECLS = new Set([
/** A `<style>` rule reduced to the paint/opacity declarations we resolve. */
type StyleRule = { selector: string; declarations: Map<string, string> };
/** Matches paint declarations inside a `<style>` block (not `color`, which only
* resolves `currentColor` rather than painting on its own). */
const CSS_COLOR_REGEX = /(?:fill|stroke|stop-color)\s*:\s*([^;}]+)/gi;
/** Matches paint declarations inside a `<style>` block, capturing property and
* value (not `color`, which only resolves `currentColor` rather than painting). */
const CSS_COLOR_REGEX = /(fill|stroke|stop-color)\s*:\s*([^;}]+)/gi;
function hexToRgb(hex: string): [number, number, number] | null {
let value = hex.slice(1);
@ -306,10 +306,11 @@ function colorsFromStyleBlocks(root: Element, rules: StyleRule[]): string[] {
}
const selector = rule.slice(0, brace).trim();
for (const match of rule.slice(brace + 1).matchAll(CSS_COLOR_REGEX)) {
const value = match[1].trim();
const property = match[1].toLowerCase();
const value = match[2].trim();
if (value.toLowerCase() === CURRENT_COLOR) {
colors.push(...resolveCssCurrentColor(root, selector, rules));
} else {
} else if (selectorPaintsRendered(root, selector, rules, property)) {
colors.push(value);
}
}
@ -318,6 +319,39 @@ function colorsFromStyleBlocks(root: Element, rules: StyleRule[]): string[] {
return colors;
}
/**
* True when a CSS selector matches at least one element that actually paints: it
* is rendered (not `display:none`), is not inside a functional template, and the
* paint is not made invisible by opacity. Unused or hidden rules contribute no
* tone. The root element is checked too, since `querySelectorAll` only walks
* descendants.
*/
function selectorPaintsRendered(
root: Element,
selector: string,
rules: StyleRule[],
paintProp: string,
): boolean {
if (selector === '') {
return false;
}
let matched: Element[];
try {
matched = Array.from(root.querySelectorAll(selector));
if (root.matches(selector)) {
matched = [root, ...matched];
}
} catch {
return true;
}
return matched.some(
(el) =>
!isHidden(el, root, rules) &&
!isInside(el, root, FUNCTIONAL_CONTAINERS) &&
!paintInvisible(el, root, rules, paintProp),
);
}
/**
* Resolves a `currentColor` paint to the fixed `color` set on the element or an
* ancestor (inline style, attribute, or CSS), since that is what the icon actually