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"]],