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;