Skip to content

fix(css): convert a color only as far as the byte is the engine's own - #21930

Merged
alexander-akait merged 2 commits into
perf/css-shorthand-slots-and-math-foldingfrom
fix/css-color-conversion-margin
Sep 3, 2026
Merged

alexander-akait merged 2 commits into
perf/css-shorthand-slots-and-math-foldingfrom
fix/css-color-conversion-margin

Conversation

@alexander-akait

Copy link
Copy Markdown
Member

Summary

The Lab family converted to a hex under a flat 0.1-byte margin. Measured against Chromium over 91k channels, an engine's transfer function is a fitted curve rather than the spec's and a matrix carries that into every term, so the disagreement is relative to the color's own magnitude — a sixteenth of a byte at white, more than a whole byte near black — and 0.15% of the colors that margin converted painted a byte an engine did not. The margin now follows the transfer's slope, a98-rgb and ProPhoto are left alone (Chromium reads their transfer through a different function), a gray's missing hue is honoured in a mix (CSS Color 4 §12.2), and a relative color reads its origin's channels as the numbers they name rather than as the bytes they round to.

tooling/measure-color-agreement.js (yarn measure:color-agreement) re-derives both constants and the disagreeing-space list from a live browser, so they are reproducible rather than hand-tuned.

Stacked on #21929 — review the top two commits only.

What kind of change does this PR introduce?

fix

Did you add tests for your changes?

Yes — test/CssSyntax.unittest.js, test/configCases/css/minimize-colors/, and a seeded 20000-sample sweep in test/syntaxEquivalence.spectest.js that paints each rewrite in headless Chromium beside the color it replaced (15890 rewrites, none differ).

Does this PR introduce a breaking change?

No — strictly fewer conversions than before, and the ones dropped were the wrong color.

If relevant, what needs to be documented once your changes are merged or what have you already documented?

n/a — AGENTS.md documents the measuring tool.

Use of AI

Yes. Written with Claude Code: it measured the disagreement in a real browser, derived the slope-following margin, and wrote the sweep; every change was reviewed and the measurement re-run 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: eb56f10

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

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

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: 5f9080cf-50cd-430a-883d-ff2bc19c2498

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: css CSS support (lib/css) area: tooling Repo tooling, codegen, CLI entry (tooling/, bin/) labels Sep 3, 2026
@alexander-akait
alexander-akait force-pushed the perf/css-shorthand-slots-and-math-folding branch from 8768090 to 3a0d61d Compare September 3, 2026 08:52
@alexander-akait
alexander-akait force-pushed the fix/css-color-conversion-margin branch from 30496c4 to 6b15737 Compare September 3, 2026 08:52
alexander-akait and others added 2 commits September 3, 2026 09:04
Measured against Chromium over 91k channels: an engine's transfer function is a
fitted curve rather than the spec's, and a conversion through a matrix carries
that into every term, so the disagreement is relative to the color's own
magnitude — a sixteenth of a byte at white, more than a whole byte near black.
The flat 0.1-byte margin the Lab family used covered neither, and 0.15% of the
colors it converted painted a byte an engine did not. The margin now follows the
transfer's slope, `color(srgb …)` takes the tight one it always deserved, and
a98-rgb and ProPhoto are left alone: Chromium reads their transfer through a
different function altogether, which no margin bridges.

Two more colors were not the ones an engine computes:

- a gray states no hue, and an engine reads that component as missing and takes
  the other color's rather than mixing the two (CSS Color 4 §12.2),
- a mix or a relative color no byte holds is now written in the space it was
  computed in, where every color that went into it was written there too — the
  one case the numbers are the engine's own — and only where that is shorter
  than the call it replaces.

`system-ui` is lowered inside the `font` shorthand as well, out of the
grammars: the properties that take a family list are the ones referencing it.

Verified over 15854 rewrites of random colors in every spelling, each painted
in headless Chromium beside the color it replaced: none differ.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018tNx7sU4SbnKFhFeofDVr5
CSS Color 5 §4.1 substitutes a channel keyword as a `<number>`, so `calc(h + 40)`
reduces to no unit at all and a hue slot reads it as degrees — the printer asked
for an angle there and left every expression over a hue written out. An angle
still stands where no keyword is mixed into it, and mixing the two is no value,
which stays declined.

The margins the color conversions rest on are now re-derivable rather than
remembered: `yarn measure:color-agreement` asks an engine for its own conversion
instead of for a pixel, prints how far it sits from webpack's in linear light and
through the transfer alone, and names the spaces it reads through a different
transfer function. It reproduces both constants and both spaces.

The verification itself is a test rather than a session's own run: the syntax
equivalence suite now sweeps a seeded corpus of ~12k color rewrites — every
spelling, mixes and relative references two levels deep — and paints each beside
the color it replaced, in six seconds.
@alexander-akait
alexander-akait force-pushed the perf/css-shorthand-slots-and-math-folding branch from 3a0d61d to ceaa051 Compare September 3, 2026 09:22
@alexander-akait
alexander-akait force-pushed the fix/css-color-conversion-margin branch from 6b15737 to eb56f10 Compare September 3, 2026 09:22
@alexander-akait
alexander-akait merged commit fa00dad into perf/css-shorthand-slots-and-math-folding Sep 3, 2026
6 checks passed
@alexander-akait
alexander-akait deleted the fix/css-color-conversion-margin branch September 3, 2026 11:24
alexander-akait added a commit that referenced this pull request Sep 3, 2026
…#21930)

* fix(css): convert a color only as far as the byte is the engine's own

Measured against Chromium over 91k channels: an engine's transfer function is a
fitted curve rather than the spec's, and a conversion through a matrix carries
that into every term, so the disagreement is relative to the color's own
magnitude — a sixteenth of a byte at white, more than a whole byte near black.
The flat 0.1-byte margin the Lab family used covered neither, and 0.15% of the
colors it converted painted a byte an engine did not. The margin now follows the
transfer's slope, `color(srgb …)` takes the tight one it always deserved, and
a98-rgb and ProPhoto are left alone: Chromium reads their transfer through a
different function altogether, which no margin bridges.

Two more colors were not the ones an engine computes:

- a gray states no hue, and an engine reads that component as missing and takes
  the other color's rather than mixing the two (CSS Color 4 §12.2),
- a mix or a relative color no byte holds is now written in the space it was
  computed in, where every color that went into it was written there too — the
  one case the numbers are the engine's own — and only where that is shorter
  than the call it replaces.

`system-ui` is lowered inside the `font` shorthand as well, out of the
grammars: the properties that take a family list are the ones referencing it.

Verified over 15854 rewrites of random colors in every spelling, each painted
in headless Chromium beside the color it replaced: none differ.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018tNx7sU4SbnKFhFeofDVr5

* fix(css): read a relative color's channel keyword as the number it names

CSS Color 5 §4.1 substitutes a channel keyword as a `<number>`, so `calc(h + 40)`
reduces to no unit at all and a hue slot reads it as degrees — the printer asked
for an angle there and left every expression over a hue written out. An angle
still stands where no keyword is mixed into it, and mixing the two is no value,
which stays declined.

The margins the color conversions rest on are now re-derivable rather than
remembered: `yarn measure:color-agreement` asks an engine for its own conversion
instead of for a pixel, prints how far it sits from webpack's in linear light and
through the transfer alone, and names the spaces it reads through a different
transfer function. It reproduces both constants and both spaces.

The verification itself is a test rather than a session's own run: the syntax
equivalence suite now sweeps a seeded corpus of ~12k color rewrites — every
spelling, mixes and relative references two levels deep — and paints each beside
the color it replaced, in six seconds.

---------

Co-authored-by: Claude Opus 5 <[email protected]>
alexander-akait added a commit that referenced this pull request Sep 3, 2026
…o one

The stack collapsed #21930 into this branch, so its changeset arrived beside
this one. The guide asks for a single entry per pull request at the highest
bump the changes need.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018tNx7sU4SbnKFhFeofDVr5
alexander-akait added a commit that referenced this pull request Sep 3, 2026
#21929)

* perf(css): take out a shorthand slot that holds the value an unwritten one takes

A slot nothing writes takes its initial anyway, so writing it says nothing.
`background: 0% 0% / auto repeat scroll padding-box border-box red` is
`background: red`, and `border-left: currentcolor medium none` is
`border-left: none`.

Two readings, because the shorthands part two ways.

A family's slots are order-free, so `_familySlots` says which slot each
component fills — by what the slot takes rather than by an enumerated list of
spellings, which is what lets a color and a length be answered for where the
keyword table could only answer for a keyword. Where every slot holds its own
initial, whichever component is shortest says all of them. The initial each slot
takes is derived from what `mdn-data` states per longhand.

A layer's are not, so `background` and `mask` are read positionally: the size is
the one or two `<bg-size>` components after the `/` and the position stands
directly before it, and the origin and the clip are the run of `<box>` keywords —
two of them in that order, or one that is both. Each goes only where every
component of it is the value an unwritten slot takes, so `0% 0%/auto 50%` keeps
its size and `0% 0%/auto auto` does not.

`text-decoration` is left alone where the target does not read one of its slots:
taking that slot out would bring the rest of the declaration back to life on an
engine that drops it whole, where the author wrote nothing.

Held to Chromium beside the rest: 26 computed properties over the shorthands
above, and the whole `configCases` corpus through the CSSOM comparison that
already ran.

* test(css): record the transform-to-matrix trade, measured

A transform list written as one `matrix()` is a re-encoding, and it loses on
every column: over 200 rules drawn from six transform shapes it is +12.2% raw,
+4.3% gzip and +7.8% brotli. Named functions are short repeated tokens a
compressor rewards; a matrix is six novel floats that widen the literal
alphabet.

lightningcss makes this trade on raw bytes per declaration, which the numbers
say is the wrong column. The other direction — a matrix that names a function —
is made, and shipped earlier on this branch.

* feat(css): fold a math function to the value it names

The fold declined every result it could not show exact, which left sqrt(),
pow(), exp(), log(), hypot() and the trig functions written out for all but a
handful of arguments. They now compute, and the printer's own rounding — six
significant digits below 1e4, under what a stylesheet can observe — decides
what is written. Exactness is kept where semantics need it: a stepped function
still pins its integer, and an angle-valued fold is left alone because an angle
is never rounded.

`e` and `pi` are read inside a calculation, out of the <calc-constant> grammar;
`infinity` and `NaN` are named there with no value so a calculation using one
is declined rather than read as a unit.

* fix(css): convert a color only as far as the byte is the engine's own (#21930)

* fix(css): convert a color only as far as the byte is the engine's own

Measured against Chromium over 91k channels: an engine's transfer function is a
fitted curve rather than the spec's, and a conversion through a matrix carries
that into every term, so the disagreement is relative to the color's own
magnitude — a sixteenth of a byte at white, more than a whole byte near black.
The flat 0.1-byte margin the Lab family used covered neither, and 0.15% of the
colors it converted painted a byte an engine did not. The margin now follows the
transfer's slope, `color(srgb …)` takes the tight one it always deserved, and
a98-rgb and ProPhoto are left alone: Chromium reads their transfer through a
different function altogether, which no margin bridges.

Two more colors were not the ones an engine computes:

- a gray states no hue, and an engine reads that component as missing and takes
  the other color's rather than mixing the two (CSS Color 4 §12.2),
- a mix or a relative color no byte holds is now written in the space it was
  computed in, where every color that went into it was written there too — the
  one case the numbers are the engine's own — and only where that is shorter
  than the call it replaces.

`system-ui` is lowered inside the `font` shorthand as well, out of the
grammars: the properties that take a family list are the ones referencing it.

Verified over 15854 rewrites of random colors in every spelling, each painted
in headless Chromium beside the color it replaced: none differ.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018tNx7sU4SbnKFhFeofDVr5

* fix(css): read a relative color's channel keyword as the number it names

CSS Color 5 §4.1 substitutes a channel keyword as a `<number>`, so `calc(h + 40)`
reduces to no unit at all and a hue slot reads it as degrees — the printer asked
for an angle there and left every expression over a hue written out. An angle
still stands where no keyword is mixed into it, and mixing the two is no value,
which stays declined.

The margins the color conversions rest on are now re-derivable rather than
remembered: `yarn measure:color-agreement` asks an engine for its own conversion
instead of for a pixel, prints how far it sits from webpack's in linear light and
through the transfer alone, and names the spaces it reads through a different
transfer function. It reproduces both constants and both spaces.

The verification itself is a test rather than a session's own run: the syntax
equivalence suite now sweeps a seeded corpus of ~12k color rewrites — every
spelling, mixes and relative references two levels deep — and paints each beside
the color it replaced, in six seconds.

---------

Co-authored-by: Claude Opus 5 <[email protected]>

* chore(css): fold the two changesets this pull request now carries into one

The stack collapsed #21930 into this branch, so its changeset arrived beside
this one. The guide asks for a single entry per pull request at the highest
bump the changes need.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018tNx7sU4SbnKFhFeofDVr5

* fix(css): keep a background position written as a length zero

The browser tier over web-platform-tests, which this branch reaches for the
first time now that it sits on main, caught the slot removal taking out a
position it should not: `background:0 0` became `background:none`.

A length zero lands the image where an unwritten slot does, but the engine
computes it as `0px` where the unwritten one computes as `0%`, so a script
reading the longhand back sees the rewrite. Only a written `0%` is the initial
now; a keyword still is, and `background:0 0` was a byte longer written as
`background:none` anyway.

Three stylesheets in the corpus said so — bootstrap.min.css and both copies of
bulma.min.css, each on `background:0 0!important` in a print reset.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018tNx7sU4SbnKFhFeofDVr5

* fix(css): keep the arc two opposite hues state, and draw the corpus the seed names

Four findings from the review, one of them a color an engine reads differently.

Both seeded generators wrote `(seed * 1103515245 + 12345) % 2147483648`, whose
product passes 2^53 — so the rounding collapsed the sequence to about 12,900
values however many were drawn. `Math.imul` and a mask make it the recurrence it
reads as, and the corpus it draws found the rest:

- `_hueEndpoints` read a half turn the conversion left a hair over as the arc
  the other way round. Two opposite hues have no shorter arc, and CSS Color 4
  §12.4 breaks the tie by which is greater — but a color reaches its own space
  through sRGB, and the trip back moves the hue just enough to wrap. So
  `color-mix(in hsl,hsl(200 50% 40%),hsl(20 50% 40%))` folded to a purple where
  Chromium paints green. The arc stands unless it passes a half turn by more
  than the trip costs; 12 spellings, both tie orderings, every hue method and a
  near-tie either side were painted in Chromium against the value they replace.
- `_CONVERSION_ERROR` is re-derived from the corpus the fixed generator draws:
  the worst a matrix costs is 9.53e-4 of the color's magnitude, not the 9.46e-4
  measured before, so the margin is 1.52e-3. `_TRANSFER_ERROR` already covers
  what this run reports and stands.
- the color measurement closes its browser whatever the run did.

And the changeset is one sentence under the length the guide asks for.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018tNx7sU4SbnKFhFeofDVr5

* test: stop the deno run opening one SQLite store from every worker

Both failures the `runtimes (deno)` job has reported, reproduced against deno
2.9.6 and fixed at the cause.

`database is locked`, which took out a whole suite before it ran a test: Deno
holds `localStorage` in a SQLite file behind an accessor that has a setter, so
the `global.localStorage = undefined` the jest environment carries for Node
*opens* the store rather than clearing it — reading it back answers
`Storage { length: 0 }`. Every worker opens the one file, and Deno's open runs a
`CREATE TABLE IF NOT EXISTS`, so a fresh cache has them all writing at once. 24
concurrent processes doing exactly that reproduce the message; the accessor is
configurable, so taking it off the global first leaves the assignment the plain
property it has always been on Node. Over the 110 unit suites the file is now
never created and the message is gone.

`NotFound ... utime '…/src/style.modules.css'`: one of the nineteen `updateSrc`
calls was not awaited, and it ends in a `writeFile` and a `utimes`. The writes
outlive the test, and the next one's `rimraf` takes the file out from under
them — which is why it surfaced under a runtime with different fs timing rather
than being a Deno defect.

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: css CSS support (lib/css) area: tooling Repo tooling, codegen, CLI entry (tooling/, bin/)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant