Repository navigation
fix(css): convert a color only as far as the byte is the engine's own - #21930
alexander-akait merged 2 commits into
Conversation
🦋 Changeset detectedLatest commit: eb56f10 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 |
8768090 to
3a0d61d
Compare
30496c4 to
6b15737
Compare
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.
3a0d61d to
ceaa051
Compare
6b15737 to
eb56f10
Compare
fa00dad
into
perf/css-shorthand-slots-and-math-folding
…#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]>
…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
#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]>
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 intest/syntaxEquivalence.spectest.jsthat 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.mddocuments 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