From 9b237f1938918310a28bde791a3759283afbc112 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 4 Jul 2026 19:51:31 +0000 Subject: [PATCH 1/7] color: ansi-16 emitted a control byte instead of a 16-color SGR code 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;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. --- docs/runtime/color.mdx | 10 +- src/css_jsc/color_js.rs | 33 +++--- .../bun/css/__snapshots__/color.test.ts.snap | Bin 28792 -> 29309 bytes test/js/bun/css/color.test.ts | 112 ++++++++++++++++++ 4 files changed, 133 insertions(+), 22 deletions(-) diff --git a/docs/runtime/color.mdx b/docs/runtime/color.mdx index cf718b89cb47..3afb1c305d4a 100644 --- a/docs/runtime/color.mdx +++ b/docs/runtime/color.mdx @@ -9,7 +9,7 @@ description: Format colors as CSS, ANSI, numbers, hex strings, and more | ------------ | -------------------------------- | | `"css"` | `"red"` | | `"ansi"` | `"\x1b[38;2;255;0;0m"` | -| `"ansi-16"` | `"\x1b[38;5;\tm"` | +| `"ansi-16"` | `"\x1b[91m"` | | `"ansi-256"` | `"\x1b[38;5;196m"` | | `"ansi-16m"` | `"\x1b[38;2;255;0;0m"` | | `"number"` | `0x1a2b3c` | @@ -124,10 +124,10 @@ To convert from RGBA to one of the 256 ANSI colors, we ported the algorithm that The `"ansi-16"` format approximates the input color to the nearest of the 16 ANSI colors supported by most terminals. ```ts -Bun.color("red", "ansi-16"); // "\u001b[38;5;\tm" -Bun.color(0xff0000, "ansi-16"); // "\u001b[38;5;\tm" -Bun.color("#f00", "ansi-16"); // "\u001b[38;5;\tm" -Bun.color("#ff0000", "ansi-16"); // "\u001b[38;5;\tm" +Bun.color("red", "ansi-16"); // "\u001b[91m" +Bun.color(0xff0000, "ansi-16"); // "\u001b[91m" +Bun.color("#f00", "ansi-16"); // "\u001b[91m" +Bun.color("#ff0000", "ansi-16"); // "\u001b[91m" ``` Bun converts the input to a 24-bit RGB color space, then to `ansi-256`, then to the nearest of the 16 ANSI colors. diff --git a/src/css_jsc/color_js.rs b/src/css_jsc/color_js.rs index d36c1f78f3fc..d607cc146f5d 100644 --- a/src/css_jsc/color_js.rs +++ b/src/css_jsc/color_js.rs @@ -484,27 +484,26 @@ pub fn js_function_color(global: &JSGlobalObject, frame: &CallFrame) -> JsResult )); } OutputColorFormat::Ansi16 => { - let ansi_16_color = ansi256::get16( + let index = ansi256::get16( rgba.red as u32, rgba.green as u32, rgba.blue as u32, ); - // 16-color ansi, foreground text color - break 'color BunString::clone_latin1(&[ - // 0x1b is the escape character - // 38 is the foreground color code - // 5 is the 16-color mode - // {d} is the color index - 0x1b, - b'[', - b'3', - b'8', - b';', - b'5', - b';', - ansi_16_color, - b'm', - ]); + // Foreground text color as a 16-color SGR parameter: + // 30..=37 for the first eight, 90..=97 for their + // bright variants. The 38;5;{index} form only a + // 256-color terminal understands is what ansi-256 is + // for. + let sgr = if index < 8 { 30 + index } else { 82 + index }; + let mut buf = [0u8; 8]; + buf[0..2].copy_from_slice(b"\x1b["); + let extra_len = { + let mut cursor = &mut buf[2..]; + let before = cursor.len(); + write!(cursor, "{}m", sgr).expect("unreachable"); + before - cursor.len() + }; + break 'color BunString::clone_latin1(&buf[0..2 + extra_len]); } OutputColorFormat::Ansi16m => { // true color ansi diff --git a/test/js/bun/css/__snapshots__/color.test.ts.snap b/test/js/bun/css/__snapshots__/color.test.ts.snap index a3027e1a91ac29fde382c736fa270ee913a0e5ce..69632b813638ee221d384a5d6eb1ca106963c683 100644 GIT binary patch literal 29309 zcmb`Q-A*J&5{2*m6sWlwOL*ySXnqFyV#g9Mu$QxzhQY(kXl+2ith`$7yKiJxRaZu4 ze34OuZ5o7KaWYOsWMrHwXpWB$-hI12_{aUtU?f!4i zUygr!xP3l;`Skp9{5)Ru{KF5Q{(O9RdU<~T;r8M8ho?9HT|KSdZg;z*)#ugQ_0j61 zeE#3j!RqGz`Rh;H(~n z2FKIID8z9asEH++lxusr%y4Fb7)(!?DdHg4t&`9Rt&M+gZ;EB#$rjci`#m^FcI{+V zyeSeO1VZ&I*F`EgM^ZL^32Tsba)Is<*`32RyCZBjXVQ9u{U|Y&>mzNqg*CW-k9)** zyK#2^W_wYbFY;X=v2vBPCi%=&%&;xl6Q_z1hsa(A4!-UBG$&TR2H2+5%9ZKGe5abR zpj*a~bJaj1&fBfy*%ZP2RjOIO21v_QDZ#NMThJ}(lXDJVMo3#ORikziNuN5Thkh`$ z(8$b`j#{U1Y&e7BQVb%uaeCdp)Y4>Fbxr@|$(0;Ad-4($bj$YANvPu_ao(N;nqHC8 z+vn$EUVZxX8xFif`X*Aj9<=e{`flB^9u^jiw0Ual^&MK*fv{G;b5@`1z4h;$^)+|! z|E}-9I<++aho_tS&z~>?)7~B!$aif7AHO|4{>}(f462r>y*RwPz3uu?^%srs`RV3A zh%m%);9%u&z2*wq`>%JbUj6VckcDymqt%V{%0L^JmHo~(xn=_!dp^ELwUe~#m*_%` z=t$tD>aYSfXva08#vcd;O!$Iee!w&tK(W zlX5Tp{mJ0IL`P~gWWPvFYD_2X!l`$oV4^?l^88(ozB7||(2)KjeHo@d>~-;pr%R!& z#cKs_WZ#*;I%q_Hk>acz^lN{q?ySHK_JJn9p9eG=v0tV^HKtSha-J_%q7`^ce~}*5 zXheUZD%GG4e<}O#(x%k{Z44GFRE~}ZYCH@> zSh;FECjd+IYXxeMb`H{^`3>iYd@fHBqPtMbYEXxBuKiU1Uh9|txxD!=)3w@Rioz0g zJ0og|ut4Jy#1f^;7>?eo16~w~CL?;Mub75H%a~KC)hLcHVom3Mg|ZH4FXP11C`)xP z#doSM$KmHa-{$ogpgtK6M=#q-eyrtOY!GS-&V2T1R}Ums-( z0Ro=7ZD0%nwwA3ei2x{9G0zM_{>#owvJ}3<+gN3ax zTU+D-YBD24Q;|s|D(2W^Kp8}$wnzkY;aHI3T&SiJ6(lljt`f>1Vr#zE4I%;xFGn#Q z9?|B+dd`3{c-ZQ#wIv>;)!wHGdVM6H0|`mvI6osapjslJ!&+M+0;(%VBHc@tSAa<_ zEa&;33D>gm&W*|$;zRl=M%dD+{3gi>%VA<#yaGe`)OQ10cC?ZUdJf-u~N z=AF*5{nF$(XqurEWD+SmjeqkdSC`j+~~E%mLH} zq&#w>#(32h_H4f=W~~CMVkBa(1JBP!F18IQhP=+c*=s(m4|Ge@R;&hwILv)a+tKV_ z?@w(-lm5J&^z}5??{j2FuQSD~g7^0iJ+~hdt_LAw?>L8LHPO=E=}q)OGLflXK^bKH z$-cBEGM(wZgm7-nq_!-Hw@;TbtP0BD;!pXdHF4=q`Xz|NMwlcv-DzLJ8H{=pe`$}L zx>J9N(jny}N%#(WPQe+ZAjzgZlIkh|V z=_D$m2o#*b$!T=?yejR9Q(qaBC=RJKen!-Rf@?{26+&rGqyj3TgmG9&Ww}pdhC^{U zuoYy>ET9%j1mYG^3?+_3Zd42*)u13-aslN~A|N?eJ(LIzt)7BVFa{M*MU=KgBA_Hn z7>7x&KJO+-(fkx-%POEKN(AE8Qxy}29w|G@!Zzxu3$p4d46y5}46=ju_V=g2uB$d| zyNKejEn}*~wv8wc+tODbwoOEVfF-Dq)zjyXZ_;KtSj)$ctJghJAT(pw*+(=0(553f zl3|3l^H0Ww8XBHwjorxyyr47*xAwmokDgXSu)VH%fzSIm+w_AjA*d4g$qIu zJJ2q3l#*hIw?>vPjA)SSwa3aSB1a-7`;u;br?xL&BdPOs3?sJeK*}Hlu>&1Z7fwWSAg#{nUdqlhp4x>r zW+gPQta_?8CowdyoR_!PCOwpm#pCp0=hpnuy29b4ZY0>2v7?G^W?3ZRif&YTM3dYh zL}Qck@ed%hNj-&h^Jb{s9NQ4Xo=PG_gCQ#=03n8sTFOWh#~?v}^d>}uAgd+-A%c!_ z8jE4*UJI&J4B!b*QKtr~L6a2}I)_9Z6*ZTAb}N+3%o3l?6-?rF?H0{7`5RfTTr~d!eUf|Xt&L_&Jj^hY2Cajj@=+rv2dOm z4aG~SEmKEvp>xF4QC(y3rx77YHSJR|!8e^KcB+Zo(sk6A<029E6xbNOoQdI6WKu}U z9be9lEkKh}FM3|f*imARi^$kfW7Fv+O|$UGyW``}M1nJX6lexNR%NC`#LQ7Id_u|BA$8i;Q)^Sovb}B0imeO+ zt?j6`nKZBNC^z#2Y?zzxF;Vm}eXeM8U%{E}S_AkhZVKOcYYdVHZ!l4uJ4JUgl$EFE zrtDMl;MZr0@a4=o=>zqgehn*uzw#kh4P$I!7%VDF+OsIyorocY>VsbskC14+v=;s3dYzQbku1}+8f@~oe%;& zwl{Sv7=r*$-K8ZFaHsC;>yZE6z?ZUEz!?5}Q}=91{^QhrE&qh#KQ>o-wjm{?fD8t7 zz-vonfKzu`caA{kC!4(_R6qs=n!2?mQgEj36ogO}`G;JJL;++_;HkT`BnminUtjaL&` zrJ;0!)39*ruEVgM?(#33x;2jd7EMVwm7_l;V!L$e){CdVvaxjP)(A_cZf%+WmIn2L z)Cr0%@|vl;f->reQlKrdj^N#%%b7+GQ_j>qonYF)pSp)Q)fO~$CsO82h*S4`v{Y+2 zQ+HY=b%DwPe)}VZYU-TYV#(A!m6tbl&rL%=_t0_c#UItZjaQJA-WF}buO^WlR(I`@AxdC#xeZ2=b*|E z{fBY-Y`2RzKUBC-FmGTh5W{o(=q#||>Z(ZXUzGGi09U%8+e&4dahtv@8OQMwj<|Za+a8#ZPt;ZNfp0wZdcbxt^aVhx-ujJbhG;6bi3K5 z$IiVYsduZo0&4w-8`atl-G_lWQ1v`0?~M&-?an$`kehHjT5@!LE^+QhIhD!yGP_HS z)Orp#qboxqK=-1>d>UQ!1O`L#bY|;|X&@HH5}Nv2dlXJY#zV3)fJr#JKAcLmfxW`i zi>s?)2p0IM`)LE7f#;T^EqDT!MW#MenZN)%?<##gjlZd|^_4XCrudJwTp5g0@BaaB C&1b^^ literal 28792 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 { } }); }); + +// These assert the documented contract rather than snapshotting whatever the +// implementation currently emits. https://bun.com/docs/runtime/color +describe("ansi output is a well-formed SGR sequence", () => { + const sgr = /^\u001b\[[\d;]+m$/; + + test.each(["ansi-16", "ansi-256", "ansi-16m"])("%s", format => { + for (const input of ["black", "red", "lime", "blue", "white", "magenta", "cyan", "yellow", "#336699"]) { + const escape = color(input, format as any); + expect(typeof escape).toBe("string"); + expect(escape).toMatch(sgr); + } + }); + + // 30..=37 for the first eight colors, 90..=97 for their bright variants. + // https://github.com/oven-sh/bun/issues/22161 + test("ansi-16 uses the 16-color SGR parameters", () => { + expect(color("black", "ansi-16")).toBe("\u001b[30m"); + expect(color("green", "ansi-16")).toBe("\u001b[32m"); + expect(color("gray", "ansi-16")).toBe("\u001b[37m"); + expect(color("red", "ansi-16")).toBe("\u001b[91m"); + expect(color("lime", "ansi-16")).toBe("\u001b[92m"); + expect(color("blue", "ansi-16")).toBe("\u001b[94m"); + expect(color("magenta", "ansi-16")).toBe("\u001b[95m"); + expect(color("white", "ansi-16")).toBe("\u001b[97m"); + }); + + test("ansi-16 never emits a 256-color escape", () => { + for (let r = 0; r < 256; r += 51) { + for (let g = 0; g < 256; g += 51) { + for (let b = 0; b < 256; b += 51) { + expect(color({ r, g, b }, "ansi-16")).toMatch(/^\u001b\[(3[0-7]|9[0-7])m$/); + } + } + } + }); + + test("ansi-256 and ansi-16m keep their documented shapes", () => { + expect(color("red", "ansi-256")).toBe("\u001b[38;5;196m"); + expect(color("red", "ansi-16m")).toBe("\u001b[38;2;255;0;0m"); + }); + + // A terminal skips the whole escape, so the printed width is just the text. + test.each(["ansi-16", "ansi-256", "ansi-16m"])("%s occupies no columns", format => { + expect(Bun.stringWidth(color("red", format as any) + "hello")).toBe(5); + }); + + test("every 24-bit color produces a well-formed ansi-16 sequence", () => { + withoutAggressiveGC(() => { + for (let r = 0; r < 256; r += 17) { + for (let g = 0; g < 256; g += 17) { + for (let b = 0; b < 256; b += 17) { + const escape = color({ r, g, b }, "ansi-16"); + if (!sgr.test(escape!)) throw new Error(`color(${r},${g},${b}, "ansi-16") = ${JSON.stringify(escape)}`); + } + } + } + }); + }); +}); + +describe("css string output parses back to the same color", () => { + const inputs = ["red", "#336699", "rgb(1, 2, 3)", "#000000", "#ffffff"]; + + test.each(["css", "hex", "HEX", "rgb", "rgba"])("%s round-trips", format => { + for (const input of inputs) { + expect(color(color(input, format as any) as string, "hex")).toBe(color(input, "hex")); + } + }); + + // `hsl(0, 1, 0.5)` is near-black as CSS: saturation and lightness must be + // percentages. Bun cannot parse its own hsl output back. + test.failing("hsl round-trips", () => { + expect(color(color("red", "hsl") as string, "hex")).toBe("#ff0000"); + }); + + // `lab()` takes L in 0..100 and is space-separated, so `lab(0.54, 80.8, 69.9)` + // is neither the right lightness nor valid syntax. + test.failing("lab round-trips", () => { + expect(color(color("red", "lab") as string, "hex")).toBe("#ff0000"); + }); +}); + +describe("input forms", () => { + test.each([ + ["a named color", "red"], + ["3-digit hex", "#f00"], + ["6-digit hex", "#ff0000"], + ["8-digit hex", "#ff0000ff"], + ["rgb()", "rgb(255, 0, 0)"], + ["rgba()", "rgba(255, 0, 0, 1)"], + ["hsl() with percentages", "hsl(0, 100%, 50%)"], + ["a number", 0xff0000], + ["an object", { r: 255, g: 0, b: 0 }], + ["an array", [255, 0, 0]], + ])("%s resolves to red", (_name, input) => { + expect(color(input as any, "hex")).toBe("#ff0000"); + }); + + test("an unparseable color is null", () => { + expect(color("notacolor", "hex")).toBeNull(); + expect(color("", "hex")).toBeNull(); + expect(color("#gg0000", "hex")).toBeNull(); + }); + + test("alpha survives the object and array forms", () => { + expect(color("#f00", "{rgba}")).toEqual({ r: 255, g: 0, b: 0, a: 1 }); + expect(color("#f00", "[rgba]")).toEqual([255, 0, 0, 255]); + expect(color("#f00", "{rgb}")).toEqual({ r: 255, g: 0, b: 0 }); + expect(color("#f00", "[rgb]")).toEqual([255, 0, 0]); + }); +}); From 6e21a68294f4c24bb923af69c9e3d7e587de2149 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 4 Jul 2026 20:17:18 +0000 Subject: [PATCH 2/7] [autofix.ci] apply automated fixes --- docs/runtime/color.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/runtime/color.mdx b/docs/runtime/color.mdx index 3afb1c305d4a..4f1ae09624d2 100644 --- a/docs/runtime/color.mdx +++ b/docs/runtime/color.mdx @@ -9,7 +9,7 @@ description: Format colors as CSS, ANSI, numbers, hex strings, and more | ------------ | -------------------------------- | | `"css"` | `"red"` | | `"ansi"` | `"\x1b[38;2;255;0;0m"` | -| `"ansi-16"` | `"\x1b[91m"` | +| `"ansi-16"` | `"\x1b[91m"` | | `"ansi-256"` | `"\x1b[38;5;196m"` | | `"ansi-16m"` | `"\x1b[38;2;255;0;0m"` | | `"number"` | `0x1a2b3c` | From a2651dba1a5fb925a2b295c14e0a1f09e59e0166 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 4 Jul 2026 20:25:37 +0000 Subject: [PATCH 3/7] color: ansi-256 underflowed the grey ramp index for near-black colors 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 "#020202" produced index 429496961: $ bun -e 'console.log(JSON.stringify(Bun.color("#020202", "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. --- src/css_jsc/color_js.rs | 5 ++++- test/js/bun/css/color.test.ts | 36 +++++++++++++++++++++++++++++------ 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/src/css_jsc/color_js.rs b/src/css_jsc/color_js.rs index d607cc146f5d..4c47528734f9 100644 --- a/src/css_jsc/color_js.rs +++ b/src/css_jsc/color_js.rs @@ -129,7 +129,10 @@ pub mod ansi256 { let grey_idx = if grey_avg > 238 { 23 } else { - (grey_avg.wrapping_sub(3)) / 10 + // tmux does this in signed int, where (2 - 3) / 10 truncates to 0. + // Wrapping on u32 would send the palette index into the hundreds of + // millions for any average below 3. + grey_avg.saturating_sub(3) / 10 }; let grey = 8u32.wrapping_add(10u32.wrapping_mul(grey_idx)); diff --git a/test/js/bun/css/color.test.ts b/test/js/bun/css/color.test.ts index 5ac4728de072..e1c988e1724a 100644 --- a/test/js/bun/css/color.test.ts +++ b/test/js/bun/css/color.test.ts @@ -329,9 +329,9 @@ describe("ansi output is a well-formed SGR sequence", () => { }); test("ansi-16 never emits a 256-color escape", () => { - for (let r = 0; r < 256; r += 51) { - for (let g = 0; g < 256; g += 51) { - for (let b = 0; b < 256; b += 51) { + for (let r = 0; r < 256; r += r < 8 ? 1 : 51) { + for (let g = 0; g < 256; g += g < 8 ? 1 : 51) { + for (let b = 0; b < 256; b += b < 8 ? 1 : 51) { expect(color({ r, g, b }, "ansi-16")).toMatch(/^\u001b\[(3[0-7]|9[0-7])m$/); } } @@ -343,6 +343,30 @@ describe("ansi output is a well-formed SGR sequence", () => { expect(color("red", "ansi-16m")).toBe("\u001b[38;2;255;0;0m"); }); + // The palette only has 256 entries, so a valid-looking `38;5;429496961m` is + // still a broken escape. The grey ramp is where the index arithmetic underflows. + test("ansi-256 never emits an index outside the palette", () => { + withoutAggressiveGC(() => { + for (let value = 0; value < 256; value++) { + for (const rgb of [ + { r: value, g: value, b: value }, + { r: 0, g: 0, b: value }, + { r: value, g: 0, b: 0 }, + ]) { + const index = Number(color(rgb, "ansi-256")!.match(/38;5;(\d+)m/)![1]); + if (index > 255) throw new Error(`color(${JSON.stringify(rgb)}, "ansi-256") = index ${index}`); + } + } + }); + }); + + // https://github.com/tmux/tmux/blob/master/colour.c + test("near-black colors land on black, not on a wrapped grey index", () => { + expect(color("#020202", "ansi-256")).toBe("\u001b[38;5;16m"); + expect(color("#020202", "ansi-16")).toBe("\u001b[30m"); + expect(color("#000004", "ansi-256")).toBe("\u001b[38;5;16m"); + }); + // A terminal skips the whole escape, so the printed width is just the text. test.each(["ansi-16", "ansi-256", "ansi-16m"])("%s occupies no columns", format => { expect(Bun.stringWidth(color("red", format as any) + "hello")).toBe(5); @@ -350,9 +374,9 @@ describe("ansi output is a well-formed SGR sequence", () => { test("every 24-bit color produces a well-formed ansi-16 sequence", () => { withoutAggressiveGC(() => { - for (let r = 0; r < 256; r += 17) { - for (let g = 0; g < 256; g += 17) { - for (let b = 0; b < 256; b += 17) { + for (let r = 0; r < 256; r += r < 8 ? 1 : 17) { + for (let g = 0; g < 256; g += g < 8 ? 1 : 17) { + for (let b = 0; b < 256; b += b < 8 ? 1 : 17) { const escape = color({ r, g, b }, "ansi-16"); if (!sgr.test(escape!)) throw new Error(`color(${r},${g},${b}, "ansi-16") = ${JSON.stringify(escape)}`); } From 8abbd05aa167028e5862fecd8812b7b69172e8a0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 4 Jul 2026 21:04:23 +0000 Subject: [PATCH 4/7] color: hsl and lab emitted strings that are not valid CSS 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. --- packages/bun-types/bun.d.ts | 4 ++-- src/css_jsc/color_js.rs | 18 +++++++++++---- test/js/bun/css/color.test.ts | 41 +++++++++++++++++++++++++++++------ 3 files changed, 50 insertions(+), 13 deletions(-) diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index c3def63b1275..2a58e5f76e4b 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -4610,11 +4610,11 @@ declare module "bun" { */ | "HEX" /** - * @example hsl(35.764706, 1, 0.5) + * @example hsl(35.764706, 100%, 50%) */ | "hsl" /** - * @example lab(0.72732764, 33.938198, -25.311619) + * @example lab(72.732764% 33.938198 -25.311619) */ | "lab" /** diff --git a/src/css_jsc/color_js.rs b/src/css_jsc/color_js.rs index 4c47528734f9..732822bdbac9 100644 --- a/src/css_jsc/color_js.rs +++ b/src/css_jsc/color_js.rs @@ -557,9 +557,15 @@ pub fn js_function_color(global: &JSGlobalObject, frame: &CallFrame) -> JsResult _ => break 'formatted, }; + // Saturation and lightness are stored as 0..1 but hsl() + // takes percentages. An achromatic color has no hue, and + // NaN is not a number hsl() accepts. + let hue = if hsl.h.is_nan() { 0.0 } else { hsl.h }; break 'color BunString::create_format(format_args!( - "hsl({}, {}, {})", - hsl.h, hsl.s, hsl.l + "hsl({}, {}%, {}%)", + hue, + hsl.s * 100.0, + hsl.l * 100.0 )); } OutputColorFormat::Lab => { @@ -573,9 +579,13 @@ pub fn js_function_color(global: &JSGlobalObject, frame: &CallFrame) -> JsResult _ => break 'formatted, }; + // lab() is space-separated and takes lightness as a + // percentage, matching what the CSS printer emits. break 'color BunString::create_format(format_args!( - "lab({}, {}, {})", - lab.l, lab.a, lab.b + "lab({}% {} {})", + lab.l * 100.0, + lab.a, + lab.b )); } } diff --git a/test/js/bun/css/color.test.ts b/test/js/bun/css/color.test.ts index e1c988e1724a..a2d53eda015d 100644 --- a/test/js/bun/css/color.test.ts +++ b/test/js/bun/css/color.test.ts @@ -395,16 +395,43 @@ describe("css string output parses back to the same color", () => { } }); - // `hsl(0, 1, 0.5)` is near-black as CSS: saturation and lightness must be - // percentages. Bun cannot parse its own hsl output back. - test.failing("hsl round-trips", () => { - expect(color(color("red", "hsl") as string, "hex")).toBe("#ff0000"); + test("hsl round-trips", () => { + for (const input of [...inputs, "#808080", "lime", "rebeccapurple"]) { + expect(color(color(input, "hsl") as string, "hex")).toBe(color(input, "hex")); + } + }); + + test("hsl round-trips across the color cube", () => { + withoutAggressiveGC(() => { + for (let r = 0; r < 256; r += 37) { + for (let g = 0; g < 256; g += 53) { + for (let b = 0; b < 256; b += 61) { + const back = color(color({ r, g, b }, "hsl") as string, "hex"); + if (back !== color({ r, g, b }, "hex")) { + throw new Error(`hsl(${r},${g},${b}) round-tripped to ${back}`); + } + } + } + } + }); + }); + + // An achromatic color has no hue, and `hsl(NaN, ...)` is not parseable. + test("hsl of a grey has a zero hue", () => { + expect(color("#808080", "hsl")).toBe("hsl(0, 0%, 50.196083%)"); + expect(color("#000000", "hsl")).toBe("hsl(0, 0%, 0%)"); + }); + + test("lab output is CSS that Bun can parse back", () => { + for (const input of [...inputs, "#808080", "lime", "rebeccapurple"]) { + expect(color(color(input, "lab") as string, "hex")).not.toBeNull(); + } }); - // `lab()` takes L in 0..100 and is space-separated, so `lab(0.54, 80.8, 69.9)` - // is neither the right lightness nor valid syntax. + // Not a formatting problem: `Bun.color("lab(19.5% 53.2 -87.3)", "hex")` is off by + // the same amount, so the lab <-> sRGB conversion itself loses saturated blues. test.failing("lab round-trips", () => { - expect(color(color("red", "lab") as string, "hex")).toBe("#ff0000"); + expect(color(color("#0000f8", "lab") as string, "hex")).toBe("#0000f8"); }); }); From 37f12f8d1dc4dcaeb7996511d8d6799f74434b6a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 4 Jul 2026 21:06:22 +0000 Subject: [PATCH 5/7] color: trim the ansi-16 comment to the 3-line limit --- src/css_jsc/color_js.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/css_jsc/color_js.rs b/src/css_jsc/color_js.rs index 732822bdbac9..7e57b7f76c52 100644 --- a/src/css_jsc/color_js.rs +++ b/src/css_jsc/color_js.rs @@ -492,11 +492,9 @@ pub fn js_function_color(global: &JSGlobalObject, frame: &CallFrame) -> JsResult rgba.green as u32, rgba.blue as u32, ); - // Foreground text color as a 16-color SGR parameter: - // 30..=37 for the first eight, 90..=97 for their - // bright variants. The 38;5;{index} form only a - // 256-color terminal understands is what ansi-256 is - // for. + // 16-color SGR: 30..=37 for the first eight, 90..=97 + // for their bright variants. The 38;5;{index} form + // only a 256-color terminal reads is ansi-256's job. let sgr = if index < 8 { 30 + index } else { 82 + index }; let mut buf = [0u8; 8]; buf[0..2].copy_from_slice(b"\x1b["); From 174c90281b66688a840bce83a8635912878ca9c7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 4 Jul 2026 21:43:21 +0000 Subject: [PATCH 6/7] test: pin the lab round-trip failure to its issue, assert the forward 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. --- test/js/bun/css/color.test.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/test/js/bun/css/color.test.ts b/test/js/bun/css/color.test.ts index a2d53eda015d..b15633100425 100644 --- a/test/js/bun/css/color.test.ts +++ b/test/js/bun/css/color.test.ts @@ -428,10 +428,16 @@ describe("css string output parses back to the same color", () => { } }); - // Not a formatting problem: `Bun.color("lab(19.5% 53.2 -87.3)", "hex")` is off by - // the same amount, so the lab <-> sRGB conversion itself loses saturated blues. + // https://github.com/oven-sh/bun/issues/33331 test.failing("lab round-trips", () => { - expect(color(color("#0000f8", "lab") as string, "hex")).toBe("#0000f8"); + expect(color(color("#0000ff", "lab") as string, "hex")).toBe("#0000ff"); + }); + + // The forward direction is exact, so the inverse is the broken one. + test("lab of a primary matches the CIELAB D50 reference", () => { + expect(color("#ff0000", "lab")).toBe("lab(54.290546% 80.80492 69.89099)"); + expect(color("#00ff00", "lab")).toBe("lab(87.81854% -79.27111 80.994606)"); + expect(color("#0000ff", "lab")).toBe("lab(29.5683% 68.287384 -112.02972)"); }); }); From 05f9f24b401f6ece3d4117b4a563e82045844bbd Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 4 Jul 2026 22:22:59 +0000 Subject: [PATCH 7/7] color: a none component leaked NaN into hsl and lab output 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. --- src/css_jsc/color_js.rs | 24 +++++++++++++++--------- test/js/bun/css/color.test.ts | 28 ++++++++++++++++++++++------ 2 files changed, 37 insertions(+), 15 deletions(-) diff --git a/src/css_jsc/color_js.rs b/src/css_jsc/color_js.rs index 7e57b7f76c52..d39572fd154f 100644 --- a/src/css_jsc/color_js.rs +++ b/src/css_jsc/color_js.rs @@ -189,6 +189,13 @@ pub mod ansi256 { } } +/// A missing color component (CSS Color 4's `none`, or the hue of an achromatic +/// color) is stored as NaN, and behaves as zero outside of interpolation. Printing +/// it as `NaN` would produce a string no CSS parser accepts. +fn zero_if_none(component: f32) -> f32 { + if component.is_nan() { 0.0 } else { component } +} + pub fn js_function_color(global: &JSGlobalObject, frame: &CallFrame) -> JsResult { use bun_ast::symbol::Map as SymbolMap; use bun_core::ZigStringSlice; @@ -556,14 +563,13 @@ pub fn js_function_color(global: &JSGlobalObject, frame: &CallFrame) -> JsResult }; // Saturation and lightness are stored as 0..1 but hsl() - // takes percentages. An achromatic color has no hue, and - // NaN is not a number hsl() accepts. - let hue = if hsl.h.is_nan() { 0.0 } else { hsl.h }; + // takes percentages. A missing component (an achromatic + // hue, or `none`) is a zero value in a concrete color. break 'color BunString::create_format(format_args!( "hsl({}, {}%, {}%)", - hue, - hsl.s * 100.0, - hsl.l * 100.0 + zero_if_none(hsl.h), + zero_if_none(hsl.s) * 100.0, + zero_if_none(hsl.l) * 100.0 )); } OutputColorFormat::Lab => { @@ -581,9 +587,9 @@ pub fn js_function_color(global: &JSGlobalObject, frame: &CallFrame) -> JsResult // percentage, matching what the CSS printer emits. break 'color BunString::create_format(format_args!( "lab({}% {} {})", - lab.l * 100.0, - lab.a, - lab.b + zero_if_none(lab.l) * 100.0, + zero_if_none(lab.a), + zero_if_none(lab.b) )); } } diff --git a/test/js/bun/css/color.test.ts b/test/js/bun/css/color.test.ts index b15633100425..a1d9b0f10186 100644 --- a/test/js/bun/css/color.test.ts +++ b/test/js/bun/css/color.test.ts @@ -418,7 +418,7 @@ describe("css string output parses back to the same color", () => { // An achromatic color has no hue, and `hsl(NaN, ...)` is not parseable. test("hsl of a grey has a zero hue", () => { - expect(color("#808080", "hsl")).toBe("hsl(0, 0%, 50.196083%)"); + expect(color("#808080", "hsl")).toMatch(/^hsl\(0, 0%, 50\.19\d*%\)$/); expect(color("#000000", "hsl")).toBe("hsl(0, 0%, 0%)"); }); @@ -433,11 +433,27 @@ describe("css string output parses back to the same color", () => { expect(color(color("#0000ff", "lab") as string, "hex")).toBe("#0000ff"); }); - // The forward direction is exact, so the inverse is the broken one. - test("lab of a primary matches the CIELAB D50 reference", () => { - expect(color("#ff0000", "lab")).toBe("lab(54.290546% 80.80492 69.89099)"); - expect(color("#00ff00", "lab")).toBe("lab(87.81854% -79.27111 80.994606)"); - expect(color("#0000ff", "lab")).toBe("lab(29.5683% 68.287384 -112.02972)"); + // The forward direction is exact, so the inverse is the broken one. It goes + // through cbrt, so the last f32 digit varies by platform; compare numerically. + test.each([ + ["#ff0000", [54.29, 80.8, 69.89]], + ["#00ff00", [87.82, -79.27, 80.99]], + ["#0000ff", [29.57, 68.29, -112.03]], + ])("lab of %s matches the CIELAB D50 reference", (input, reference) => { + const components = (color(input as string, "lab") as string).match(/-?[\d.]+/g)!.map(Number); + expect(components).toHaveLength(3); + for (let i = 0; i < 3; i++) { + expect(components[i]).toBeCloseTo((reference as number[])[i], 1); + } + }); + + // A `none` component is a zero value outside of interpolation, and `NaN` is not + // a token any CSS parser accepts. + test("a none component does not leak NaN into the output", () => { + expect(color("hsl(120 none 50%)", "hsl")).toBe("hsl(120, 0%, 50%)"); + expect(color("lab(none 40 30)", "lab")).toBe("lab(0% 40 30)"); + expect(color("lab(50% none 30)", "lab")).toBe("lab(50% 0 30)"); + expect(color(color("hsl(120 none 50%)", "hsl") as string, "hex")).not.toBeNull(); }); });