Skip to content
Open
4 changes: 2 additions & 2 deletions docs/bundler/css.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -199,11 +199,11 @@ For browsers that don't support these color spaces, Bun's CSS bundler adds RGB f
```css title="styles.css" icon="file-code"
.vivid-element {
/* RGB fallback first for maximum compatibility */
color: #fa1a4c;
color: #ff1f51;
/* Keep original for browsers that support it */
color: color(display-p3 1 0.1 0.3);

background-color: #6a805d;
background-color: #6a815c;
background-color: color(a98-rgb 0.44 0.5 0.37);
}
```
Expand Down
10 changes: 3 additions & 7 deletions src/css/values/color.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1778,9 +1778,7 @@ define_colorspace! {
define_colorspace! {
/// A color in the [`sRGB-linear`](https://www.w3.org/TR/css-color-4/#predefined-sRGB-linear) color space.
SRGBLinear { r, g, b }
// `r` intentionally uses the angle channel type (sic) — kept for
// behavioral compatibility.
types = (CT_ANG, CT_PCT, CT_PCT);
types = (CT_PCT, CT_PCT, CT_PCT);
gamut = bounded;
premultiply = rectangular;
powerless = none;
Expand Down Expand Up @@ -2352,8 +2350,7 @@ pub(crate) fn parse_predefined_relative(
b"srgb" => PredefinedColor::Srgb(SRGB { r: a, g: b, b: c, alpha }),
b"srgb-linear" => PredefinedColor::SrgbLinear(SRGBLinear { r: a, g: b, b: c, alpha }),
b"display-p3" => PredefinedColor::DisplayP3(P3 { r: a, g: b, b: c, alpha }),
// "a99-rgb" (sic) — kept for behavioral compatibility.
b"a99-rgb" => PredefinedColor::A98(A98 { r: a, g: b, b: c, alpha }),
b"a98-rgb" => PredefinedColor::A98(A98 { r: a, g: b, b: c, alpha }),
Comment thread
claude[bot] marked this conversation as resolved.
b"prophoto-rgb" => PredefinedColor::Prophoto(ProPhoto { r: a, g: b, b: c, alpha }),
b"rec2020" => PredefinedColor::Rec2020(Rec2020 { r: a, g: b, b: c, alpha }),
b"xyz-d50" => PredefinedColor::XyzD50(XYZd50 { x: a, y: b, z: c, alpha }),
Expand Down Expand Up @@ -2458,9 +2455,8 @@ pub(crate) fn parse_color_mix(input: &mut css::Parser) -> CssResult<CssColor> {
ColorSpaceName::Xyz | ColorSpaceName::XyzD65 => {
first_color.interpolate::<XYZd65>(p1, &second_color, p2, hue_method)
}
// Intentionally XYZd65 for xyz-d50 too (sic) — kept for behavioral compatibility.
ColorSpaceName::XyzD50 => {
first_color.interpolate::<XYZd65>(p1, &second_color, p2, hue_method)
first_color.interpolate::<XYZd50>(p1, &second_color, p2, hue_method)
}
};

Expand Down
76 changes: 76 additions & 0 deletions test/js/bun/css/color.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,64 @@ describe("lab()/oklab() sRGB fallback for boundary colors (#33331)", () => {
});
});

describe("color() predefined color spaces", () => {
const spaces: [space: string, channels: string, printed?: string][] = [
["srgb", "b g r"],
["srgb-linear", "b g r"],
["display-p3", "b g r"],
["a98-rgb", "b g r"],
["prophoto-rgb", "b g r"],
["rec2020", "b g r"],
["xyz-d50", "z y x"],
["xyz-d65", "z y x", "xyz"],
["xyz", "z y x"],
];

test.each(spaces)("color(%s ...) parses", (space, channels, printed = space) => {
expect(color(`color(${space.toUpperCase()} 0 1 0 / 50%)`, "css")).toBe(`color(${printed} 0 1 0 / .5)`);
expect(color(`color(from color(${space} .5 .25 .125) ${space} ${channels})`, "css")).toBe(
`color(${printed} .125 .25 .5)`,
);
});

test("an unknown color space is not a color", () => {
// a99-rgb is the misspelling the parser used to accept in place of a98-rgb.
expect(color("color(a99-rgb 1 0 0)", "css")).toBeNull();
expect(color("color(a98rgb 1 0 0)", "css")).toBeNull();
});

test("a98-rgb converts to sRGB", () => {
expect(color("rgb(from color(a98-rgb .5 .25 .125) r g b)", "hex")).toBe("#923e17");
expect(color("color-mix(in srgb, color(a98-rgb .5 .25 .125), white)", "hex")).toBe("#c89e8b");
});

test("a98-rgb is a relative color target", () => {
expect(color("color(from #c86432 a98-rgb r g b)", "css")).toBe("color(a98-rgb .695066 .391898 .220089)");
});

test("a98-rgb channels accept percentages", () => {
expect(color("color(A98-RGB 100% 50% 0%)", "css")).toBe("color(a98-rgb 1 .5 0)");
});

test("srgb-linear relative colors can reference r", () => {
expect(color("color(from red srgb-linear r g b)", "css")).toBe("color(srgb-linear 1 0 0)");
expect(color("color(from red srgb-linear g g r)", "css")).toBe("color(srgb-linear 0 0 1)");
expect(color("color(from #808080 srgb-linear r g b / r)", "css")).toBe(
"color(srgb-linear .215861 .215861 .215861 / .215861)",
);
});

test("color-mix(in xyz-d50) stays in xyz-d50", () => {
expect(color("color-mix(in xyz-d50, color(xyz-d50 .1 .2 .3), color(xyz-d50 .3 .2 .1))", "css")).toBe(
"color(xyz-d50 .2 .2 .2)",
);
expect(color("color-mix(in xyz-d50, color(xyz-d50 none .2 .3), color(xyz-d50 .3 .2 .1))", "css")).toBe(
"color(xyz-d50 .3 .2 .2)",
);
expect(color("color-mix(in xyz, color(xyz .1 .2 .3), color(xyz .3 .2 .1))", "css")).toBe("color(xyz .2 .2 .2)");
});
});

// 2^24 color() calls take minutes on debug builds (past the per-test timeout) and dominate
// the ASAN lane, so those sweep the ansi256 equivalence classes (~13k deterministic inputs):
// each single channel, the grey diagonal, the sub-8 cube, and a coarse 17-step cube.
Expand Down Expand Up @@ -763,4 +821,22 @@ describe("conversions between color spaces", () => {
4,
);
});

test("a98-rgb to xyz applies the 563/256 gamma before the matrix", () => {
// XYZ of the a98-rgb primaries, i.e. the columns of the matrix in
// https://www.w3.org/TR/css-color-4/#color-conversion-code. a98-rgb uses a
// pure power curve instead of the sRGB piecewise transfer function.
const red = [0.576669, 0.297345, 0.027031];
const green = [0.185558, 0.627364, 0.070689];
const blue = [0.188229, 0.075291, 0.991338];
const g = 0.5 ** (563 / 256);
const b = 0.002 ** (563 / 256);
const out = same("xyz", "color(a98-rgb 1 0.5 0.002)");
expect(out).toStartWith("color(xyz ");
expectChannels(
out,
[0, 1, 2].map(i => red[i] + g * green[i] + b * blue[i]),
4,
);
});
});
Loading
Loading