Skip to content

feat(css): evaluate color(), color-mix() and relative colors - #21928

Merged
alexander-akait merged 3 commits into
feat/css-unused-symbolsfrom
feat/css-evaluate-color-functions
Sep 3, 2026
Merged

alexander-akait merged 3 commits into
feat/css-unused-symbolsfrom
feat/css-evaluate-color-functions

Conversation

@alexander-akait

Copy link
Copy Markdown
Member

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 new optimization.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 in test/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

@changeset-bot

changeset-bot Bot commented Sep 3, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 678eb91

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
webpack Minor

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

@coderabbitai

coderabbitai Bot commented Sep 3, 2026 •

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Team

Run ID: d6907c9c-84c5-4b68-a29a-cc75086e9b47

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added area: config Options, defaults, validation (lib/config, schemas/) area: css CSS support (lib/css) area: html Experimental HTML support (lib/html) area: types types.d.ts, JSDoc annotations, hand-maintained declarations area: tooling Repo tooling, codegen, CLI entry (tooling/, bin/) labels Sep 3, 2026
@alexander-akait
alexander-akait force-pushed the feat/css-evaluate-color-functions branch from 31ef77e to 58e8cae Compare September 3, 2026 08:52
…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.
@alexander-akait
alexander-akait force-pushed the feat/css-evaluate-color-functions branch from 58e8cae to 678eb91 Compare September 3, 2026 09:22
@alexander-akait
alexander-akait merged commit b8cd632 into feat/css-unused-symbols Sep 3, 2026
6 checks passed
@alexander-akait
alexander-akait deleted the feat/css-evaluate-color-functions branch September 3, 2026 11:22
alexander-akait added a commit that referenced this pull request Sep 3, 2026
* 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.
alexander-akait added a commit that referenced this pull request Sep 3, 2026
…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]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: config Options, defaults, validation (lib/config, schemas/) area: css CSS support (lib/css) area: html Experimental HTML support (lib/html) area: tooling Repo tooling, codegen, CLI entry (tooling/, bin/) area: types types.d.ts, JSDoc annotations, hand-maintained declarations

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant