Skip to content

css: resolve relative color channel keywords as numbers in the function's range - #38553

Open
robobun wants to merge 6 commits into
mainfrom
farm/32b6a83d/css-relative-color-channel-numbers
Open

robobun wants to merge 6 commits into
mainfrom
farm/32b6a83d/css-relative-color-channel-numbers

Conversation

@robobun

@robobun robobun commented Aug 14, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • In relative color syntax, adding a plain number to a channel keyword computes the wrong color, and so does a channel written without a keyword:
    rgb(from rgb(200 100 50) calc(r + 10) g b)      -> #ff6432         (browsers, lightningcss 1.30.2: #d26432)
    rgb(from red calc(100) g b)                     -> red             (#640000)
    hsl(from hsl(120 50% 40%) h calc(s + 10) l)     -> #666            (#29a329)
    lab(from lab(50% 20 30) calc(l + 10) a b)       -> lab(none 20 30) (lab(60% 20 30))
    rgb(from rgb(200 100 50) r g b / calc(r / 255)) -> alpha 1/255     (alpha 200/255)
    
    calc(r * 2) and bare keywords happen to come out right, which is why this went unnoticed since the CSS parser landed. No user has reported it; it was found by diffing Bun.color against lightningcss. It does reach users: the idiom in our own docs, lch(from purple calc(l + 15) c h), is emitted into the stylesheet as lch(none 66.8 327) (a none lightness, i.e. black) by bun build on 1.4.0, and every browser has shipped this syntax since 2024.
  • CSS Color 5 defines each keyword as a <number> in the range of the function being written: r/g/b are 0..255 in rgb(), s/l (and hwb() w/b) and lab()/lch() l are 0..100, oklab()/oklch() l, color() channels and alpha are 0..1 (https://drafts.csswg.org/css-color-5/#relative-colors).
  • Cause, in src/css/values/color.rs: RelativeComponentParser::new copies the origin's components as the colorspace structs store them (unit values: r is 0.784 for 200, l is 0.5 for 50%), the define_colorspace! tables type those channels as percentages, and RelativeComponentParser::parse_number_or_percentage returns a keyword or calc() result as NumberOrPercentage::Percentage. So calc(r + 10) is 0.784 + 10 read as a percentage, and hsl()/lab() lightness goes through parse_percentage, where keyword + number ends in Percentage::from_calc returning NaN. The same typing also rejected spec-valid positions: a number literal for s/l, and a/b/alpha anywhere a <number> is accepted (lab(from c a b l), hsl(from c s s l)).

Fix

  • Upstream fixed this in lightningcss 1.30 (Update relative color parsing to latest spec parcel-bundler/lightningcss#465) by typing every non-hue channel as a number and storing rgb(), hsl() and lab() channels in their CSS ranges, so a keyword is simply the stored value. Bun's structs store unit values (and Bun.color, color-mix and the serializers are built on that), so this PR keeps the storage and carries upstream's percent basis on the relative parser instead: RelativeComponentParser gains percent_basis, set by the function being parsed (255 in parse_rgb_components, 100 in parse_hsl_hwb_components, the new l_basis argument of parse_lab/parse_lch, 100 or 1, which is upstream's argument of the same name; color() keeps 1). The fixing line is get_number: a keyword resolves to component * percent_basis, and parse_ident and the calc() callback use it. ComponentParser::parse_number_or_percentage returns those as Number, which rgb() already divides by 255 and color()/alpha already use as is. If the structs ever move to CSS-range storage like upstream, percent_basis becomes 1 everywhere and is deleted.
  • The percentage-written channels (hsl()/hwb() second and third channel, lab()/lch()/oklab()/oklch() lightness) go through the new ComponentParser::parse_unit_channel(basis): in relative syntax it parses a <number> (keyword, calc(), or literal) and divides by the basis, otherwise a <percentage> exactly as before. css-color-4 also allows a plain <number> there outside relative syntax (lab(50 20 30), lab not supported in Bun.color #16727). That is deliberately not added here: the out-of-gamut origins in test/bundler/css/wpt/relative_color_out_of_gamut.test.ts are currently left unfolded only because lab(100 104.3 -50.9) does not parse, and making it parse without also changing how out-of-gamut origins are folded produces the #fff outputs that were rejected in Bun.color: return null for unconvertible colors, fix hsl/lab output, lab() and named-color parsing #33047's review. The doc comment on parse_unit_channel says so; dropping its from gate is the one-line change for whichever PR lands the gamut policy.
  • ChannelType::PERCENTAGE is gone (upstream's tables have the same shape): every non-hue channel and alpha is NUMBER, hues stay ANGLE, so a keyword is accepted wherever the function takes a <number> and the hue keyword only where it takes a hue. In the <angle> calc pass a non-hue keyword is now a Calc::Number instead of being read as degrees (also upstream's rule), so calc(s * 1deg) works and calc(s + 30deg) is rejected; the only inputs that folded before and are rejected now are lab() a/b and lch() c used inside an angle calc(), e.g. lch(from c l c calc(c + 10deg)).
  • The second pass over a math function the number pass could not fold (RelativeComponentParser::parse_percentage, what folds min(r, g), and what gives calc(l + 10%) its meaning) now takes the basis of the channel being parsed and substitutes number / basis for a keyword, so everything in it is compared as the number CSS Color 5 defines. Main substituted the stored component, which was only right while every keyword in the expression had the same basis as the channel: rgb(from c min(r, alpha) g b) folded to min(0.784, 1), i.e. 200, instead of min(200, 1), and once a/b/c are admitted there (they are numbers now) lab(from lab(50% 20 30) min(l, a) a b) would have folded to 50% instead of 20% (caught in review). Same-basis expressions are byte-identical to before. Upstream types the keywords as numbers in this pass instead; css: parse calc(<channel> * <percentage>) in relative colors #38513 ports that (it also needs Percentage::from_calc to reject a number, otherwise the keyword + percentage spellings turn into none channels), which folds the same min()/max() cases to the same values in the number pass and decides the fate of the + 10% spellings. The two PRs edit the same function, so whichever lands second has a small rebase; both orders end at upstream's shape.
  • css: fix color(a98-rgb), srgb-linear relative colors and color-mix(in xyz-d50) #38487 fixes the srgb-linear r channel type. This PR rewrites the same types = ... line (the CT_PCT constant it uses no longer exists) but leaves r as the (sic) angle it is on main, so that fix is still needed and becomes (CT_NUM, CT_NUM, CT_NUM) after this. css: keep rotate: 0deg out of none, and keep the origin alpha in relative colors #38486 (origin alpha) is unaffected: alpha is not scaled.
  • Why this is right: every value asserted by the new tests is byte for byte what lightningcss 1.30.2 emits for the same input, except where the test comments say it leaves the input unparsed (calc(h + 30), and the min()/max() folds, which bun folded before this change too). Pass-through outputs do not move: the basis multiplies stored values that rgb() rounds and the other functions divide straight back, and the relative color WPT expectations pass unchanged.
  • Docs: the relative color example in docs/bundler/css.mdx used calc(l + 15%), which browsers reject; it now uses the spec spelling this change makes work, and both output lines are what bun build prints (the var() line is passed through; it was shown as computed before).
  • Verified with bun bd test test/js/bun/css/css.test.ts (new relative colors block, 66 cases through the minifier: every function, converted and light-dark() origins, a missing channel, keywords used as alpha and alpha as a channel, the / var(--a) path in src/css/properties/custom.rs, the hue rules, the min()/max() pass) and bun bd test test/js/bun/css/color.test.ts (30 cases through Bun.color, including the lines above). Without the src/ change 37 of the 66 and 18 of the 30 fail on the released binary; the rest pin behavior that must not move. With it both files pass, and so do test/bundler/css/ (including the relative color WPT file) and the rest of test/js/bun/css/, apart from the 20k-rule case in nested-vendor-prefix-duplication.test.ts, which has no colors in it and sits at its 5 s budget on this loaded machine (css: parse calc(<channel> * <percentage>) in relative colors #38513 measured it at 4.9 s on main too), and css-fuzz.test.ts, which is skipped in CI.

Background

  • Relative color syntax: rgb(from <origin> r g b / alpha) converts the origin color into the function's color space and lets each channel be written as a keyword naming one of the converted channels, a calc() over them, or a literal.
  • Percent basis: the value a channel's 100% stands for, 255 for an rgb() channel, 100 for hsl() saturation or lab() lightness, 1 for alpha. It is also the factor between Bun's stored unit value and the <number> CSS uses for the channel, which is what the relative parser needs.
  • ComponentParser parses one channel of any color function; when a from origin is present it first asks its RelativeComponentParser (built from the converted origin) to resolve keywords and keyword calc()s, and falls back to literal parsing. parse_rgb_components and parse_hsl_hwb_components are shared with custom.rs, which handles rgb(... / var(--x)) by resolving the channels and keeping the alpha as tokens.
  • Calc<f32> evaluates a math function whose terms are numbers, substituting keywords through a callback; Calc<Percentage> does the same with percentage terms and is the second pass described above.
Every probe whose output changes (Bun.color(x, "css"); the right column is also lightningcss 1.30.2's answer)
input                                                   before        after
rgb(from rgb(200 100 50) calc(r + 10) g b)              #ff6432       #d26432
rgb(from rgb(200 100 50) calc(r - 10) g b)              #006432       #be6432
rgb(from red calc(100) g b)                             red           #640000
rgb(from rgb(none 100 50) calc(r + 10) g b)             #ff6432       #0a6432
rgb(from hsl(120 50% 40%) calc(r + 10) g b)             #f93          #3d9933
rgb(from rgb(200 100 50) r g b / calc(r / 255))         #c8643201     #c86432c8
rgb(from rgb(200 100 50) r g b / r)                     #c86432c8     #c86432
rgb(from rgb(200 100 50) alpha g b)                     #ff6432       #016432
rgb(from light-dark(rgb(200 100 50), rgb(50 100 200)) calc(r + 10) g b)
                                        light-dark(#ff6432, #ff64c8)   light-dark(#d26432, #3c64c8)
hsl(from hsl(120 50% 40%) h calc(s + 10) l)             #666          #29a329
hsl(from hsl(120 50% 40%) h s calc(l + 10))             #000          #40bf40
hsl(from hsl(120 50% 40%) h 60 l)                       null          #29a329
hsl(from hsl(120 50% 40%) s s l)                        null          #983
hsl(from hsl(120 50% 40%) alpha s l)                    null          #993533
hsl(from hsl(120 50% 40%) h alpha l)                    #0c0          #656765
hsl(from hsl(120 50% 40%) h s l / calc(l / 100))        #33993301     #3936
hsl(from hsl(120 50% 40%) calc(s * 1deg) s l)           null          #983
hsl(from rgb(200 100 50) h calc(s + 10) l)              #7d7d7d       #d56025
hwb(from hwb(120 20% 30%) h calc(w + 10) b)             #00b300       #4db34d
hwb(from hwb(120 20% 30%) h 30 b)                       null          #4db34d
lab(from lab(50% 20 30) calc(l + 10) a b)               lab(none 20 30)   lab(60% 20 30)
lab(from lab(50% 20 30) 60 a b)                         null          lab(60% 20 30)
lab(from lab(50% 20 30) a b l)                          null          lab(20% 30 50)
lab(from lab(50% 20 30) l alpha b)                      null          lab(50% 1 30)
lab(from lab(50% 20 30) l a b / calc(l / 100))          lab(50% 20 30 / .005)   lab(50% 20 30 / .5)
lab(from rgb(200 100 50) calc(l + 10) a b)              lab(none 38.1977 46.2491)   lab(64.2174% 38.1977 46.2491)
lch(from lch(50% 30 120) calc(l + 10) c h)              lch(none 30 120)   lch(60% 30 120)
lch(from lch(50% 30 120) c l h)                         null          lch(30% 50 120)
lch(from lch(50% 30 120) l c calc(c + 10deg))           lch(50% 30 40)   null
oklab(from oklab(50% 0.1 0.1) calc(l + 0.1) a b)        oklab(none .1 .1)   oklab(60% .1 .1)
oklch(from rgb(200 100 50) calc(l + 0.1) c h)           oklch(none .142265 45.0831)   oklch(71.3838% .142265 45.0831)

Unchanged, as intended: every bare keyword pass-through, calc(r * 2), calc(h + 30), calc(h + 30deg), color() in every space, every alpha written as a number or percentage, min(r, g), calc(l + 10%), and the hue keyword being rejected outside hue positions.


[review] gate passed · iteration 2 · 4 files touched

fails on main (without fix)
ASAN without fix: 55 failed, 68 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/css/color.test.ts test/js/bun/css/css.test.ts
bun test v1.4.0 (9ecdaf271)

test/js/bun/css/color.test.ts:
�[38;2;255;0;0m[object Object]
(pass) console.log(color({"r":255,"g":0,"b":0}, "ansi-24bit")) [3.51ms]
�[38;5;196m[object Object]
(pass) console.log(color({"r":255,"g":0,"b":0}, "ansi-256")) [1.99ms]
�[91m[object Object]
(pass) console.log(color({"r":255,"g":0,"b":0}, "ansi-16")) [1.51ms]
(pass) color({"r":255,"g":0,"b":0}, "{rgb}") = {"r":255,"g":0,"b":0} [2.48ms]
(pass) color({"r":255,"g":0,"b":0}, "ansi-24bit") [15.49ms]
(pass) color({"r":255,"g":0,"b":0}, "ansi-16") [2.16ms]
(pass) color({"r":255,"g":0,"b":0}, "ansi256") [2.17ms]
�[38;2;0;255;0m[object Object]
(pass) console.log(color({"r":0,"g":255,"b":0}, "ansi-24bit")) [0.40ms]
�[38;5;46m[object Object]
(pass) console.log(color({"r":0,"g":255,"b":0}, "ansi-256")) [0.26ms]
�[92m[object Object]
(pass) console.log(color({"r":0,"g":255,"b":0}, "ansi-16")) [0.30ms]
(pass) color({"r":0,"g":255,"b":0}, "{rgb}") = {"r":0,"g":255,"b":0} [0.64ms]
(pass) color({"r":0,"g":25
... (truncated)

release without fix: 55 failed, 67 skipped
bun test v1.4.0-canary.1 (b7a043103)

test/js/bun/css/color.test.ts:
�[38;2;255;0;0m[object Object]
(pass) console.log(color({"r":255,"g":0,"b":0}, "ansi-24bit")) [1.69ms]
�[38;5;196m[object Object]
(pass) console.log(color({"r":255,"g":0,"b":0}, "ansi-256")) [0.06ms]
�[91m[object Object]
(pass) console.log(color({"r":255,"g":0,"b":0}, "ansi-16")) [0.02ms]
(pass) color({"r":255,"g":0,"b":0}, "{rgb}") = {"r":255,"g":0,"b":0} [0.06ms]
(pass) color({"r":255,"g":0,"b":0}, "ansi-24bit") [0.81ms]
(pass) color({"r":255,"g":0,"b":0}, "ansi-16") [0.04ms]
(pass) color({"r":255,"g":0,"b":0}, "ansi256") [0.02ms]
�[38;2;0;255;0m[object Object]
(pass) console.log(color({"r":0,"g":255,"b":0}, "ansi-24bit"))
�[38;5;46m[object Object]
(pass) console.log(color({"r":0,"g":255,"b":0}, "ansi-256"))
�[92m[object Object]
(pass) console.log(color({"r":0,"g":255,"b":0}, "ansi-16"))
(pass) color({"r":0,"g":255,"b":0}, "{rgb}") = {"r":0,"g":255,"b":0} [0.01ms]
(pass) color({"r":0,"g":255,"b":0}, "ansi-24bit")
(pass) color({"r":0,"g":255,"b":0}, "ansi-16")
(pass) color({"r":0,"g":255,"b":0}, "ansi256")
�[38;2;0;0;255m[object Object]
(pass) console.log(color({"r":0,"g":0,"b":255}, "ansi-24bit")
... (truncated)
passes on PR (with fix)
ASAN with fix: 68 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/css/color.test.ts test/js/bun/css/css.test.ts
bun test v1.4.0 (9ecdaf271)

test/js/bun/css/color.test.ts:
�[38;2;255;0;0m[object Object]
(pass) console.log(color({"r":255,"g":0,"b":0}, "ansi-24bit")) [3.39ms]
�[38;5;196m[object Object]
(pass) console.log(color({"r":255,"g":0,"b":0}, "ansi-256")) [2.22ms]
�[91m[object Object]
(pass) console.log(color({"r":255,"g":0,"b":0}, "ansi-16")) [1.57ms]
(pass) color({"r":255,"g":0,"b":0}, "{rgb}") = {"r":255,"g":0,"b":0} [2.27ms]
(pass) color({"r":255,"g":0,"b":0}, "ansi-24bit") [15.36ms]
(pass) color({"r":255,"g":0,"b":0}, "ansi-16") [2.07ms]
(pass) color({"r":255,"g":0,"b":0}, "ansi256") [2.02ms]
�[38;2;0;255;0m[object Object]
(pass) console.log(color({"r":0,"g":255,"b":0}, "ansi-24bit")) [0.32ms]
�[38;5;46m[object Object]
(pass) console.log(color({"r":0,"g":255,"b":0}, "ansi-256")) [0.25ms]
�[92m[object Object]
(pass) console.log(color({"r":0,"g":255,"b":0}, "ansi-16")) [0.24ms]
(pass) color({"r":0,"g":255,"b":0}, "{rgb}") = {"r":0,"g":255,"b":0} [0.58ms]
(pass) color({"r":0,"g":25
... (truncated)

release with fix: 67 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     9ecdaf2718
  features     baseline

22 deps, 123 codegen, 1176 objects in 936ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1238] install /workspace/bun
bun install v1.4.0-canary.1 (b7a043103)

Checked 107 installs across 153 packages (no changes) [20.00ms]
[2/1238] gen ErrorCode+*.h
[3/1238] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (b7a043103)

Checked 1 install across 2 packages (no changes) [8.00ms]
[4/1238] install /workspace/bun/src/node-fallbacks
bun install v1.4.0-canary.1 (b7a043103)

Checked 129 installs across 147 packages (no changes) [14.00ms]
[5/1238] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[6/1238] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingConstants.cpp
[7/1238] gen .bind.ts → GeneratedBindings.cpp
[8/1238] gen ProcessBindingHTTPParser.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBind
... (truncated)
diff hotspot
docs/bundler/css.mdx          |  12 ++-
 src/css/values/color.rs       | 242 ++++++++++++++++++++++--------------------
 test/js/bun/css/color.test.ts |  57 ++++++++++
 test/js/bun/css/css.test.ts   | 137 ++++++++++++++++++++++++
 4 files changed, 327 insertions(+), 121 deletions(-)

gate history · 2 passed · 0 rejected · iteration 2

evidence per changed file
file                           reads  edits  tests
docs/bundler/css.mdx               2      3      0
src/css/values/color.rs           23     55      0
test/js/bun/css/color.test.ts      3      5      0
test/js/bun/css/css.test.ts        3     18      0

…on's range

In the relative color syntax every channel keyword is a <number> in the
range of the function it is used in: r/g/b are 0..255 in rgb(), s/l and
lab()/lch() l are 0..100, alpha and color() channels 0..1. The relative
component parser handed out the unit values the colorspace structs store
instead (r = 0.78 for 200, l = 0.5 for 50%) and typed every keyword and
calc() result as a percentage, so rgb(from c calc(r + 10) g b) clamped
to 255 and hsl()/lab() calc(l + 10) produced a NaN channel.

Each function now records the range of its channels on the relative
parser, keywords resolve to value * range, and the channels written as
percentages divide the numbers they parse by that range again. The
percentage pass (what folds min(r, g), and calc(l + 10%)) is unchanged.
@coderabbitai

coderabbitai Bot commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 1 minute

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: bfefb8f3-9b13-41dd-98e3-5c8c7514e13d

📥 Commits

Reviewing files that changed from the base of the PR and between eabb96d and 9ecdaf2.

📒 Files selected for processing (4)
  • docs/bundler/css.mdx
  • src/css/values/color.rs
  • test/js/bun/css/color.test.ts
  • test/js/bun/css/css.test.ts

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

@robobun

robobun commented Aug 14, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for a maintainer. Fix and tests are in, review findings are addressed, CI is green on every lane that has run.

  • Reproduced on the released binary (1.4.0) and on main with Bun.color(...): rgb(from rgb(200 100 50) calc(r + 10) g b) gives #ff6432 (lightningcss 1.30.2 and browsers: #d26432), hsl(from hsl(120 50% 40%) h calc(s + 10) l) gives #666 (#29a329), lab(from lab(50% 20 30) calc(l + 10) a b) gives lab(none 20 30) (lab(60% 20 30)). The keywords were resolved to the stored unit values instead of the function's own number range.
  • Tests: the relative colors block in test/js/bun/css/css.test.ts (66 cases) and relative color channel keywords in test/js/bun/css/color.test.ts (30 cases). 37 + 18 of them fail without the src/ change, everything passes with it; test/bundler/css/ is unchanged.
  • Review: one real finding (the percentage pass compared mixed-basis keywords on different scales), fixed in be7ca3d with tests; the follow-up review found nothing further.
  • CI: build 96906 (the code is unchanged since be7ca3d) has 177 of 179 jobs green, the remaining two are the darwin 14 aarch64 test jobs still waiting for an agent; the annotations on it are retries that passed. The previous build's only failures were three unrelated leak tests timing out on the x64-asan lane (require-cache, setInterval, sourcetextmodule-leak, reported separately), which passed on this run.
  • Related PRs and what is deliberately left out are in the PR body: css: parse calc(<channel> * <percentage>) in relative colors #38513 (second pass typing) composes with this, css: fix color(a98-rgb), srgb-linear relative colors and color-mix(in xyz-d50) #38487 still applies on top, non-relative <number> lightness (lab not supported in Bun.color #16727) is left to the out-of-gamut work in Bun.color: return null for unconvertible colors, fix hsl/lab output, lab() and named-color parsing #33047.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and didn't find any bugs. Because it's a substantive redesign of relative-color channel resolution — removing ChannelType::PERCENTAGE, threading per-function ranges through the parser, and shifting output for a whole class of inputs — a human look would still be worthwhile, especially given the stated interactions with #38513/#38486/#38487.

Checked: CT_PCT/PERCENTAGE have no remaining references; parse_rgb_components/parse_hsl_hwb_components callers in custom.rs are covered by the / var(--a) tests; parse_alpha goes through parse_number_or_percentage which now returns Number for keywords (alpha range is 1, so unscaled); the legacy comma-syntax rgb() path still uses the untouched ComponentParser::parse_percentage.

Extended reasoning...

Overview

This PR reworks how CSS relative-color channel keywords resolve in src/css/values/color.rs. It adds a ranges: (f32, f32, f32) field to RelativeComponentParser, introduces per-function range constants (RGB_CHANNEL_RANGE 255, HSL_CHANNEL_RANGE 100, LAB_LIGHTNESS_RANGE 100, OKLAB_LIGHTNESS_RANGE 1), threads an l_range argument through parse_lab/parse_lch, replaces get_ident with a (value, range) pair plus a get_number that multiplies them, adds ComponentParser::parse_unit_channel, and removes ChannelType::PERCENTAGE (every non-hue channel becomes NUMBER). parse_angle_or_number's Calc<Angle> pass now emits Calc::Number for non-hue keywords instead of wrapping them as degrees. 89 new test cases across css.test.ts and color.test.ts plus a docs fix.

Security risks

None. Pure parser/constant-folding logic over CSS color literals; no I/O, no allocation lifetime changes, no user-controlled paths or exec.

Level of scrutiny

Medium-high. The change is well-scoped to one file but is a semantic redesign, not a mechanical fix: it changes bundler output for many relative-color spellings, deletes an enum variant used across every define_colorspace! entry, and deliberately flips one previously-accepted input (lch(from c l c calc(c + 10deg))) to rejected. A maintainer should confirm the ranges-on-the-parser approach and the PERCENTAGE-removal are the design they want, particularly since #38513 is described as re-typing the same fallback pass.

Other factors

Test coverage is thorough (every color function, converted origins, light-dark(), missing channels, keyword-as-alpha, the custom.rs / var(--a) path, hue typing rules, and the min()/max() percentage-pass fallback), and every asserted value is stated to match lightningcss 1.30.2. I confirmed CT_PCT/PERCENTAGE have no remaining references anywhere under src/css, that custom.rs's calls to parse_rgb_components/parse_hsl_hwb_components are exercised by the new tests, and that alpha (range 1) and the legacy comma-syntax rgb() percentage path are unaffected. What keeps this from auto-approval is scope: it's a ~250-line parser semantics change with documented cross-PR interactions, not a simple/obvious fix.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Heads-up: #38513 (relative color calc(<channel> * <percentage>), e.g. calc(r * 50%) and calc(alpha * 50%), which this PR leaves unparsed because the percentage pass still sees the keyword as a Percentage value) is now stacked on this branch and changes only RelativeComponentParser::parse_percentage plus Percentage::from_calc. It relies on get_number/ranges from here to fold in the right units and keeps the keyword-as-percentage evaluation as the fallback, so calc(l + 10%) and the min(r, g) tests here are unaffected. If this branch gets reworked around parse_percentage or get_ident, #38513 will follow; otherwise nothing to do here, it just needs to land first.

parse_lab/parse_lch take l_basis like lightningcss 1.30 does, the
factor on RelativeComponentParser is percent_basis, and the lightness
values are passed as literals at the call sites. No behavior change.
Drops the hsl() lightness clamping case from the tests; it is not what
this change is about.
Comment thread src/css/values/color.rs Outdated
Comment thread src/css/values/color.rs Outdated
Comment thread src/css/values/color.rs Outdated
Comment thread src/css/values/color.rs Outdated
@robobun

robobun commented Aug 14, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 8:05 PM PT - Aug 14th, 2026

❌ @robobun, your commit 9ecdaf2 has some failures in Build #96906 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 38553

That installs a local version of the PR into your bun-38553 executable, so you can run:

bun-38553 --bun

Comment thread src/css/values/color.rs Outdated
Comment thread src/css/values/color.rs Outdated
Comment thread src/css/values/color.rs Outdated
Comment thread src/css/values/color.rs Outdated
Comment thread src/css/values/color.rs Outdated
Comment thread src/css/values/color.rs Outdated
Comment thread src/css/values/color.rs Outdated
Comment thread src/css/values/color.rs Outdated
Comment thread src/css/values/color.rs Outdated
Comment thread src/css/values/color.rs
Comment thread src/css/values/color.rs
@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Self-review done; three follow-up commits since the first push, no behavior change in any of them:

CI on the intermediate commits was cancelled by the later pushes (all finished lanes were green); the run for the current head is in progress.

Comment thread src/css/values/color.rs Outdated
…ge pass

The second pass substituted the stored component for a keyword, which
compared lab's l (stored as a unit value) against a and b (stored as
numbers) on different scales once every channel was admitted there:
lab(from lab(50% 20 30) min(l, a) a b) folded to 50% instead of 20%,
and max(a, b) in the lightness slot to 3000%. alpha had the same problem
against r or s before this branch. The pass now takes the basis of the
channel being parsed and substitutes number / basis, so every keyword
is compared as the number CSS Color 5 defines it as.
Comment thread src/css/values/color.rs
Comment thread src/css/values/color.rs
@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

be7ca3d fixes the finding from the line review above: the second (percentage) pass substituted a keyword's stored component, which compared channels with different bases on different scales. It now substitutes number / basis of the channel being parsed, so lab(from lab(50% 20 30) min(l, a) a b) is lab(20% 20 30) (the previous head folded it to 50%, main leaves it unparsed) and the pre-existing rgb(from rgb(200 100 50) min(r, alpha) g b) is #016432 (main and the previous head: #c86432). Same-basis expressions are unchanged. Six minifier cases and two Bun.color cases were added for it; all fail on the released binary. The PR body's second-pass bullet and the test counts are updated, and the review threads are replied to and resolved (the two doc comments that are still flagged are kept on purpose, with the reason in each thread).

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mixed-scale min(l, a) regression I flagged earlier is fixed in be7ca3d — parse_percentage now goes through get_number and divides by the position's basis, and the exact repros (lab min(l,a), max(a,b), lch min(l,c)) are asserted in both test files. I re-checked the other parse_percentage callers with the new scaling (rgb min(r, alpha), hsl min(s, alpha), alpha min(alpha, r)) and they all reduce to the test-asserted values. No further issues found; a human look is still worthwhile given the storage-vs-basis design divergence from upstream and the sequencing with #38513/#38487/#33047.

Extended reasoning...

Overview

This PR rewires relative-color channel keyword resolution in src/css/values/color.rs: drops ChannelType::PERCENTAGE, retypes every non-hue channel as NUMBER, adds percent_basis to RelativeComponentParser (set by parse_rgb_components/parse_hsl_hwb_components/parse_lab/parse_lch), introduces parse_unit_channel, and rewrites parse_number_or_percentage/parse_percentage so keywords resolve to component * percent_basis — the CSS-range <number> the spec says they are. 88 test cases across css.test.ts and color.test.ts cover every function, converted/light-dark origins, alpha-as-channel, the / var() path, hue typing, and the min/max second pass; docs example is corrected.

Follow-up on prior review

My earlier review found that the rewritten parse_percentage wrapped the raw stored component (discarding percent_basis) as a Percentage, so lab's unit-scale l (0.5) and raw-scale a (20) were compared directly and min(l, a) folded to 50% instead of 20%. Commit be7ca3d replaces that with get_number(ident) / percent_basis, which puts every keyword on the same scale before comparison. I traced the fix through every caller: for lab lightness (basis 100) l→50/100, a→20/100, min→0.2 → 20%; for rgb channel (basis 255) r→200/255, alpha→1/255, min→1/255 → #1; for alpha (basis 1) alpha→1, r→200, min→1 → opaque. All match the newly-added assertions.

Security risks

None. This is deterministic float arithmetic on parsed CSS tokens; no I/O, no allocation of untrusted sizes, no external effects. Worst case is a wrong color value or a declaration passed through unfolded.

Level of scrutiny

Medium-high. The logic itself is contained and the test matrix is thorough (validated byte-for-byte against lightningcss 1.30.2), but this deliberately diverges from upstream's fix shape (upstream moved struct storage to CSS ranges; this keeps Bun's unit-value storage and carries the basis on the parser instead). That is a design choice with downstream consequences for #38513 (stacked on this, retypes the same second pass), #38487 (one-line srgb-linear fix that becomes (CT_NUM, CT_NUM, CT_NUM) after this), and the out-of-gamut policy in #33047 (which is why non-relative <number> lightness is gated behind from here). A maintainer should confirm the sequencing and that keeping unit-value storage is the intended path.

Other factors

The two remaining comment-cop flags (lines ~1495 and ~2181) are on 2- and 4-line doc comments explaining why alpha/color() use basis 1 and how the second pass compares mixed-scale keywords — both are the kind of non-obvious semantics REVIEW.md says to keep. The author already trimmed the longer comments in 665179f/0a4db974.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I re-reviewed after be7ca3d and didn't find any further bugs — the parse_percentage scaling issue I flagged earlier is fixed correctly (keywords now compare as number / percent_basis of the channel being parsed), and the added min(l, a) / max(a, b) / min(r, alpha) tests cover it. Given this is a ~240-line rework of the relative color parser's channel-typing model with stated interactions across #38513, #38487 and #33047, a human sign-off from someone familiar with the CSS subsystem would still be worthwhile.

What was reviewed: the percent_basis plumbing through every color function (rgb 255, hsl/hwb 100, lab/lch l_basis, color()/alpha 1); the ChannelType::PERCENTAGE removal and every define_colorspace! retyping; the angle-calc pass now returning Calc::Number for non-hue keywords; and that ComponentParser::parse_percentage (legacy rgb% path) is only reached when from.is_none() so relative inputs never hit it.

Extended reasoning...

Overview

This PR fixes CSS Color 5 relative color syntax so channel keywords (r, s, l, etc.) resolve as <number>s in the target function's range instead of Bun's internal unit-value storage. It touches src/css/values/color.rs (~130 added / ~110 removed): adds a percent_basis field to RelativeComponentParser, sets it per-function in parse_rgb_components / parse_hsl_hwb_components / parse_lab / parse_lch, retypes every non-hue channel from CT_PCT to CT_NUM, replaces RelativeComponentParser::parse_number_or_percentage and parse_percentage with a single get_number-based path, and adds ComponentParser::parse_unit_channel. 96 new test cases across two files, plus a docs fix for an example that was emitting lch(none ...).

Prior finding addressed

My earlier review found that the rewritten parse_percentage compared mixed-basis keywords on incompatible scales (min(l, a) in lab folded to 50% instead of 20%). The fix in be7ca3d threads the channel's percent_basis into the pass and substitutes get_number(ident) / percent_basis, so all keywords are on the same scale before comparison. I traced the six new mixed-basis test cases through the code and they fold correctly; the same-basis cases (min(r, g), calc(l + 10%)) are byte-identical to before as claimed.

Security risks

None. This is pure CSS value parsing with no I/O, allocation from untrusted sizes, or path handling. Inputs that don't fold are passed through unparsed for the browser.

Level of scrutiny

Moderate-to-high. This is production bundler output — bun build on any stylesheet using relative color syntax — and Bun.color(). The change is not mechanical: it swaps the channel-type model (removing PERCENTAGE entirely), introduces a scaling factor that must be set correctly at every call site, and deliberately diverges from upstream's storage-model fix while aiming for identical output. The PR body enumerates every probe whose output changes and every one that must not; the test suite pins both.

Other factors

The PR body explicitly names three interacting PRs: #38513 (stacked on this branch, retypes the same parse_percentage pass), #38487 (srgb-linear r channel type — this PR rewrites the same line but preserves the (sic) bug), and #33047 (out-of-gamut folding, which is why non-relative <number> lightness is deliberately gated out). A maintainer familiar with the CSS subsystem should confirm the landing order and that the percent_basis approach (vs. upstream's storage change) is the direction they want, since it's called out as a divergence that a later PR may delete. The comment-cop threads are all resolved; the two remaining doc comments are justified in-thread.

@robobun

robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator Author

Heads up on overlap: #39205 adds the non relative <number> form for the hsl()/hwb() channels as ComponentParser::parse_unit_channel(input, percent_basis) plus HSL_PERCENT_BASIS, i.e. the same name, signature and constant this PR introduces, with the literal number branch that this PR's doc comment leaves as a follow-up. Whichever lands second merges the two bodies into one: the relative keyword branch from here goes in front of the literal branch from there, and the duplicate constant goes away. The only test overlap is the literal number rows (hsl(from red h 50 l) there, h 60 l here), which agree on the values. Neither PR depends on the other to be correct.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant