Skip to content

css: keep rotate: 0deg out of none, and keep the origin alpha in relative colors - #38486

Open
robobun wants to merge 2 commits into
mainfrom
farm/3477caf9/css-value-folding
Open

robobun wants to merge 2 commits into
mainfrom
farm/3477caf9/css-value-folding

Conversation

@robobun

@robobun robobun commented Aug 14, 2026 •

Copy link
Copy Markdown
Collaborator

Two CSS minifier value folds that change the meaning of the input. Both came out of a fuzzing round against lightningcss; the third finding from that round, rem() folding with the divisor's sign, already has an open fix in #32286 and is not repeated here.

Problem

  • rotate: 0deg is minified to rotate: none. The two are not equivalent: a rotate value other than none creates a stacking context and a containing block for fixed/absolute descendants, exactly like a non-none transform, so the fold can change layout. lightningcss keeps 0deg.
    • Cause: Rotate in src/css/properties/transform.rs was a plain struct and parsed none as {x: 0, y: 0, z: 1, angle: 0deg}; to_css printed every value matching that pattern as none, so a literal 0deg, z 0deg or 0 0 1 0deg became none too.
  • A relative color with no / alpha is folded fully opaque: rgb(from #0008 r g b) becomes #000, hsl(from rgb(1 2 3 / .5) calc(h + 180) s l) becomes #030201. CSS Color 5 says an omitted alpha in the relative syntax defaults to the origin color's alpha (not to 100% as in the absolute syntax), so these should be #0008 and #03020180. lightningcss has the same bug; this follows the spec and browsers.
    • Cause: parse_alpha in src/css/values/color.rs returns 1.0 whenever there is no /, without looking at parser.from, which already holds the resolved origin color (it is what the alpha keyword reads).
    • Bun.color() uses the same parser, so Bun.color("rgb(from #0008 r g b)", "css") also returned "#000".

Fix

  • Rotate becomes an enum with a None variant and an Xyz { x, y, z, angle } variant, the same shape as Translate and Scale (and as current upstream lightningcss). none round-trips to none, everything else prints its angle; the x/y keyword and z-axis shortenings are unchanged. to_transform still maps None to the identity rotate3d(0, 0, 1, 0deg), so merging into a preceding transform is unchanged.
  • parse_alpha returns the origin's alpha when a from color is set and no / was written. Every relative-capable function (rgb, hsl, hwb, lab, lch, oklab, oklch, color()) goes through this one helper. The origin alpha is already resolved (none becomes 0, same as the explicit alpha keyword), so the explicit / alpha and the omitted form now agree. The token-list path for rgb(... / var(--a)) requires an explicit / and is not affected.
  • Verified:
    • test/js/bun/css/css.test.ts (transform block, new relative colors block): 5 rotate cases and 14 relative color cases fail on the released build and pass with this change; full file passes.
    • test/js/bun/css/color.test.ts (new relative color syntax block): fails on the released build, passes with this change; full file passes.
    • test/bundler/css/ (including the WPT relative color file, whose origins are all opaque) passes.

Background

  • Individual transform properties: translate, rotate and scale are separate properties that compose with transform. Their initial value none is special in CSS Transforms 2: it is the only value that does not establish a stacking context / containing block, which is why a minifier may not rewrite an identity rotation to none even though both render the same.
  • Relative color syntax: rgb(from <origin> r g b / alpha) parses the origin color, converts it into the function's color space, and exposes its channels as keywords. In this codebase ComponentParser::from holds those resolved channels (components.3 is alpha) while the arguments are parsed; RelativeComponentParser::get_ident is how the alpha keyword reads it.
  • The CSS parser and minifier live in src/css/ and are a port of lightningcss; minify_test in test/js/bun/css/ runs a stylesheet through the minifier in-process and compares the output string.
Repro
a{rotate:0deg}
c{color:rgb(from #0008 r g b); background:hsl(from rgb(1 2 3 / .5) calc(h + 180) s l)}

bun build --minify before: a{rotate:none}c{color:#000;background:#030201}

after: a{rotate:0deg}c{color:#0008;background:#03020180}

lightningcss 1.33.0 on the same input: a{rotate:0deg} and c{color:#000;...} (it shares the alpha bug).


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

fails on main (without fix)
ASAN without fix: 24 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 (ab00a59ba)

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.96ms]
�[38;5;196m[object Object]
(pass) console.log(color({"r":255,"g":0,"b":0}, "ansi-256")) [1.66ms]
�[91m[object Object]
(pass) console.log(color({"r":255,"g":0,"b":0}, "ansi-16")) [1.54ms]
(pass) color({"r":255,"g":0,"b":0}, "{rgb}") = {"r":255,"g":0,"b":0} [1.85ms]
(pass) color({"r":255,"g":0,"b":0}, "ansi-24bit") [16.19ms]
(pass) color({"r":255,"g":0,"b":0}, "ansi-16") [2.53ms]
(pass) color({"r":255,"g":0,"b":0}, "ansi256") [2.28ms]
�[38;2;0;255;0m[object Object]
(pass) console.log(color({"r":0,"g":255,"b":0}, "ansi-24bit")) [0.43ms]
�[38;5;46m[object Object]
(pass) console.log(color({"r":0,"g":255,"b":0}, "ansi-256")) [0.27ms]
�[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.59ms]
(pass) color({"r":0,"g":25
... (truncated)

release without fix: 24 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")) [0.08ms]
�[38;5;196m[object Object]
(pass) console.log(color({"r":255,"g":0,"b":0}, "ansi-256")) [0.02ms]
�[91m[object Object]
(pass) console.log(color({"r":255,"g":0,"b":0}, "ansi-16")) [0.01ms]
(pass) color({"r":255,"g":0,"b":0}, "{rgb}") = {"r":255,"g":0,"b":0} [0.05ms]
(pass) color({"r":255,"g":0,"b":0}, "ansi-24bit") [0.54ms]
(pass) color({"r":255,"g":0,"b":0}, "ansi-16") [0.03ms]
(pass) color({"r":255,"g":0,"b":0}, "ansi256") [0.01ms]
�[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 (ab00a59ba)

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")) [1.53ms]
�[91m[object Object]
(pass) console.log(color({"r":255,"g":0,"b":0}, "ansi-16")) [1.52ms]
(pass) color({"r":255,"g":0,"b":0}, "{rgb}") = {"r":255,"g":0,"b":0} [1.71ms]
(pass) color({"r":255,"g":0,"b":0}, "ansi-24bit") [14.29ms]
(pass) color({"r":255,"g":0,"b":0}, "ansi-16") [1.70ms]
(pass) color({"r":255,"g":0,"b":0}, "ansi256") [1.72ms]
�[38;2;0;255;0m[object Object]
(pass) console.log(color({"r":0,"g":255,"b":0}, "ansi-24bit")) [0.34ms]
�[38;5;46m[object Object]
(pass) console.log(color({"r":0,"g":255,"b":0}, "ansi-256")) [0.27ms]
�[92m[object Object]
(pass) console.log(color({"r":0,"g":255,"b":0}, "ansi-16")) [0.26ms]
(pass) color({"r":0,"g":255,"b":0}, "{rgb}") = {"r":0,"g":255,"b":0} [0.56ms]
(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     ab00a59ba5
  features     baseline

22 deps, 123 codegen, 1176 objects in 846ms

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

Checked 107 installs across 153 packages (no changes) [15.00ms]
[3/1238] gen bindgenv2
[4/1238] fetch tinycc
[tinycc] up to date
[5/1237] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (b7a043103)

Checked 1 install across 2 packages (no changes) [2.00ms]
[6/1237] fetch zlib
[zlib] up to date
[7/1237] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[8/1237] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingConstants.cpp
[9/1237] install /workspace/bun/src/node-fallbacks
bun install v1.4.0-canary.1 (b7a043103)

Checked 129 installs across 147 packages (no changes) [13.00ms]
[10/1237] gen ProcessBindingHTTPParser.lut.h
Gen
... (truncated)
diff hotspot
src/css/properties/transform.rs | 93 ++++++++++++++++++++++-------------------
 src/css/values/color.rs         | 16 ++++---
 test/js/bun/css/color.test.ts   | 23 ++++++++++
 test/js/bun/css/css.test.ts     | 42 ++++++++++++++++++-
 4 files changed, 124 insertions(+), 50 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                             reads  edits  tests
src/css/properties/transform.rs      2      3      0
src/css/values/color.rs              3      1      0
test/js/bun/css/color.test.ts        1      2      0
test/js/bun/css/css.test.ts          3      4      0

Rotate stored none as a zero rotation about the z axis and printed any such
value as none, so rotate: 0deg minified to rotate: none. The two are not
equivalent: per CSS Transforms 2, any rotate value other than none creates a
stacking context and a containing block, like a non-none transform. Give
Rotate an explicit None variant, matching Translate and Scale.
…lpha

rgb(from #8 r g b) was folded to #000. CSS Color 5 says an omitted alpha
in the relative color syntax defaults to the origin color's alpha rather
than to 100%, so this should stay #8. All relative color functions
(rgb, hsl, hwb, lab, lch, oklab, oklch, color) share parse_alpha, and
Bun.color() uses the same parser.
@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: 4 minutes

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: 32049bc3-a467-43e8-85f6-03a14f0900f2

📥 Commits

Reviewing files that changed from the base of the PR and between 032b8db and ab00a59.

📒 Files selected for processing (4)
  • src/css/properties/transform.rs
  • 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 review. Everything that ran in CI is green; the red build status is a queue problem, not a test failure.

  • Reproduced on bun 1.4.0 and on main with bun build --minify over a{rotate:0deg} and c{color:rgb(from #0008 r g b)}, which printed rotate:none and #000; with this branch they print rotate:0deg and #0008.
  • Tests: test/js/bun/css/css.test.ts (transform block plus a new relative colors block) and test/js/bun/css/color.test.ts. The new cases fail on the released build and pass with the branch.
  • CI (build 95969): 177 of 179 jobs passed, and the only test annotation is the usual flaky list (nothing CSS related). The other two jobs are the darwin 14 aarch64 test-bun shards, which never started: that queue has a multi-hour backlog today, the shards expired in the queue four times (12:08 to 17:05 UTC) and ran out of automatic retries, so Buildkite marked the build failed without running them. Nothing in this diff is platform specific, and a retrigger would land in the same backlog, so I have not pushed one; a retry of those two jobs once the queue drains should be enough.
  • The rem() sign fold reported alongside these two is already fixed by css: fix rem() sign semantics to match CSS Values 4 #32286 and is intentionally not part of this PR.

@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.

LGTM — two well-scoped CSS minifier correctness fixes with thorough coverage.

What was reviewed

  • Rotate struct→enum: mirrors the existing Translate/Scale shape in the same file; all consumers (parse/to_css/to_transform/deep_clone and the generic bridges in properties_generated.rs) still line up.
  • parse_alpha: confirmed parse_legacy_alpha is unreachable when parser.from is set (both is_legacy_syntax sites gate on from.is_none()), so the single-helper fix covers every relative-color function.
  • Origin alpha is read from the same components.3 the explicit alpha keyword uses, and is populated after .resolve()/.resolve_missing(), so none→0 and range are already handled.
  • Tests cover both entry points (minifier + Bun.color()), every color function, explicit /1 and /alpha, none origin alpha, nested/light-dark() origins, and the rotate 0deg/0turn/axis/merge-into-transform variants.
Extended reasoning...

Overview

Two independent CSS minifier value-semantics fixes:

  • src/css/properties/transform.rs: Rotate changes from a struct (which conflated none with {0,0,1,0deg}) to an enum with None and Xyz variants, matching Translate/Scale in the same file and upstream lightningcss. to_css now only prints none for the None variant, so rotate: 0deg is preserved (it creates a stacking context, none does not). to_transform still maps None to the identity rotate3d, so folding into a preceding transform: list is unchanged.
  • src/css/values/color.rs: parse_alpha now returns parser.from.components.3 (the origin color's alpha) when no / is present in a relative color, per CSS Color 5. Absolute colors (from is None) still default to 1.0.
  • Tests: new relative colors block in css.test.ts (rgb/hsl/hwb/lab/lch/oklab/oklch/color(), explicit alpha, none alpha, nested, light-dark); expanded rotate cases (0deg/0turn/z 0deg/0 0 1 0deg/x 0deg, none↔0deg ordering, merge-into-transform); new relative color syntax block in color.test.ts exercising Bun.color(). One existing assertion (0 0 1 0deg → none) was corrected to 0deg, which is the bug being fixed.

Security risks

None. Pure CSS value parsing/serialization in safe Rust; no untrusted length arithmetic, no allocation changes, no FFI, no auth/crypto.

Level of scrutiny

Medium. The Rotate change is a mechanical refactor to an established local pattern; I grep-verified no other site constructs or field-accesses Rotate outside transform.rs (the generated-property and generics bridges go through parse/to_css/deep_clone/eql, all of which are updated). The parse_alpha change is a 4-line semantic fix at the shared helper; I verified parse_legacy_alpha cannot co-occur with a from origin (both is_legacy_syntax computations at color.rs:1183 and :1391 require parser.from.is_none()), and that components.3 is the same value the explicit alpha keyword returns (color.rs:2250), so omitted and / alpha now agree by construction.

Other factors

  • The origin alpha is already resolved (.resolve()/.resolve_missing() before RelativeComponentParser::new), so a none origin alpha is 0 and no extra clamping is needed — covered by the rgb(from rgb(0 0 0 / none) r g b) → #0000 case.
  • Rotate retains #[derive(Copy, Clone, PartialEq)], so deep_clone (*self) and css_eql_partialeq! remain valid.
  • PR description states the new tests fail on the released build and pass with the change, and that the WPT relative-color bundler suite (opaque origins) still passes.
  • No prior human review comments to address; only a rate-limited coderabbit placeholder.

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.

2 participants