Repository navigation
feat(css): evaluate color(), color-mix() and relative colors - #21928
Conversation
🦋 Changeset detectedLatest commit: 678eb91 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Team Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
9d0c5b8 to
89355e2
Compare
31ef77e to
58e8cae
Compare
…er four more spellings The color work rests on one table: every predefined color space CSS Color 4 §10 names, derived in the generator from the primaries, white point and transfer function the spec states, as the pair of functions taking linear-light sRGB to its components and back. `lib/css/syntax.js` implements no color space of its own — it reads a color into a space, works one component at a time, and reads it back — and a space added to the generator needs no arithmetic beside it. On that: - `color(<space> …)` folds to the sRGB it names, and gets the same fallback the Lab family does where the target reads no `color()`. - A color sRGB cannot show now takes a `color(display-p3 …)` rung between the clipped hex and the author's own, wherever the target reads one — so an engine reading no Lab family still gets the wider gamut. Skipped where P3 cannot show it either, which is a color no fallback preserves. - `color-mix()` is evaluated in any of the fifteen interpolation spaces, with the four hue methods, percentages either side of a color, and the alphas premultiplied as CSS Color 5 §3 states. - A relative color (`rgb(from rebeccapurple r calc(g * 2) b)`) is resolved against its origin: the channels are bound to the function's own keywords and each argument read against them, an expression included. And three more spellings the target decides: - `system-ui` is written as the stack each platform's own engine reads. - `text-decoration` naming more than its line becomes the line in the shorthand — which is what resets the family — and a longhand per other slot. - `optimization.minimize.css.pseudoClasses` writes a pseudo-class as an ordinary class, at any depth, so a script can apply it where the engine reads none. Every fold declines rather than guesses: a component that cannot be pinned down, a channel on a `.5` boundary, a result outside the gamut it would be written in. A mix in a space reached from sRGB by arithmetic alone is held to the tight boundary margin rather than the Lab family's wide one.
…and hold every lowering to a real engine `light-dark()` names a color per scheme. For a target that does not read it, the call becomes `var(--webpack-light, A) var(--webpack-dark, B)` — `initial` is the guaranteed-invalid value, so the property set to it takes the fallback and the one set to nothing substitutes nothing — and every rule that says which scheme it is in says it again as that pair, with a `(prefers-color-scheme:dark)` copy where it hands the choice to the user. Two things it does that the ecosystem's version does not. A stylesheet that names no `color-scheme` at all still reads the light half, from a `:where(:root)` rule of no specificity — elsewhere that case leaves a declaration the engine drops. And the call is left alone where a half names a color the target cannot read: substituted, that is invalid at computed-value time, which is `unset` rather than the declaration before it. The whole rewrite needs `:where()`, so a target reading neither is one it leaves alone. That last point is a defect this turn's new check found in what shipped before it: a color fallback pair standing before a value a substitution reaches is bytes nothing ever reads, and is no longer written. The check is the other half of this commit. `syntaxEquivalence.spectest.js` now holds every lowering to a real engine: one that reads both spellings has to compute the same style from either, across color schemes and writing directions, with colors compared as painted rather than as text — the engine keeps a mix at the precision it computed it, while the printer writes the byte it lands on. Beside it, a unit test pins the browser version each rewrite turns on at, either side of the one the compat data gives.
…it means `:not(a, b)` matches what neither of them matches, which is what `:not(:is(a, b))` says — and `:is()` carries the specificity of its most specific argument, which is the specificity the list inside `:not()` has. `:lang(a, b)` matches either, which is `:is(:lang(a), :lang(b))`. Both need `:is()` itself, so a target reading neither keeps what the author wrote. Each has its own window. Gecko took `:is()` six releases before it took a list inside `:not()`, so that rewrite is written for Firefox 78 through 83 and no other target. No Chromium has ever taken a list inside `:lang()`, so every Chrome target is written the second way. The `:lang()` one is held to a reference rather than to the engine's reading of the source: Chromium reads no spelling of `:lang(en, fr)` at all, so the equivalence check compares the rewrite against the pair of rules that state the same thing.
89355e2 to
2ee1eb5
Compare
58e8cae to
678eb91
Compare
* feat(css): evaluate color(), color-mix() and relative colors, and lower four more spellings The color work rests on one table: every predefined color space CSS Color 4 §10 names, derived in the generator from the primaries, white point and transfer function the spec states, as the pair of functions taking linear-light sRGB to its components and back. `lib/css/syntax.js` implements no color space of its own — it reads a color into a space, works one component at a time, and reads it back — and a space added to the generator needs no arithmetic beside it. On that: - `color(<space> …)` folds to the sRGB it names, and gets the same fallback the Lab family does where the target reads no `color()`. - A color sRGB cannot show now takes a `color(display-p3 …)` rung between the clipped hex and the author's own, wherever the target reads one — so an engine reading no Lab family still gets the wider gamut. Skipped where P3 cannot show it either, which is a color no fallback preserves. - `color-mix()` is evaluated in any of the fifteen interpolation spaces, with the four hue methods, percentages either side of a color, and the alphas premultiplied as CSS Color 5 §3 states. - A relative color (`rgb(from rebeccapurple r calc(g * 2) b)`) is resolved against its origin: the channels are bound to the function's own keywords and each argument read against them, an expression included. And three more spellings the target decides: - `system-ui` is written as the stack each platform's own engine reads. - `text-decoration` naming more than its line becomes the line in the shorthand — which is what resets the family — and a longhand per other slot. - `optimization.minimize.css.pseudoClasses` writes a pseudo-class as an ordinary class, at any depth, so a script can apply it where the engine reads none. Every fold declines rather than guesses: a component that cannot be pinned down, a channel on a `.5` boundary, a result outside the gamut it would be written in. A mix in a space reached from sRGB by arithmetic alone is held to the tight boundary margin rather than the Lab family's wide one. * feat(css): write light-dark() as the pair the color scheme switches, and hold every lowering to a real engine `light-dark()` names a color per scheme. For a target that does not read it, the call becomes `var(--webpack-light, A) var(--webpack-dark, B)` — `initial` is the guaranteed-invalid value, so the property set to it takes the fallback and the one set to nothing substitutes nothing — and every rule that says which scheme it is in says it again as that pair, with a `(prefers-color-scheme:dark)` copy where it hands the choice to the user. Two things it does that the ecosystem's version does not. A stylesheet that names no `color-scheme` at all still reads the light half, from a `:where(:root)` rule of no specificity — elsewhere that case leaves a declaration the engine drops. And the call is left alone where a half names a color the target cannot read: substituted, that is invalid at computed-value time, which is `unset` rather than the declaration before it. The whole rewrite needs `:where()`, so a target reading neither is one it leaves alone. That last point is a defect this turn's new check found in what shipped before it: a color fallback pair standing before a value a substitution reaches is bytes nothing ever reads, and is no longer written. The check is the other half of this commit. `syntaxEquivalence.spectest.js` now holds every lowering to a real engine: one that reads both spellings has to compute the same style from either, across color schemes and writing directions, with colors compared as painted rather than as text — the engine keeps a mix at the precision it computed it, while the printer writes the byte it lands on. Beside it, a unit test pins the browser version each rewrite turns on at, either side of the one the compat data gives. * feat(css): write a selector list a pseudo does not take as the :is() it means `:not(a, b)` matches what neither of them matches, which is what `:not(:is(a, b))` says — and `:is()` carries the specificity of its most specific argument, which is the specificity the list inside `:not()` has. `:lang(a, b)` matches either, which is `:is(:lang(a), :lang(b))`. Both need `:is()` itself, so a target reading neither keeps what the author wrote. Each has its own window. Gecko took `:is()` six releases before it took a list inside `:not()`, so that rewrite is written for Firefox 78 through 83 and no other target. No Chromium has ever taken a list inside `:lang()`, so every Chrome target is written the second way. The `:lang()` one is held to a reference rather than to the engine's reading of the source: Chromium reads no spelling of `:lang(en, fr)` at all, so the equivalence check compares the rewrite against the pair of rules that state the same thing.
…l cells (#21927) * feat(css): take out @charset, squeeze grid null cells, name a matrix, add unusedSymbols Four rewrites the minifier did not make: - `@charset` names the encoding the *source* was read in, and what is written out is UTF-8 whatever it said — so it is at best 22 dead bytes and at worst an encoding the output is not in. A `@charset` carrying a block is no charset rule and still keeps its case, which is what the sniffer reads. - A `grid-template-areas` cell name is a `<custom-ident>` and holds no `.`, so a run of them is the one null cell token it already is: `"foot ...."` is `"foot ."`, and the column count is unchanged. - A matrix says in six numbers what a named function says in one or two, and every shape read back out is exact: a translation, a scale, and a rotation at a quarter turn, which is the only angle where the matrix is rational. A 3D one that is only a translation or a scale names its own function too. - `optimization.minimize.css.unusedSymbols` takes out what a whole-project analysis found nothing uses: a bare name is matched against every class and id a selector names and against every `@keyframes` name, a `--`-prefixed one against every custom property a declaration sets. A selector list keeps the selectors that do not name one, and a rule left with none goes — but a name a functional pseudo holds is not one the rule needs, so `:not(.gone)` and `:is(.gone, .kept)` are left alone. The inline CSS the HTML minimizer hands over reads the same list. * feat(css): evaluate color(), color-mix() and relative colors (#21928) * feat(css): evaluate color(), color-mix() and relative colors, and lower four more spellings The color work rests on one table: every predefined color space CSS Color 4 §10 names, derived in the generator from the primaries, white point and transfer function the spec states, as the pair of functions taking linear-light sRGB to its components and back. `lib/css/syntax.js` implements no color space of its own — it reads a color into a space, works one component at a time, and reads it back — and a space added to the generator needs no arithmetic beside it. On that: - `color(<space> …)` folds to the sRGB it names, and gets the same fallback the Lab family does where the target reads no `color()`. - A color sRGB cannot show now takes a `color(display-p3 …)` rung between the clipped hex and the author's own, wherever the target reads one — so an engine reading no Lab family still gets the wider gamut. Skipped where P3 cannot show it either, which is a color no fallback preserves. - `color-mix()` is evaluated in any of the fifteen interpolation spaces, with the four hue methods, percentages either side of a color, and the alphas premultiplied as CSS Color 5 §3 states. - A relative color (`rgb(from rebeccapurple r calc(g * 2) b)`) is resolved against its origin: the channels are bound to the function's own keywords and each argument read against them, an expression included. And three more spellings the target decides: - `system-ui` is written as the stack each platform's own engine reads. - `text-decoration` naming more than its line becomes the line in the shorthand — which is what resets the family — and a longhand per other slot. - `optimization.minimize.css.pseudoClasses` writes a pseudo-class as an ordinary class, at any depth, so a script can apply it where the engine reads none. Every fold declines rather than guesses: a component that cannot be pinned down, a channel on a `.5` boundary, a result outside the gamut it would be written in. A mix in a space reached from sRGB by arithmetic alone is held to the tight boundary margin rather than the Lab family's wide one. * feat(css): write light-dark() as the pair the color scheme switches, and hold every lowering to a real engine `light-dark()` names a color per scheme. For a target that does not read it, the call becomes `var(--webpack-light, A) var(--webpack-dark, B)` — `initial` is the guaranteed-invalid value, so the property set to it takes the fallback and the one set to nothing substitutes nothing — and every rule that says which scheme it is in says it again as that pair, with a `(prefers-color-scheme:dark)` copy where it hands the choice to the user. Two things it does that the ecosystem's version does not. A stylesheet that names no `color-scheme` at all still reads the light half, from a `:where(:root)` rule of no specificity — elsewhere that case leaves a declaration the engine drops. And the call is left alone where a half names a color the target cannot read: substituted, that is invalid at computed-value time, which is `unset` rather than the declaration before it. The whole rewrite needs `:where()`, so a target reading neither is one it leaves alone. That last point is a defect this turn's new check found in what shipped before it: a color fallback pair standing before a value a substitution reaches is bytes nothing ever reads, and is no longer written. The check is the other half of this commit. `syntaxEquivalence.spectest.js` now holds every lowering to a real engine: one that reads both spellings has to compute the same style from either, across color schemes and writing directions, with colors compared as painted rather than as text — the engine keeps a mix at the precision it computed it, while the printer writes the byte it lands on. Beside it, a unit test pins the browser version each rewrite turns on at, either side of the one the compat data gives. * feat(css): write a selector list a pseudo does not take as the :is() it means `:not(a, b)` matches what neither of them matches, which is what `:not(:is(a, b))` says — and `:is()` carries the specificity of its most specific argument, which is the specificity the list inside `:not()` has. `:lang(a, b)` matches either, which is `:is(:lang(a), :lang(b))`. Both need `:is()` itself, so a target reading neither keeps what the author wrote. Each has its own window. Gecko took `:is()` six releases before it took a list inside `:not()`, so that rewrite is written for Firefox 78 through 83 and no other target. No Chromium has ever taken a list inside `:lang()`, so every Chrome target is written the second way. The `:lang()` one is held to a reference rather than to the engine's reading of the source: Chromium reads no spelling of `:lang(en, fr)` at all, so the equivalence check compares the rewrite against the pair of rules that state the same thing. * test(css): type the hue-method helper the strict test project checks `tsconfig.types.test.json` runs with `checkJs` and `strict`, so the helper's parameters and the two color pairs spread into it need their types stated. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_018tNx7sU4SbnKFhFeofDVr5 * fix(css): write an opaque wide-gamut fallback without an alpha, and keep a mix's own hue method Three findings from the review, two of them colors an engine reads differently: - `_wideGamutColor` was the third caller of `_alphaByteText` and the one still passing it `255`: an alpha above 254.5/255 quantizes to the opaque byte, and the helper answers `.` for it, so `color(display-p3 … / .)` reached a target that reads `color()` but no Lab family. The alpha is quantized before it is written now, and an opaque one is left off. - `_foldColorMix` held the hue interpolation method in module state across the two `_resolveColor` calls, and a nested `color-mix()` folds through the same function — so the inner method decided the outer mix. It is held locally until both colors resolve. - the mark `unusedSymbols` leaves on a rule is now read whichever reason drops an empty rule, rather than after a `||` that a qualified rule short-circuits: an unread mark rides the node back into the pool, which is the hazard `_takeUnusedRule` documents. The two changesets this pull request added are one, as the guide asks. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_018tNx7sU4SbnKFhFeofDVr5 * fix(css): write no pseudo-class as a class after a pseudo-element Selectors 4 §3.5: a pseudo-element takes only pseudo-classes after it, so the class `pseudoClasses` writes in one's place matches nothing there. `a::before:hover` became `a:before.hovered` — a rule the engine reads and then applies to no element, which is worse than the pseudo it replaced. The compound a pseudo-element opens is left as written now, whichever colon it was authored with and whether or not the fold shortened it; the compounds around it still take the class. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_018tNx7sU4SbnKFhFeofDVr5 * test(css): shorten the four comments this branch left over two lines The guide caps a comment in `test/` at two short lines; these ran to four. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_018tNx7sU4SbnKFhFeofDVr5 * test(css): drive the conversions this branch added but never exercised `lib/css/data.js` carries a transfer and a matrix per predefined space, and the suite reached neither ProPhoto's nor HWB's: the mix and the `color()` fold that read them had no case. Line coverage of the generated file goes 95.07% to 96.14%, and its functions 78.87% to 83.09%. The two spaces Chromium reads through a function of its own are covered by the decline they take instead. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_018tNx7sU4SbnKFhFeofDVr5 * fix(css): treat a pseudo-element taking arguments as one too `::part()` and `::slotted()` print as a single token with their arguments attached, so the branch reading those arguments ran before the one that spots a pseudo-element and the boundary was never set: `::part(label):hover` became `::part(label).hovered`, which matches nothing. The second colon decides it now, whether or not the name takes arguments — `:not(.x):hover` is a functional pseudo-class and still takes the class it was given. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_018tNx7sU4SbnKFhFeofDVr5 * fix(css): fold no color an engine reads as invalid The browser tier over web-platform-tests caught three spellings the fold read where an engine drops the declaration, so webpack painted a color where the page showed none: - `color()` took `,` and `/` for whitespace, so `color(srgb 0, 0, 0)` and `color(srgb 1 1 1 1)` both parsed. The grammar has one slash, before the alpha, and no comma at all. - a `color-mix()` percentage outside `[0,100]` (CSS Color 5 §2.1) folded: one over 100 left the other negative where it was omitted, and Chromium refuses the pair even where both are written. Each rewrite in the group, and every valid spelling beside it, was painted in headless Chromium beside the value it replaced: 17 of 17 agree. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_018tNx7sU4SbnKFhFeofDVr5 * 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 --------- Co-authored-by: Claude Opus 5 <[email protected]>
Summary
color(),color-mix()and a relative color (rgb(from …)) were printed as written. They are now evaluated to the color they name where the answer is a byte an engine computes for itself,light-dark()is written as the pair a color scheme switches between, and a selector list a pseudo-class does not take is written as the:is()it means — behind the newoptimization.minimize.css.pseudoClasses.Stacked on #21927 — review the top three commits only.
What kind of change does this PR introduce?
feat
Did you add tests for your changes?
Yes —
test/CssSyntax.unittest.js,test/configCases/css/minimize-colors/,test/configCases/css/minimize-pseudo-classes/, and the browser tier intest/syntaxEquivalence.spectest.js.Does this PR introduce a breaking change?
No.
If relevant, what needs to be documented once your changes are merged or what have you already documented?
optimization.minimize.css.pseudoClasses.Use of AI
Yes. Written with Claude Code: it implemented the evaluation, wrote the tests, and painted every rewrite in headless Chromium beside the value it replaced; every change was reviewed before pushing.
🤖 Generated with Claude Code
https://claude.ai/code/session_018tNx7sU4SbnKFhFeofDVr5
Generated by Claude Code