Skip to content

Commit 16293db

Browse files
fix(css): decline a polar mix a gray reaches with no hue
The browser tier over web-platform-tests caught the two divergences left. `color-mix(in hsl, green 50%, white)` folded to the hue halfway between green's 120 and the zero white lands on, where the engine leaves an achromatic color's hue missing and interpolates the other's own (CSS Color 4 §12.2): webpack wrote `#cfcf70` for the `#70cf70` Chromium paints. A hue written beside a zero saturation is lost through sRGB the same way, so a mix in a space with a hue channel is declined wherever either color is achromatic. The other is the tier's own. Its value tier compared a computed value as text, while every other comparison paints the colors the value holds — and Chromium echoes the color `image()` was written with rather than computing it, so `image(red)` and the relative color naming that same red read as two values. The nested-color painting is one helper both tiers use now, and it counts a bare keyword inside a call — never `currentcolor`, whose pixel is the element's own. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_018tNx7sU4SbnKFhFeofDVr5
1 parent 5aa803a commit 16293db

4 files changed

Lines changed: 84 additions & 18 deletions

File tree

‎lib/css/syntax.js‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8780,6 +8780,16 @@ const _foldColorMix = (inner, hexAlpha, forFallback) => {
87808780
if (color === null) return null;
87818781
weights.push(percent);
87828782
colors.push(color);
8783+
// Achromatic, so a polar space gives it no hue: the engine leaves the hue
8784+
// missing and interpolates the other color's own (CSS Color 4 §12.2), and a
8785+
// hue written in that space is lost on the way through sRGB regardless.
8786+
if (
8787+
space.hue !== -1 &&
8788+
color.linear[0] === color.linear[1] &&
8789+
color.linear[1] === color.linear[2]
8790+
) {
8791+
return null;
8792+
}
87838793
}
87848794
// An omitted percentage is what the other leaves; both omitted is an even mix.
87858795
if (weights[0] < 0 && weights[1] < 0) weights[0] = weights[1] = 50;

‎test/CssSyntax.unittest.js‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8083,6 +8083,24 @@ describe("CssSyntax minify — color-mix()", () => {
80838083
).toBe("a{color:#993a33}");
80848084
});
80858085

8086+
it("keeps a polar mix an achromatic color reaches with no hue", () => {
8087+
// A gray converts into hsl and hwb with its hue missing, and the engine
8088+
// interpolates the other color's own rather than the zero it lands on.
8089+
for (const css of [
8090+
"color-mix(in hsl,green,#fff)",
8091+
"color-mix(in hsl,green,#000)",
8092+
"color-mix(in hwb,green,gray)",
8093+
// ...and a hue written beside a zero saturation is lost the same way.
8094+
"color-mix(in hsl,green,hsl(50 0% 100%))"
8095+
]) {
8096+
expect(minify(`a{color:${css}}`)).toBe(`a{color:${css}}`);
8097+
}
8098+
// A space with no hue takes the same color and folds the mix as before.
8099+
expect(minify("a{color:color-mix(in srgb,#000 25%,#fff)}")).toBe(
8100+
"a{color:#bfbfbf}"
8101+
);
8102+
});
8103+
80868104
it("reads its own hue method, not one a nested mix left behind", () => {
80878105
// The nested mix resolves through this same fold, so a method held in module
80888106
// state would be the inner one's by the time the outer interpolates.

‎test/helpers/syntaxEquivalence.js‎

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ const buildCorpus = (dir, extension, minify) => {
7575
* @property {(conditions: string[]) => string[]} supportsSignatures whether each support condition holds
7676
* @property {(tagName: string, attribute: string, value: string | null) => [string | undefined, unknown]} probeReflection the IDL member an attribute reflects, and its value
7777
* @property {(value: string) => string} canonical a value under the one name the spec gives it
78+
* @property {(value: string) => string} paintedColors a value with every color it holds painted
7879
*/
7980

8081
/**
@@ -181,6 +182,49 @@ const installHelpers = () => {
181182
return out + rewrite(run);
182183
};
183184

185+
// A color token, and the whole of a color function that holds no call of its
186+
// own — which is every spelling but a nested `calc()`.
187+
const COLOR_TOKEN_RE =
188+
/#[\da-f]{3,8}\b|\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color|color-mix)\([^()]*\)/gi;
189+
190+
// One word, so a number with its unit is read whole rather than as an ident.
191+
const WORD_RE = /[\w-]/;
192+
193+
/**
194+
* Every color a value holds, as the pixel it paints. A color the engine hands
195+
* back as written — a `var()` fallback, the one `image()` carries — is a color
196+
* still, and two spellings of it are one value. A bare keyword counts only
197+
* inside a call, where it is an argument: `font-family:red` names a family.
198+
* `currentcolor` is never painted, since the pixel it takes is the element's.
199+
* @param {string} value a value
200+
* @returns {string} the value, its colors painted
201+
*/
202+
const paintedColors = (value) =>
203+
outsideText(value, (run) => {
204+
let out = "";
205+
let word = "";
206+
let depth = 0;
207+
const take = () => {
208+
out +=
209+
depth > 0 && word !== "" && !/^currentcolor$/i.test(word)
210+
? painted(word)
211+
: word;
212+
word = "";
213+
};
214+
for (const ch of run.replace(COLOR_TOKEN_RE, (color) => painted(color))) {
215+
if (WORD_RE.test(ch)) {
216+
word += ch;
217+
continue;
218+
}
219+
take();
220+
if (ch === "(") depth++;
221+
else if (ch === ")" && depth > 0) depth--;
222+
out += ch;
223+
}
224+
take();
225+
return out;
226+
});
227+
184228
// The three code points CSS Syntax §3.3 calls a newline, `\r\n` included.
185229
const NEWLINE = /[\n\r\f]/;
186230

@@ -364,16 +408,9 @@ const installHelpers = () => {
364408
// CSS Color 4 §5: `transparent` is that color written as a keyword, and an
365409
// engine echoing a descriptor hands back whichever spelling it was given.
366410
out = out.replace(/(^|[^\w-])transparent(?![\w-])/gi, "$1rgba(0, 0, 0, 0)");
367-
// A color written into a value the engine cannot compute — a `var()`
368-
// fallback — is still a color, and `#ff0` is `rgb(255,255,0)`.
369411
// A string is text and a `url()` body names something, so neither holds a
370412
// color: `url(#fff)` and `url(#ffffff)` are two different elements.
371-
out = outsideText(out, (run) =>
372-
run.replace(
373-
/#[\da-f]{3,8}\b|\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color)\([^()]*\)/gi,
374-
(color) => painted(color)
375-
)
376-
);
413+
out = paintedColors(out);
377414
return (
378415
out
379416
// Nothing fuses with a comma or a block's delimiters, so the whitespace
@@ -1202,7 +1239,8 @@ const installHelpers = () => {
12021239
containerSignatures,
12031240
supportsSignatures,
12041241
probeReflection,
1205-
canonical
1242+
canonical,
1243+
paintedColors
12061244
};
12071245
};
12081246

‎test/syntaxEquivalence.spectest.js‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -690,9 +690,9 @@ describe("printer output in real Chrome", () => {
690690
const compareValues = (cases) =>
691691
inBatches(probePage, cases, (batch) =>
692692
probePage.evaluate((each) => {
693-
const { canonical } = /** @type {{ __eq: PageHelpers }} */ (
694-
/** @type {unknown} */ (window)
695-
).__eq;
693+
const { canonical, paintedColors } =
694+
/** @type {{ __eq: PageHelpers }} */ (/** @type {unknown} */ (window))
695+
.__eq;
696696
const probe = document.createElement("div");
697697
document.body.append(probe);
698698
// Computed values, not `cssText`: `left bottom` and `0% 100%` are one
@@ -720,12 +720,12 @@ describe("printer output in real Chrome", () => {
720720
// boundary ("ab"+"c" against "a"+"bc") reads as equal.
721721
let out = "";
722722
for (const name of names) {
723-
// Under the one name the spec gives the value: the engine echoes
724-
// the spelling it was handed — `jump-start` beside `start`, a
725-
// gradient's implied last stop beside a written one — so without
726-
// this the tier reads a synonym as a change of meaning.
727-
out += `${name}:${canonical(
728-
computed.getPropertyValue(name)
723+
// Under the one name the spec gives the value, its colors painted:
724+
// the engine echoes the spelling it was handed — `jump-start`
725+
// beside `start`, the color `image()` carries — so without this
726+
// the tier reads a synonym as a change of meaning.
727+
out += `${name}:${paintedColors(
728+
canonical(computed.getPropertyValue(name))
729729
)}\u0000`;
730730
}
731731
return out;

0 commit comments

Comments
 (0)