diff --git a/src/__tests__/compiler/compiler.test.tsx b/src/__tests__/compiler/compiler.test.tsx index b08e24c3..6e47ae54 100644 --- a/src/__tests__/compiler/compiler.test.tsx +++ b/src/__tests__/compiler/compiler.test.tsx @@ -99,6 +99,26 @@ test("preserves excluded CSS variables using the public compiler option", () => expect(rule?.v?.some(([name]) => name === "red")).toBe(false); }); +test.each([ + [ + "an hsl() alpha", + `:root { --alpha: 0.5; } .test { color: hsl(0 100% 50% / var(--alpha)); }`, + `.test { color: hsl(0 100% 50% / 0.5); }`, + ], + [ + "an rgb() alpha", + `:root { --alpha: 0.5; } .test { color: rgb(255 0 0 / var(--alpha)); }`, + `.test { color: rgb(255 0 0 / 0.5); }`, + ], + [ + "a light-dark() arm", + `:root { --dark: #654321; } .test { color: light-dark(#123456, var(--dark)); }`, + `.test { color: light-dark(#123456, #654321); }`, + ], +])("folds a variable read inside %s", (_name, css, folded) => { + expect(compile(css).stylesheet()).toStrictEqual(compile(folded).stylesheet()); +}); + test("multiple rules with same selector", () => { const compiled = compile(` .redOrGreen:hover { 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..544a023e 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,208 @@ describe("hsla", () => { }); }); +// Chromium 153, Firefox 155 and WebKit 26.6 compute each colour below with an alpha of 0.5 +const BROWSER_COLORS = [ + ["rgb(255 0 0)", "rgba(255, 0, 0, 0.5)"], + ["rgb(100% 0% 0%)", "rgba(255, 0, 0, 0.5)"], + ["rgb(51 102 153)", "rgba(51, 102, 153, 0.5)"], + ["rgb(127.5 0 0)", "rgba(128, 0, 0, 0.5)"], + ["rgb(50% 25% 10%)", "rgba(128, 64, 26, 0.5)"], + ["rgb(50% 25% 75%)", "rgba(128, 64, 191, 0.5)"], + ["rgb(300 -20 0)", "rgba(255, 0, 0, 0.5)"], + ["hsl(120 50% 50%)", "rgba(64, 191, 64, 0.5)"], + ["hsl(120deg 100% 25%)", "rgba(0, 128, 0, 0.5)"], + ["hsl(0.5turn 50% 50%)", "rgba(64, 191, 191, 0.5)"], + ["hsl(3.14159rad 50% 50%)", "rgba(64, 191, 191, 0.5)"], + ["hsl(200grad 50% 50%)", "rgba(64, 191, 191, 0.5)"], + ["hsl(480 50% 50%)", "rgba(64, 191, 64, 0.5)"], + ["hsl(-120 50% 50%)", "rgba(64, 64, 191, 0.5)"], + ["hsl(-600 100% 50%)", "rgba(0, 255, 0, 0.5)"], + ["hsl(1e7 100% 50%)", "rgba(170, 0, 255, 0.5)"], + ["hsl(120 150% 50%)", "rgba(0, 255, 0, 0.5)"], + ["hsl(120 50% 120%)", "rgba(255, 255, 255, 0.5)"], + ["hsl(120 -10% 50%)", "rgba(128, 128, 128, 0.5)"], + ["hsl(0 84.2% 60.2%)", "rgba(239, 68, 68, 0.5)"], + ["hsl(33.3 47.4% 11.2%)", "rgba(42, 30, 15, 0.5)"], +] as const; + +// The same browsers render an infinite or NaN hue as hue 0 +const NON_FINITE_HUE_COLORS = [ + ["hsl(calc(infinity) 50% 50%)", "rgba(191, 64, 64, 0.5)"], + ["hsl(calc(-infinity) 50% 50%)", "rgba(191, 64, 64, 0.5)"], + ["hsl(calc(NaN) 50% 50%)", "rgba(191, 64, 64, 0.5)"], +] as const; + +function withAlpha(color: string, alpha: string) { + return `${color.slice(0, -1)} / ${alpha})`; +} + +function renderedBackground(id = testID) { + 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; +} + +function renderInParent() { + render( + + + , + ); +} + +describe("a colour with an unresolved alpha renders what browsers compute", () => { + test.each([...BROWSER_COLORS, ...NON_FINITE_HUE_COLORS])( + "%s, alpha read at runtime", + (color, expected) => { + registerCSS( + `.parent { --a: 0.5; } .my-class { background-color: ${withAlpha(color, "var(--a)")}; }`, + { inlineVariables: false }, + ); + + renderInParent(); + + expect(renderedBackground()).toBe(processColor(expected)); + }, + ); + + test.each([...BROWSER_COLORS, ...NON_FINITE_HUE_COLORS])( + "%s, alpha read from a fallback", + (color, expected) => { + registerCSS( + `.my-class { background-color: ${withAlpha(color, "var(--a, 0.5)")}; }`, + ); + + renderInParent(); + + expect(renderedBackground()).toBe(processColor(expected)); + }, + ); + + test.each(BROWSER_COLORS)( + "%s, alpha folded at compile time", + (color, expected) => { + registerCSS( + `.parent { --a: 0.5; } .my-class { background-color: ${withAlpha(color, "var(--a)")}; }`, + ); + + renderInParent(); + + expect(renderedBackground()).toBe(processColor(expected)); + }, + ); + + test.each(BROWSER_COLORS)( + "%s, resolved at compile time", + (color, expected) => { + registerCSS( + `.my-class { background-color: ${withAlpha(color, "0.5")}; }`, + ); + + renderInParent(); + + expect(renderedBackground()).toBe(processColor(expected)); + }, + ); + + test("the alpha is the variable's value on the element", () => { + registerCSS( + `.parent { --a: 0.5; } .my-class { --a: 0.25; background-color: ${withAlpha("rgb(255 0 0)", "var(--a)")}; }`, + { inlineVariables: false }, + ); + + renderInParent(); + + expect(renderedBackground()).toBe(processColor("rgba(255, 0, 0, 0.25)")); + }); +}); + +describe("a hue the second lightningcss pass reads as a float32", () => { + // Chromium and WebKit reduce 3e9 to 120deg; Firefox renders it as hue 0 + test("below 2 ** 32 the hue reduces", () => { + registerCSS( + `.my-class { background-color: ${withAlpha("hsl(3e9 100% 50%)", "var(--a, 0.5)")}; }`, + ); + + renderInParent(); + + expect(renderedBackground()).toBe(processColor("rgba(0, 255, 0, 0.5)")); + }); + + test.each(["4294967296", "1e20", "1e38"])( + "%s names no angle and renders as hue 0", + (hue) => { + registerCSS(` + .my-class { background-color: ${withAlpha(`hsl(${hue} 100% 50%)`, "var(--a, 0.5)")}; } + .resolved { background-color: hsl(${hue} 100% 50% / 0.5); } + `); + + render( + <> + + + , + ); + + expect(renderedBackground()).toBe(processColor("rgba(255, 0, 0, 0.5)")); + expect(renderedBackground("resolved")).toBe(renderedBackground()); + }, + ); + + // lightningcss's first pass resolves an infinite hue to an achromatic colour; browsers render hue 0 + test("lightningcss resolves calc(infinity) to black where the unresolved spelling renders hue 0", () => { + registerCSS(` + .resolved { background-color: hsl(calc(infinity) 100% 50%); } + .my-class { background-color: ${withAlpha("hsl(calc(infinity) 100% 50%)", "var(--a, 1)")}; } + `); + + render( + <> + + + , + ); + + expect(renderedBackground("resolved")).toBe(processColor("#000")); + expect(renderedBackground()).toBe(processColor("rgb(255, 0, 0)")); + }); +}); + +describe("light-dark()", () => { + afterEach(() => { + act(() => { + colorScheme.set("light"); + }); + }); + + test("carries an unresolved alpha into both schemes", () => { + registerCSS(` + :root { color-scheme: light dark; } + .my-class { + background-color: light-dark( + rgb(50% 25% 10% / var(--a, 0.5)), + hsl(120 100% 50% / var(--a, 0.5)) + ); + } + `); + + render(); + + expect(renderedBackground()).toBe(processColor("rgba(128, 64, 26, 0.5)")); + + act(() => { + colorScheme.set("dark"); + }); + + expect(renderedBackground()).toBe(processColor("rgba(0, 255, 0, 0.5)")); + }); +}); + describe("currentcolor", () => { test("currentcolor and global variables", () => { registerCSS(` diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index 9ca2aab8..022f32c0 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -2924,6 +2924,19 @@ export function parseTranslateProp( return parseLength(value[prop], builder); } +// colorjs.io holds sRGB in 0-1 and rgba() takes 0-255; CSS Color 4 renders a missing component as 0 +function toRgbChannel(coordinate: number | null): number { + return Math.round((coordinate ?? 0) * 255); +} + +// The second lightningcss pass reparses a hue as a float32, whose step from 2 ** 32 spans more than a turn +const FLOAT32_HUE_LIMIT = 2 ** 32; + +// Browsers render an infinite or NaN hue as 0, and past the float32 limit a hue names no angle either +function toHueDegrees(hue: number): number { + return Math.abs(hue) < FLOAT32_HUE_LIMIT ? hue : 0; +} + export function parseUnresolvedColor( color: UnresolvedColor, builder: StylesheetBuilder, @@ -2932,27 +2945,35 @@ export function parseUnresolvedColor( ): StyleDescriptor { switch (color.type) { case "rgb": + // lightningcss hands over rgb channels as 0-255 integers, the scale 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": { + // The channels are resolved here, so they convert to the sRGB parseColor writes for the same colour + 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"]], diff --git a/src/compiler/inline-variables.ts b/src/compiler/inline-variables.ts index 609760c1..1d94c001 100644 --- a/src/compiler/inline-variables.ts +++ b/src/compiler/inline-variables.ts @@ -151,6 +151,21 @@ function flattenPart( part.value.arguments = part.value.arguments.flatMap((arg) => { return flattenPart(arg, vars); }); + } else if (part.type === "unresolved-color") { + const color = part.value; + + switch (color.type) { + case "rgb": + case "hsl": + color.alpha = color.alpha.flatMap((arg) => flattenPart(arg, vars)); + break; + case "light-dark": + color.light = color.light.flatMap((arg) => flattenPart(arg, vars)); + color.dark = color.dark.flatMap((arg) => flattenPart(arg, vars)); + break; + default: + color satisfies never; + } } return part;