Skip to content

color: ansi-16, ansi-256 and hsl/lab all produced unusable output - #33328

Merged
Jarred-Sumner merged 7 commits into
mainfrom
farm/2b677b4f/color-ansi16-digits
Jul 4, 2026
Merged

Jarred-Sumner merged 7 commits into
mainfrom
farm/2b677b4f/color-ansi16-digits

Conversation

@robobun

@robobun robobun commented Jul 4, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #22161

Bun.color had three output formats that produced strings nothing could use. A round-trip property test (feed each output back into Bun.color) found all of them.

On the three-in-one scope: the bot keeps flagging it. They are three arms of the same match in the same function, all found by the same test, and all of the same shape (the output string is not something any consumer can parse). Splitting them would mean three PRs regenerating the same snapshot in sequence. Happy to split if a maintainer prefers.

1. ansi-16 emitted a control byte instead of an SGR code

$ bun -e 'console.log(JSON.stringify(Bun.color("red", "ansi-16")))'
"\^[[38;5;\tm"     # a tab, in the middle of an escape sequence
$ bun -e 'console.log(JSON.stringify(Bun.color("blue", "ansi-16")))'
"\^[[38;5;\fm"     # a form feed

src/css_jsc/color_js.rs wrote the palette index into the string as a single byte rather than as decimal digits:

break 'color BunString::clone_latin1(&[
    0x1b, b'[', b'3', b'8', b';', b'5', b';',
    ansi_16_color,   // index 9 is '\t', index 12 is '\x0c'
    b'm',
]);

Both halves of that sequence were wrong. The index now goes in as decimal digits, and as a 16-color SGR parameter (30..=37, 90..=97 for the bright variants) rather than 38;5;{index}, which is the 256-color form. Red is now \x1b[91m, matching #22161.

That second half matters: Bun.color(x, "ansi") selects ansi-16 exactly when the terminal reports it cannot do 256 colors, and such a terminal does not understand 38;5;N. The format was emitting an escape that the one terminal it exists to serve cannot read. (The old code comment even said "5 is the 16-color mode", which is not what 38;5;N means.)

Nothing can have depended on the old output, since it was not a valid escape sequence.

2. ansi-256 underflowed the grey ramp for near-black colors

$ bun -e 'console.log(JSON.stringify(Bun.color("#020202", "ansi-256")))'
"\^[[38;5;429496961m"

ansi256::get ports tmux's colour_find_rgb, which computes the grey index as (grey_avg - 3) / 10 in signed int, so an average below 3 truncates to 0. The port does it on u32 with wrapping_sub. 115 of the 216 colors with r,g,b < 6 were affected, and through get16's & 0xff mask ansi-16 rendered near-black as bright blue. Now saturating_sub, which matches tmux.

3. hsl and lab emitted strings that are not CSS

$ bun -e 'console.log(Bun.color("red", "hsl"), "|", Bun.color("red", "lab"))'
hsl(0, 1, 0.5) | lab(0.54290545, 80.80492, 69.89099)

$ bun -e 'console.log(Bun.color("hsl(0, 1, 0.5)", "hex"))'
null

Saturation, lightness and L* are stored as 0..1 and were printed raw. hsl() takes percentages and lab() takes lightness as a percentage and is space-separated. Bun's own CSS parser rejects both of the strings above, so Bun.color's output could not be fed back into Bun.color.

This is not a judgement call, it is what the repo already says:

  • docs/runtime/color.mdx has always documented the "hsl" output as "hsl(120, 50%, 50%)". The implementation never matched its own docs.
  • The CSS printer in src/css prints lab(54.29% 80.8 69.89) for the same color.
  • Bun.color itself only parses hsl(h, s%, l%) and lab(L% a b).

So: hsl(0, 100%, 50%) and lab(54.29% 80.80492 69.89099). Also, an achromatic color has no hue, so a grey used to print hsl(NaN, 0%, 50%); it now prints a zero hue. hsl round-trips across a sweep of the color cube.

The .d.ts @example lines documented the old broken output and are updated.

Tests

Snapshots pin whatever the implementation happens to emit, which is how all of this survived. The snapshot was also corrupt: index 13 (magenta) is a carriage return, which the snapshot writer normalized to a newline, so magenta's entry recorded index 10.

Assertions now, not snapshots:

  • every ansi-16 output matches /^\x1b\[(3[0-7]|9[0-7])m$/, swept across the color cube, so a 256-color escape can never come back.
  • ansi-256 never emits an index outside the 256-entry palette, swept along the grey ramp where the arithmetic underflowed. A valid-looking 38;5;429496961m is all digits, so the SGR regex alone would have let it through.
  • the exact values from Bun.color ansi-16 output is incorrect #22161: red 91, green 92, blue 94, white 97.
  • Bun.stringWidth(Bun.color("red", format) + "hello") === 5, since a terminal skips the escape.
  • the round-trip property for every css-string output format.
  • the documented input forms and null for unparseable input.

Reverting any one of the three fixes turns specific tests red.

Regenerating the snapshot rewrites all 432 entries because the current writer escapes control characters as \x1B instead of embedding them raw, not because 432 values changed. Only the 108 ansi-16 values changed in meaning. Rather than take my word for it on a binary diff, this normalizes both escapings and compares entry by entry:

verify the snapshot
git show origin/main:test/js/bun/css/__snapshots__/color.test.ts.snap > /tmp/old.snap

python3 - <<'EOF'
import re
def load(p):
    d = open(p, "rb").read().replace(b"\\x1B", b"\x1b").replace(b"\\t", b"\t").replace(b"\\r", b"\r")
    return {m.group(1): m.group(2) for m in re.finditer(rb'exports\[`(.+?)`\] = `(.*?)`;', d, re.S)}
old = load("/tmp/old.snap")
new = load("test/js/bun/css/__snapshots__/color.test.ts.snap")
changed = [k for k in old if old[k] != new.get(k)]
other   = [k for k in changed if b'"ansi-16")' not in k]
print(f"entries {len(old)} -> {len(new)}; changed {len(changed)}; ansi-16 {len(changed)-len(other)}; other {len(other)}")
for k in other: print("  OTHER:", k)
EOF
entries 432 -> 432; changed 108; ansi-16 108; other 0

One thing left, with the repro

lab output now parses, but it does not round-trip for saturated blues:

$ bun -e 'console.log(Bun.color(Bun.color("#0000f8", "lab"), "hex"))'
#002be3

Filed separately as #33331, and the test.failing now points at it. It is worse than a Bun.color problem: the same conversion feeds the CSS bundler's sRGB fallback for lab() colors, so lab(29.568% 68.287 -112.029) (pure blue) compiles to #002cea.

The direction is pinned. The forward conversion is exact, matching the CIELAB D50 reference to four decimals:

color Bun reference (D50)
#ff0000 lab(54.290546% 80.80492 69.89099) L 54.29, a 80.80, b 69.89
#0000ff lab(29.5683% 68.287384 -112.02972) L 29.568, a 68.287, b -112.029

Feeding that exactly-correct Lab back in returns #002cea, so the inverse is not the inverse. Not fixed here: that is colour-science to be done against reference vectors rather than guessed at, and it is a different bug from "the string is not CSS". This PR adds a passing test for the forward direction so whoever picks up #33331 knows which half to look at.

Verification

bun bd test test/js/bun/css/color.test.ts
964 pass, 1 skip, 0 fail

@github-actions github-actions Bot added the claude label Jul 4, 2026
@coderabbitai

coderabbitai Bot commented Jul 4, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR fixes ansi-16 escape generation so the color index is written as decimal digits, updates related hsl/lab formatting, refreshes docs and type examples, and adds tests for ANSI output, round-trips, and input parsing.

Changes

Color output formatting

Layer / File(s) Summary
Escape sequence and CSS formatting
src/css_jsc/color_js.rs
Changes the greyscale palette index calculation, adds NaN normalization for missing components, rebuilds the ansi-16 escape with decimal digits, and updates hsl and lab output formatting.
Docs and type examples
docs/runtime/color.mdx, packages/bun-types/bun.d.ts
Updates the runtime color docs and Bun.color JSDoc examples to show the corrected ansi-16, hsl, and lab output forms.
Color behavior tests
test/js/bun/css/color.test.ts
Adds coverage for ansi escape shapes, CSS output round-trips, input parsing, and alpha handling.

Sequence Diagram(s)

flowchart TD
  js_function_color --> output_buffer
  output_buffer --> return_string
  ansi256_get16 --> js_function_color
Loading

Possibly related issues

  • #33331 — The lab output changes and added lab() round-trip tests touch the same conversion path referenced by that issue.
  • #22161 — The ansi-16 escape fix directly addresses the incorrect ansi-16 output reported there.

Related PRs: None referenced.

Suggested labels: bug, docs, tests

Suggested reviewers: None determinable from provided data.

Poem
A stray tab hid inside the hue,
Now digits render cleanly through.
Docs, types, and tests agree today,
The ansi-16 path works the right way.

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR also includes ansi-256, hsl/lab, docs, and type changes that are not part of linked issue #22161. Split unrelated fixes into separate PRs or link the additional issues so the scope matches the tracked requirement.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The ansi-16 fix matches #22161 by emitting valid 16-color SGR codes like 91, 92, 94, and 97.
Title check ✅ Passed The title matches the main change set: fixes unusable Bun.color outputs across ansi-16, ansi-256, hsl, and lab.
Description check ✅ Passed It covers what the PR does and includes verification details, even though it doesn't use the template headings verbatim.

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

@robobun

robobun commented Jul 4, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 3:24 PM PT - Jul 4th, 2026

@robobun, your commit 05f9f24 is building: #68378

@github-actions

github-actions Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Bun.color ansi-16 output is incorrect #22161 - Directly fixes the ansi-16 output bug where raw control bytes were emitted instead of decimal ASCII digits for the palette index

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #22161

🤖 Generated with Claude Code

Comment thread test/js/bun/css/color.test.ts Outdated
@robobun robobun changed the title color: ansi-16 emitted the palette index as a raw control byte color: ansi-16 emitted a control byte instead of a 16-color SGR code Jul 4, 2026
@robobun

robobun commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator Author

Good catch on #22161, and it pushed the fix further than I had it.

My first pass only stopped ansi-16 from writing the palette index as a raw byte, which left \x1b[38;5;9m. But the issue's expected values (91, 94, 92, 97) are right and mine were not: 38;5;N is the 256-color form. Bun.color(x, "ansi") falls back to ansi-16 precisely when the terminal reports it cannot do 256 colors, so the format was emitting an escape that the one terminal it exists for cannot read. The old code comment even claimed "5 is the 16-color mode".

So it now emits real 16-color SGR parameters, 30..=37 and 90..=97:

$ bun -e 'for (const c of ["red","blue","lime","white"]) console.log(c, JSON.stringify(Bun.color(c, "ansi-16")))'
red "\u001b[91m"
blue "\u001b[94m"
lime "\u001b[92m"
white "\u001b[97m"

which is exactly the table in the issue. Added Fixes #22161, plus a sweep asserting no ansi-16 output can ever be a 256-color escape again.

On the docs URL: fixed, bun.com/docs/runtime/color.

@robobun
robobun force-pushed the farm/2b677b4f/color-ansi16-digits branch from 3e5e02c to 5454d26 Compare July 4, 2026 20:15
Comment thread src/css_jsc/color_js.rs Outdated
@robobun

robobun commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator Author

That is a real bug and a good one, thanks. Fixed in f992406 along with the test that should have caught it.

Confirmed, and a bit wider than the analysis suggests: 115 of the 216 colors with r,g,b < 6 come out as 38;5;429496961.

$ bun -e 'console.log(JSON.stringify(Bun.color("#020202", "ansi-256")))'
"\u001b[38;5;429496961m"     # was
"\u001b[38;5;16m"            # now

#010101 happens to survive (the grey branch loses the distance comparison), #020202 does not. And through get16's & 0xff mask it landed on TABLE_256[129] = 12, so near-black rendered as bright blue in ansi-16.

The sharpest part of this is about my own test: /^\x1b\[[\d;]+m$/ accepts 429496961 happily, because it is all digits. A well-formed escape is not the same thing as a correct one. The suite now asserts the index stays inside the 256-entry palette, and sweeps the grey ramp and the low corner of the cube where the arithmetic underflows instead of striding over them. Reverting just the saturating_sub makes two tests fail:

(fail) ansi-256 never emits an index outside the palette
(fail) near-black colors land on black, not on a wrapped grey index

961 tests, 0 fail with the fix.

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/css_jsc/color_js.rs`:
- Around line 490-509: The 16-color SGR logic in the color conversion path is
fine, but the explanatory comment above the `sgr` calculation is too long and
exceeds the 3-line comment guideline. Trim the multi-line comment in
`color_js.rs` near the `ansi256::get16` and `sgr` computation so it stays
concise while still making the 30..=37/90..=97 mapping clear.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: dca4f3e8-ee95-4b18-996f-aca442a2303d

📥 Commits

Reviewing files that changed from the base of the PR and between 3e5e02c and f992406.

⛔ Files ignored due to path filters (1)
  • test/js/bun/css/__snapshots__/color.test.ts.snap is excluded by !**/*.snap
📒 Files selected for processing (3)
  • docs/runtime/color.mdx
  • src/css_jsc/color_js.rs
  • test/js/bun/css/color.test.ts

Comment thread src/css_jsc/color_js.rs
robobun and others added 3 commits July 4, 2026 20:39
Bun.color(x, "ansi-16") built the escape as \x1b[38;5;{index}m and wrote the
palette index straight into the string as a single byte, so index 9 came out as
a tab and index 12 as a form feed. No terminal renders \x1b[38;5;<TAB>m, which
means ansi-16 has never produced usable output.

Both halves were wrong. The index now goes in as decimal digits, and it goes in
as a 16-color SGR parameter (30..=37, or 90..=97 for the bright variants) rather
than the 38;5;{index} form that only a 256-color terminal understands. Red is
now \x1b[91m. Emitting a 256-color escape from ansi-16 defeated the one case the
format exists for: Bun.color(x, "ansi") picks ansi-16 exactly when the terminal
cannot do 256 colors.

Nothing can have depended on the old output, since it was not a valid sequence.

The old output was corrupting the test snapshot too: index 13 is a carriage
return, which the snapshot writer normalized to a newline, so the stored value
for magenta claimed index 10.

Adds assertion-based tests for all three ansi formats, a round-trip property for
the css string formats, and the input forms. Two formats do not round-trip and
are marked test.failing: hsl prints saturation and lightness as 0..1 rather than
percentages, and lab prints lightness as 0..1 rather than 0..100, so Bun cannot
parse back the strings it just produced.
ansi256::get is a port of tmux's colour_find_rgb, which computes the grey index
as (grey_avg - 3) / 10 in signed int, so an average below 3 truncates to 0. The
port does it on u32 with wrapping_sub, so "#20202" produced index 429496961:

  $ bun -e 'console.log(JSON.stringify(Bun.color("#20202", "ansi-256")))'
  "\u001b[38;5;429496961m"

115 of the 216 colors with r,g,b < 6 were affected. get16 masks the index with
0xff, so ansi-16 turned near-black into bright blue.

The SGR regex the new tests use accepts any digits, so it would have passed a
429-million index happily. They now assert the index stays inside the palette,
sweeping the grey ramp where the arithmetic underflows.
@robobun
robobun force-pushed the farm/2b677b4f/color-ansi16-digits branch from f992406 to a2651db Compare July 4, 2026 20:39

@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 — the old ansi-16 output was never a valid escape sequence, so this is a pure fix with no compat surface, and both follow-ups from earlier review are in.

Extended reasoning...

Overview

Fixes Bun.color(input, "ansi-16"), which previously wrote the 0–15 palette index into the escape sequence as a raw byte (so index 9 became a literal tab). The fix maps the index to a real 16-color SGR parameter (30..=37 / 90..=97) and formats it as decimal, mirroring the ansi-256 arm's approach. A second one-line fix changes wrapping_sub(3) → saturating_sub(3) in ansi256::get() so near-black inputs no longer produce a ~429M palette index (raised in my earlier inline comment; now fixed and tested). Docs and the snapshot are regenerated to match, and ~140 lines of contract-based tests are added.

Security risks

None. This is pure string formatting of a color value into an ANSI escape sequence — no I/O, no parsing of untrusted structured data beyond what the existing CSS color parser already handled, no auth/crypto/permissions.

Level of scrutiny

Low-to-moderate. Bun.color is a leaf utility API; the change is confined to one match arm plus a one-token arithmetic fix in a helper it calls. The output-format change (\x1b[91m instead of \x1b[38;5;9m) is technically a design choice, but the previous output contained raw control bytes and was unusable by any terminal, so there is no compatibility surface to break. The chosen form matches what issue #22161 explicitly requested and is the only form a 16-color-only terminal actually understands, which is the whole reason ansi-16 exists.

Other factors

  • Both of my earlier inline comments (docs URL, saturating_sub underflow) were addressed in follow-up commits and are marked resolved.
  • Buffer sizing is safe: get16 returns 0–15, so sgr is 30–97 (2 digits), and "\x1b[" + "NN" + "m" = 5 bytes into an 8-byte buffer.
  • New tests assert the documented contract (SGR regex, palette bounds, exact values from the linked issue, Bun.stringWidth zero-width) rather than re-snapshotting; the author verified that reverting each fix independently fails specific tests.
  • Two test.failing cases document pre-existing hsl/lab round-trip bugs without changing their output — appropriate restraint since those touch the documented .d.ts examples.
  • No CODEOWNERS on these paths; bug-hunter found nothing.

Bun.color(x, "hsl") printed saturation and lightness as the raw 0..1 values it
stores them as, and Bun.color(x, "lab") printed lightness the same way with
comma separators. Neither is CSS, and Bun's own parser rejects both, so the
output of Bun.color could not be fed back into Bun.color:

  $ bun -e 'console.log(Bun.color("red", "hsl"))'
  hsl(0, 1, 0.5)
  $ bun -e 'console.log(Bun.color("hsl(0, 1, 0.5)", "hex"))'
  null

hsl() takes percentages for saturation and lightness, and lab() takes lightness
as a percentage and is space-separated, which is also what the CSS printer in
src/css emits. An achromatic color has no hue, so a grey was printing
hsl(NaN, ...); it now prints a zero hue.

hsl now round-trips across a sweep of the color cube. lab parses back but still
loses saturated blues, because the lab <-> sRGB conversion is inaccurate there
independently of the formatting; that one stays marked test.failing with the
repro.
@robobun
robobun requested a review from alii as a code owner July 4, 2026 21:04
@robobun robobun changed the title color: ansi-16 emitted a control byte instead of a 16-color SGR code color: ansi-16, ansi-256 and hsl/lab all produced unusable output Jul 4, 2026
@robobun

robobun commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator Author

Took the test.failing punt back out on hsl/lab, since the repo already had the answer.

The output format is not a judgement call:

  • docs/runtime/color.mdx has documented the "hsl" output as "hsl(120, 50%, 50%)" this whole time. The implementation simply never matched its own docs.
  • The CSS printer in src/css prints lab(54.29% 80.8 69.89) for red, percentage lightness and space separated.
  • Bun.color's own parser accepts hsl(h, s%, l%) and lab(L% a b) and rejects everything the old output produced.

So hsl now emits hsl(0, 100%, 50%) and lab emits lab(54.290546% 80.80492 69.89099), both of which parse back. The round-trip sweep also caught a third thing on the way: an achromatic color has no hue, so Bun.color("#808080", "hsl") was emitting hsl(NaN, 0%, 50.196083%). Zero hue now.

The one piece I am still not claiming: lab parses back but loses saturated blues.

$ bun -e 'console.log(Bun.color(Bun.color("#0000f8", "lab"), "hex"))'
#002be3

That is not formatting. Bun.color("lab(19.5% 53.2 -87.3)", "hex") is off by the same amount on main, so the lab <-> sRGB conversion is inaccurate for those colors independently of this PR (worst channel error 43 across a sweep). That one stays test.failing with the exact repro, because fixing it means getting the colour-science right against reference vectors rather than guessing, and it is a different bug from "the string is not CSS".

963 pass, 0 fail.

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

Both of my earlier points are addressed and I didn't find anything new — this looks correct to me, but since it changes the user-visible output of three Bun.color formats (ansi-16, hsl, lab) and rewrites the full snapshot, a maintainer should sign off on the format choices.

Extended reasoning...

Overview

This PR fixes three broken output formats in Bun.color:

  • ansi-16 was emitting a raw control byte as the palette index and using the 256-color 38;5;N form; it now emits real 16-color SGR parameters (\x1b[30-37m / \x1b[90-97m).
  • ansi-256 underflowed the grey-ramp index for near-black inputs via wrapping_sub on u32; now saturating_sub, matching the tmux reference.
  • hsl and lab printed raw 0..1 floats instead of CSS percentages/space-separated form, so their output wasn't valid CSS and couldn't be re-parsed by Bun.color itself. Achromatic hue is now 0 instead of NaN.

It also updates docs/runtime/color.mdx, the .d.ts @example lines, regenerates the snapshot (all 432 entries, since the writer now escapes control chars), and adds ~160 lines of new assertions in test/js/bun/css/color.test.ts including a test.failing documenting a pre-existing lab↔sRGB conversion inaccuracy.

Security risks

None. This is pure output-string formatting for a color utility — no parsing of untrusted input was changed, no auth/crypto/filesystem/network paths touched.

Level of scrutiny

Medium. The individual fixes are small and well-reasoned (the saturating_sub change is one word; the ansi-16 SGR mapping 30+i / 82+i for i<8 / i>=8 is correct; the buffer sizing is safe). However, this changes user-visible output for three documented formats. The PR argues convincingly that all three were unusable as-is (invalid escape sequences, not-CSS strings the docs already contradicted), so nothing could have depended on the exact old output — but that's a maintainer judgment call, not something I should shadow-approve.

Other factors

  • Both of my prior inline comments (docs URL nit, wrapping_sub underflow) were addressed in follow-up commits and the threads are resolved.
  • The snapshot file is rewritten wholesale (binary diff), which the PR description explains but I can't independently verify entry-by-entry.
  • A test.failing is added for a separate pre-existing lab conversion bug — reasonable as documentation, but a maintainer may prefer it filed as an issue instead.
  • No human reviewer has looked at this yet.

… direction

The forward sRGB -> Lab conversion matches the CIELAB D50 reference values
exactly, which is what locates the bug in the inverse. Filed as #33331.
@robobun

robobun commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator Author

Both fair. Addressed in 174c902.

On the snapshot being unverifiable: you're right that "trust me, only 108 changed" is not good enough for a binary diff. The description now carries the script that proves it, so anyone can run it instead of squinting:

entries 432 -> 432; changed 108; ansi-16 108; other 0

On the lab test.failing: agreed, and filing it turned out to matter more than I expected. It is #33331 now, and the test points at it.

Chasing it down, the bug is not confined to Bun.color. The same conversion feeds the CSS bundler's sRGB fallback for lab() colors:

$ echo 'a { color: lab(29.568% 68.287 -112.029); }' > lab.css   # that is #0000ff
$ bun build lab.css --minify
a{color:#002cea;color:color(display-p3 .00000346541 -.00000245467 .95958);color:lab(...)}

So a browser that falls back to the hex renders pure blue as a washed-out #002cea. The display-p3 fallback on the same declaration is correct, which narrows it to the lab -> sRGB path.

The direction is now pinned too. The forward conversion is exact, matching the CIELAB D50 reference to four decimals for all three primaries; feeding the exactly-correct Lab for blue back in still returns #002cea, so the inverse is not the inverse. I added a passing test asserting the forward direction, so whoever picks up #33331 knows which half to look at.

On maintainer sign-off for the format choices: right instinct, and I am not trying to shadow-land it. But I would push back on calling them choices. In all three cases the old output was not usable by anything:

  • ansi-16 emitted a tab inside an escape sequence. No terminal renders it.
  • hsl and lab emitted strings that Bun.color itself returns null on, and docs/runtime/color.mdx has documented the hsl output as "hsl(120, 50%, 50%)" the whole time.

So there is no compat surface to weigh, and the format is whatever the docs and the src/css printer already said it was. 964 pass, 0 fail.

@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 — each fix is small and verifiable, the old outputs were objectively unusable, and the new ones match what the docs already promised.

Extended reasoning...

Overview

Four files touched: src/css_jsc/color_js.rs (~40 lines across four output arms of Bun.color), docs/runtime/color.mdx (5 example lines), packages/bun-types/bun.d.ts (2 JSDoc @example lines), and test/js/bun/css/color.test.ts (+170 lines of assertion-based tests replacing/supplementing snapshots). The Rust changes are: wrapping_sub → saturating_sub in the tmux grey-ramp port; the ansi-16 arm rewritten to emit real 16-color SGR parameters (30..37/90..97) as decimal digits instead of a raw palette-index byte; the hsl arm now emits percentages and guards NaN hue; the lab arm now emits space-separated with percentage lightness.

Security risks

None. This is pure output-string formatting of already-parsed color values — no new parsing of untrusted input, no I/O, no auth, no allocation driven by user-controlled sizes. The one buffer write ([0u8; 8]) is bounded: 2-byte prefix + at most "97m" = 5 bytes, and get16() provably returns 0–15 via TABLE_256.

Level of scrutiny

Moderate — it changes the string output of a public API in three formats. But the old outputs were not something anyone could depend on: ansi-16 embedded a raw control byte (tab, form-feed) mid-escape; ansi-256 emitted a 9-digit palette index for near-black; hsl/lab emitted strings Bun's own parser rejects and that never matched docs/runtime/color.mdx. This is the implementation catching up to its documented contract, not a contract change. I verified the SGR arithmetic (82 + index for index ≥ 8 gives 90–97), the saturating_sub matches tmux's signed-int truncation, and the * 100.0 scaling matches how s/l/L are stored.

Other factors

Test coverage is strong: exact-value pins for #22161, property sweeps over the color cube and grey ramp, round-trip checks for every CSS-string format, a Bun.stringWidth zero-column check, and a test.failing pinning the separate pre-existing lab↔sRGB conversion inaccuracy to its own issue. Both of my earlier inline comments (docs URL, wrapping_sub underflow) and CodeRabbit's comment-length nit are addressed and resolved. The one item flagged this run — none in non-hue hsl/lab components still surfaces as NaN — is explicitly pre-existing, niche (CSS Color 4 none keyword), and strictly no worse than before; it does not block.

Comment thread src/css_jsc/color_js.rs Outdated
CSS Color 4's none keyword stores a missing component as NaN, and the hue of an
achromatic color is NaN too. The guard I added only covered the hue, so
Bun.color("hsl(120 none 50%)", "hsl") still emitted hsl(120, NaN%, 50%), which
no CSS parser accepts. A missing component is a zero value outside of
interpolation, so all of them are now guarded.

Also stop asserting the lab reference values as exact strings. The conversion
goes through cbrt, so the last f32 digit differs by platform: Windows and darwin
aarch64 print 69.891 where linux x64 prints 69.89099. Compared numerically now.
@robobun
robobun force-pushed the farm/2b677b4f/color-ansi16-digits branch from f94c494 to 05f9f24 Compare July 4, 2026 22:23
@robobun

robobun commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator Author

Good flag, and it caught a second thing on the way in. Both in 05f9f24.

The none gap is real and I fixed it rather than leaving it. You're right that it's pre-existing, but it's the same class as the hue guard I'd just added, and the guard only covering hsl.h was an incomplete fix rather than a deliberate scope line. Confirmed exactly as traced:

$ bun -e 'console.log(Bun.color("hsl(120 none 50%)", "hsl"))'
hsl(120, NaN%, 50%)
$ bun -e 'console.log(Bun.color("lab(none 40 30)", "lab"))'
lab(NaN% 40 30)

Now all five components go through one zero_if_none. I went with zero rather than the literal none token, because CSS Color 4 says a missing component behaves as a zero value outside interpolation, and because the legacy comma form this PR emits for hsl() doesn't accept none anyway — emitting it would swap one unparseable string for another. So:

$ bun -e 'console.log(Bun.color("hsl(120 none 50%)", "hsl"))'
hsl(120, 0%, 50%)
$ bun -e 'console.log(Bun.color(Bun.color("hsl(120 none 50%)", "hsl"), "hex"))'
#808080

And the CI failure on the last push was my own doing, worth recording. The commit before this one added:

expect(color("#ff0000", "lab")).toBe("lab(54.290546% 80.80492 69.89099)");

Windows and darwin aarch64 print 69.891 where linux x64 prints 69.89099. The lab conversion goes through cbrt, so the last f32 digit is libm-dependent. Pinning the exact string was over-specification on my part: the claim the test is making is "the forward conversion matches the CIELAB D50 reference", not "this exact float prints this exact way". It compares numerically now, with toBeCloseTo.

I swept the rest of my new assertions for the same smell and relaxed one more (hsl of a grey was asserting 50.196083% when the point of the test is the zero hue). The remaining exact-string assertions are all integer arithmetic: SGR parameters, palette indices, hex.

968 pass, 0 fail.

@Jarred-Sumner
Jarred-Sumner merged commit be4f649 into main Jul 4, 2026
78 of 79 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/2b677b4f/color-ansi16-digits branch July 4, 2026 23:54
robobun added a commit that referenced this pull request Jul 5, 2026
#33328 landed the same hsl()/lab() output-format fix, so the conflicting
arms now use main's `zero_if_none` formatting; this branch keeps the
null-for-unconvertible guard it adds in front of them and leaves main's
ansi-16 and ansi-256 fixes untouched.
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.

Bun.color ansi-16 output is incorrect

2 participants