diff --git a/src/__tests__/compiler/declarations.test.tsx b/src/__tests__/compiler/declarations.test.tsx
index c2e18378..a747ce5f 100644
--- a/src/__tests__/compiler/declarations.test.tsx
+++ b/src/__tests__/compiler/declarations.test.tsx
@@ -12,6 +12,17 @@ const tests = [
["rotate: x 3deg;", [{ d: [[[{}, "rotateX", "3deg"], "rotateX"]], s: [1, 1] }]],
["stroke-width: 1px;", [{ d: [[1, ["strokeWidth"]]], s: [1, 1] }]],
["stroke: black;", [{ d: [["#000", ["stroke"]]], s: [1, 1] }]],
+ ["background-color: rgb(255 0 0 / var(--a));", [{ d: [[[{}, "rgba", [255, 0, 0, [{}, "var", "a", 1]]], "backgroundColor", 1]], dv: 1, s: [1, 1] }]],
+ ["background-color: rgb(100% 0% 0% / var(--a));", [{ d: [[[{}, "rgba", [255, 0, 0, [{}, "var", "a", 1]]], "backgroundColor", 1]], dv: 1, s: [1, 1] }]],
+ ["background-color: rgb(50% 25% 10% / var(--a));", [{ d: [[[{}, "rgba", [128, 64, 26, [{}, "var", "a", 1]]], "backgroundColor", 1]], dv: 1, s: [1, 1] }]],
+ ["background-color: hsl(0 84.2% 60.2% / var(--a));", [{ d: [[[{}, "rgba", [239, 68, 68, [{}, "var", "a", 1]]], "backgroundColor", 1]], dv: 1, s: [1, 1] }]],
+ ["background-color: hsl(120 100% 50% / var(--a));", [{ d: [[[{}, "rgba", [0, 255, 0, [{}, "var", "a", 1]]], "backgroundColor", 1]], dv: 1, s: [1, 1] }]],
+ ["background-color: hsl(calc(NaN) 100% 50% / var(--a));", [{ d: [[[{}, "rgba", [255, 0, 0, [{}, "var", "a", 1]]], "backgroundColor", 1]], dv: 1, s: [1, 1] }]],
+ ["background-color: hsl(calc(infinity) 100% 50% / var(--a));", [{ d: [[[{}, "rgba", [255, 0, 0, [{}, "var", "a", 1]]], "backgroundColor", 1]], dv: 1, s: [1, 1] }]],
+ ["background-color: hsl(4294967296 100% 50% / var(--a));", [{ d: [[[{}, "rgba", [255, 0, 0, [{}, "var", "a", 1]]], "backgroundColor", 1]], dv: 1, s: [1, 1] }]],
+ ["background-color: hsl(1e20 100% 50% / var(--a));", [{ d: [[[{}, "rgba", [255, 0, 0, [{}, "var", "a", 1]]], "backgroundColor", 1]], dv: 1, s: [1, 1] }]],
+ ["background-color: hsl(-600 100% 50% / var(--a));", [{ d: [[[{}, "rgba", [0, 255, 0, [{}, "var", "a", 1]]], "backgroundColor", 1]], dv: 1, s: [1, 1] }]],
+ ["background-color: hsl(1e7 100% 50% / var(--a));", [{ d: [[[{}, "rgba", [170, 0, 255, [{}, "var", "a", 1]]], "backgroundColor", 1]], dv: 1, s: [1, 1] }]],
] as const;
test.each(tests)("declarations for %s", (declarations, expected) => {
diff --git a/src/__tests__/native/colors.test.tsx b/src/__tests__/native/colors.test.tsx
index 6a8c7255..dc82e707 100644
--- a/src/__tests__/native/colors.test.tsx
+++ b/src/__tests__/native/colors.test.tsx
@@ -1,6 +1,9 @@
-import { render, screen } from "@testing-library/react-native";
+import { processColor } from "react-native";
+
+import { act, render, screen } from "@testing-library/react-native";
import { View } from "react-native-css/components/View";
import { registerCSS, testID } from "react-native-css/jest";
+import { colorScheme } from "react-native-css/runtime";
describe("hsl", () => {
test("inline", () => {
@@ -136,6 +139,215 @@ describe("hsla", () => {
});
});
+describe("unresolved alpha", () => {
+ test("rgb with number channels", () => {
+ registerCSS(`.my-class {
+ background-color: rgb(255 0 0 / var(--a, 0.5));
+ }`);
+
+ render();
+ const component = screen.getByTestId(testID);
+
+ expect(component.props.style).toStrictEqual({
+ backgroundColor: "rgba(255, 0, 0, 0.5)",
+ });
+ });
+
+ test("rgb with percentage channels", () => {
+ registerCSS(`.my-class {
+ background-color: rgb(50% 25% 10% / var(--a, 0.5));
+ }`);
+
+ render();
+ const component = screen.getByTestId(testID);
+
+ expect(component.props.style).toStrictEqual({
+ backgroundColor: "rgba(128, 64, 26, 0.5)",
+ });
+ });
+
+ // The resolved path compiles the same channels to `#ef4444`, and React Native
+ // rejects both `hsl()` carrying an alpha and `hsla()` missing one.
+ test("hsl resolves to the same channels as the resolved path", () => {
+ registerCSS(`.my-class {
+ background-color: hsl(0 84.2% 60.2% / var(--a, 0.5));
+ }`);
+
+ render();
+ const component = screen.getByTestId(testID);
+
+ expect(component.props.style).toStrictEqual({
+ backgroundColor: "rgba(239, 68, 68, 0.5)",
+ });
+ });
+
+ // lightningcss clamps saturation, lightness and every rgb channel, so the hue
+ // is the only channel an out-of-range `calc()` reaches the compiler through.
+ // It arrives as a 32-bit float, and past 2**32 one step of that grid covers
+ // more than a turn, so the value no longer names an angle. Each row below is a
+ // different way of landing past it and they all compile to one colour.
+ test.each([
+ "calc(NaN)", // serialized past the float range, reparses to Infinity
+ "calc(infinity)", // reparses saturated, at 9223372036854776000
+ "calc(-infinity)",
+ "4294967296", // 2**32, where one step of the grid first covers a turn
+ "1e20", // saturates too, arriving as 9223369837831520000
+ "1e38", // the same value: past the ceiling the hue is no longer carried
+ ])("hsl with a hue the float grid cannot name: %s", (hue) => {
+ registerCSS(`.my-class {
+ background-color: hsl(${hue} 100% 50% / var(--a, 0.5));
+ }`);
+
+ render();
+ const component = screen.getByTestId(testID);
+
+ expect(component.props.style).toStrictEqual({
+ backgroundColor: "rgba(255, 0, 0, 0.5)",
+ });
+ });
+
+ // The other side of that boundary: a hue far outside [0, 360) but still on a
+ // part of the grid that resolves finer than a turn is reduced, never clamped.
+ //
+ // Every row has to land on a colour the clamp does NOT also produce, or it
+ // cannot tell reduction from clamping — a hue that reduces to 0 agrees with
+ // the clamp and passes either way. `3e9` is also what bounds the threshold
+ // from below: it sits between 2**31 and 2**32, where the float32 ULP is 256
+ // and so still finer than a turn, and it arrives exactly because one
+ // significant digit survives any serializer. Together with the 2**32 row
+ // above it brackets `SMALLEST_UNNAMEABLE_HUE` to within a factor of two.
+ test.each([
+ ["-600", "rgba(0, 255, 0, 0.5)"],
+ ["3e9", "rgba(0, 255, 0, 0.5)"],
+ ["1e7", "rgba(170, 0, 255, 0.5)"],
+ ])("hsl reduces a large nameable hue: %s", (hue, expected) => {
+ registerCSS(`.my-class {
+ background-color: hsl(${hue} 100% 50% / var(--a, 0.5));
+ }`);
+
+ render();
+ const component = screen.getByTestId(testID);
+
+ expect(component.props.style).toStrictEqual({
+ backgroundColor: expected,
+ });
+ });
+
+ test("light-dark carries an unresolved alpha into both schemes", () => {
+ registerCSS(`.my-class {
+ background-color: light-dark(
+ rgb(50% 25% 10% / var(--a, 0.5)),
+ hsl(120 100% 50% / var(--a, 0.5))
+ );
+ }`);
+
+ render();
+ const component = screen.getByTestId(testID);
+
+ expect(component.props.style).toStrictEqual({
+ backgroundColor: "rgba(128, 64, 26, 0.5)",
+ });
+
+ act(() => {
+ colorScheme.set("dark");
+ });
+
+ expect(component.props.style).toStrictEqual({
+ backgroundColor: "rgba(0, 255, 0, 0.5)",
+ });
+ });
+
+ afterEach(() => {
+ act(() => {
+ colorScheme.set("light");
+ });
+ });
+});
+
+// `parseColor` compiles a fully resolved colour and `parseUnresolvedColor`
+// compiles the same channels with the alpha left open. An opaque fallback makes
+// the two spellings the same colour, so React Native has to read one number
+// from both.
+describe("unresolved alpha matches the resolved spelling", () => {
+ function renderedColor(id: string) {
+ const style: unknown = screen.getByTestId(id).props.style;
+
+ return typeof style === "object" &&
+ style !== null &&
+ "backgroundColor" in style &&
+ typeof style.backgroundColor === "string"
+ ? processColor(style.backgroundColor)
+ : undefined;
+ }
+
+ const colors = [
+ "rgb(255 0 0)",
+ "rgb(100% 0% 0%)",
+ "rgb(50% 25% 10%)",
+ "hsl(0 84.2% 60.2%)",
+ "hsl(120 100% 50%)",
+ "hsl(-600 100% 50%)",
+ "hsl(1e7 100% 50%)",
+ "hsl(calc(NaN) 100% 50%)",
+ "hsl(4294967296 100% 50%)",
+ "hsl(1e20 100% 50%)",
+ "hsl(1e38 100% 50%)",
+ ] as const;
+
+ test.each(colors)("%s", (color) => {
+ registerCSS(`
+ .resolved { background-color: ${color}; }
+ .unresolved { background-color: ${color.slice(0, -1)} / var(--a, 1)); }
+ `);
+
+ render(
+ <>
+
+
+ >,
+ );
+
+ const expected = renderedColor("resolved");
+
+ // A colour React Native rejects reads as `undefined`, which would make the
+ // comparison below pass while neither spelling renders anything.
+ expect(typeof expected).toBe("number");
+
+ expect(renderedColor("unresolved")).toBe(expected);
+ });
+
+ // `calc(infinity)` stands for a family, not a special case: sampling f32 hues
+ // above 2**32, about one in seven resolves to something other than the red the
+ // compiler emits, `5e10`, `1e12`, `1.44e38` and `calc(-infinity)` among them.
+ //
+ // What makes the family unmatchable is not that lightningcss is erratic — it
+ // is that the distinguishing information never reaches this compiler.
+ // Seventeen authored hues from `1e19` to `9223372036854775807` all arrive as
+ // the single value `9223369837831520000`, and lightningcss's resolved path
+ // splits that one arriving value twelve red to five black. No function of the
+ // hue this compiler receives can separate inputs it receives as one number.
+ //
+ // So the compiler emits the answer that IS a function of the arriving hue and
+ // the divergence is pinned here rather than reproduced. This goes red if
+ // lightningcss stabilises, which is when the row belongs in the list above.
+ test("a saturated hue diverges from lightningcss's own resolution", () => {
+ registerCSS(`
+ .resolved { background-color: hsl(calc(infinity) 100% 50%); }
+ .unresolved { background-color: hsl(calc(infinity) 100% 50% / var(--a, 1)); }
+ `);
+
+ render(
+ <>
+
+
+ >,
+ );
+
+ expect(renderedColor("resolved")).toBe(processColor("#000"));
+ expect(renderedColor("unresolved")).toBe(processColor("rgb(255, 0, 0)"));
+ });
+});
+
describe("currentcolor", () => {
test("currentcolor and global variables", () => {
registerCSS(`
diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts
index 13013642..8a89f654 100644
--- a/src/compiler/declarations.ts
+++ b/src/compiler/declarations.ts
@@ -2880,6 +2880,62 @@ export function parseTranslateProp(
return parseLength(value[prop], builder);
}
+/**
+ * colorjs.io holds sRGB in the 0-1 range while `rgba()` takes 0-255 channels.
+ * A `null` coordinate is a missing component, which CSS Color 4 treats as `0`.
+ */
+function toRgbChannel(coordinate: number | null): number {
+ return Math.round((coordinate ?? 0) * 255);
+}
+
+/**
+ * A hue reaches this function as a 32-bit float, because the compiler runs
+ * lightningcss twice and the second pass reparses the first pass's serialized
+ * output. `2 ** 32` is where one step of that grid first covers a whole turn: a
+ * float32 holds a 24-bit significand, so its ULP at `2 ** exponent` is
+ * `2 ** (exponent - 23)`, which reaches 512 at an exponent of 32.
+ *
+ * `src/__tests__/native/colors.test.tsx` brackets this constant rather than
+ * pinning it. A hue between `2 ** 31` and `2 ** 32` must still reduce and
+ * `2 ** 32` itself must not, so any constant between those two passes. The
+ * window is about a factor of two wide because the derivation above only
+ * resolves to a power of two — the ULP steps from 256 straight to 512, and no
+ * authored hue can land between them.
+ */
+const SMALLEST_UNNAMEABLE_HUE = 2 ** 32;
+
+/**
+ * Past {@link SMALLEST_UNNAMEABLE_HUE} every representable neighbour lands on a
+ * different angle, so reducing the arriving float modulo a turn reports the
+ * float grid rather than the declaration, and the value is a range limit rather
+ * than a hue.
+ *
+ * Both lightningcss passes contribute, and they saturate different inputs. A
+ * visitor is what materialises the AST into JavaScript and back, and the hue
+ * saturates to i64 on that round trip: pass one's declaration visitor saturates
+ * `1e19` through `1e38`, serializing all of them as `9223370000000000000`,
+ * while pass two's rule visitor saturates `calc(infinity)`, which pass one
+ * leaves at the float32 maximum `3.40282e38`. They arrive here as
+ * `9223369837831520000` and `9223372036854776000`. `Infinity`, which is how a
+ * `calc(NaN)` hue arrives, is the same condition at the top of the range.
+ *
+ * This threshold is about where a hue stops naming an angle, not about where it
+ * stops being exact. lightningcss's serializer keeps six significant digits, so
+ * from about `1e6` the arriving float already names a different angle than the
+ * author wrote — `12345678` arrives as `12345700`, `123456789` as `123457000` —
+ * and those hues are still reduced, from a number the serializer chose. That
+ * loss is upstream of this function and no threshold here recovers it.
+ *
+ * CSS Color 4 makes a missing component `0`, so an unnameable hue takes `0`.
+ * That is also what lightningcss's own resolved path produces for most such
+ * hues, with the divergence recorded in `src/__tests__/native/colors.test.tsx`.
+ * `Math.abs` covers `NaN` and both infinities on its own — every comparison
+ * against them is `false` — so a separate finiteness test would be dead code.
+ */
+function toHueDegrees(hue: number): number {
+ return Math.abs(hue) < SMALLEST_UNNAMEABLE_HUE ? hue : 0;
+}
+
export function parseUnresolvedColor(
color: UnresolvedColor,
builder: StylesheetBuilder,
@@ -2888,27 +2944,47 @@ export function parseUnresolvedColor(
): StyleDescriptor {
switch (color.type) {
case "rgb":
+ // lightningcss resolves rgb channels to integers in the 0-255 range,
+ // including the percentage syntax, so they are already the values
+ // `rgba()` takes.
return [
{},
"rgba",
[
- round(color.r * 255),
- round(color.g * 255),
- round(color.b * 255),
+ color.r,
+ color.g,
+ color.b,
parseUnparsed(color.alpha, builder, property),
],
];
- case "hsl":
+ case "hsl": {
+ // An `UnresolvedColor` always leaves the alpha as a `var()`, and an unset
+ // variable with no fallback drops that argument. `hsla()` is rejected
+ // three-argument, so it cannot carry an alpha that may vanish, while
+ // `rgba()` stays valid and renders opaque. lightningcss resolves the hue,
+ // saturation and lightness, so they convert to the sRGB channels
+ // `parseColor` writes for the resolved spelling and share the shape above.
+ //
+ // The hue is the only unbounded channel: lightningcss clamps saturation,
+ // lightness and every rgb channel to their range, so the hue is the one
+ // place an out-of-range `calc()` reaches this function. `toHueDegrees`
+ // decides which arriving floats still name an angle.
+ const { coords } = new Color({
+ space: "hsl",
+ coords: [toHueDegrees(color.h), color.s, color.l],
+ }).to("srgb");
+
return [
{},
- color.type,
+ "rgba",
[
- color.h,
- color.s,
- color.l,
+ toRgbChannel(coords[0]),
+ toRgbChannel(coords[1]),
+ toRgbChannel(coords[2]),
parseUnparsed(color.alpha, builder, property),
],
];
+ }
case "light-dark": {
const extraRule = builder.extendRule({
m: [["=", "prefers-color-scheme", "dark"]],