From 1350675440b104b9869266a6f95588e827cd9f40 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 9 Oct 2026 13:32:38 +0300 Subject: [PATCH 1/4] fix(compiler): fold a variable read inside a colour function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lightningcss parses `hsl(0 100% 50% / var(--a))`, `rgb(… / var(--a))` and `light-dark(#123, var(--b))` as an unresolved colour whose channels are token lists, and the inliner walked `var` and `function` tokens only. A variable declared once therefore lost its declaration while a read inside a colour function kept naming it, so the alpha, or the arm, came out empty. --- src/__tests__/compiler/compiler.test.tsx | 20 ++++++++++++++++++++ src/compiler/inline-variables.ts | 15 +++++++++++++++ 2 files changed, 35 insertions(+) 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/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; From be544ad8fc28124cd6cb1ab948b36ea02801c8ce Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 9 Oct 2026 13:33:18 +0300 Subject: [PATCH 2/4] fix: resolve light-dark() at runtime as one value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CSS Color 5 §7 makes `light-dark()` one value with two arms, resolved against the used colour scheme. It compiled to two rules instead: the current rule with the light arm and an extra rule under `prefers-color-scheme: dark` with the dark one. Wherever the dark rule could not reproduce the declaration it came from, dark mode broke: - a second `light-dark()` re-asserted the first one's light value, and the dark rule published the light colour to `currentcolor`; - a shorthand wrote its dark colour under the shorthand's name, so `box-shadow` became a bare colour and `border` gained a `border` key; - a custom property holding `light-dark()` kept its light value in dark mode wherever the inliner did not fold it; - inside a pseudo-element, the dark arm landed on the element. `light-dark()` now compiles to a `lightDark` value the runtime resolves against the colour scheme where it stands, so a shorthand, a custom property, a pseudo-element and the colour a rule publishes all carry it unchanged. The extra-rule mechanism had no other user and is removed. --- src/__tests__/compiler/compiler.test.tsx | 15 +-- src/__tests__/compiler/light-dark.test.ts | 94 +++++++++++++ src/__tests__/native/light-dark.test.tsx | 157 ++++++++++++++++++++++ src/compiler/declarations.ts | 57 ++++---- src/compiler/stylesheet.ts | 55 -------- src/native/styles/functions/index.ts | 1 + src/native/styles/functions/light-dark.ts | 19 +++ 7 files changed, 298 insertions(+), 100 deletions(-) create mode 100644 src/__tests__/compiler/light-dark.test.ts create mode 100644 src/__tests__/native/light-dark.test.tsx create mode 100644 src/native/styles/functions/light-dark.ts diff --git a/src/__tests__/compiler/compiler.test.tsx b/src/__tests__/compiler/compiler.test.tsx index 6e47ae54..3868a5a1 100644 --- a/src/__tests__/compiler/compiler.test.tsx +++ b/src/__tests__/compiler/compiler.test.tsx @@ -230,20 +230,7 @@ test("light-dark()", () => { "my-class", [ { - d: [ - { - backgroundColor: "#333b3c", - }, - ], - s: [1, 1], - }, - { - d: [ - { - backgroundColor: "#efefec", - }, - ], - m: [["=", "prefers-color-scheme", "dark"]], + d: [[[{}, "lightDark", ["#333b3c", "#efefec"]], "backgroundColor"]], s: [1, 1], }, ], diff --git a/src/__tests__/compiler/light-dark.test.ts b/src/__tests__/compiler/light-dark.test.ts new file mode 100644 index 00000000..79481591 --- /dev/null +++ b/src/__tests__/compiler/light-dark.test.ts @@ -0,0 +1,94 @@ +import { compile, type CompilerOptions } from "react-native-css/compiler"; + +import type { StyleRule } from "../../compiler/compiler.types"; + +const inlineVariablesOff = { inlineVariables: false } as const; + +const rulesFor = ( + css: string, + className = "a", + options: CompilerOptions = {}, +): StyleRule[] => + compile(css, options) + .stylesheet() + .s?.find(([name]) => name === className)?.[1] ?? []; + +const readLightDark = (value: unknown): unknown => + Array.isArray(value) + ? value[1] === "lightDark" + ? value[2] + : value.map(readLightDark).find((found) => found !== undefined) + : undefined; + +// CSS Color 5 §7: light-dark() is one value with two arms, resolved against the used colour scheme +describe("light-dark() compiles to one value", () => { + test.each([ + ["color", "color: light-dark(#123456, #654321)"], + ["background-color", "background-color: light-dark(#123456, #654321)"], + ["a border colour", "border: 1px solid light-dark(#123456, #654321)"], + ["a shadow colour", "box-shadow: 0 0 1px light-dark(#123456, #654321)"], + [ + "a text shadow colour", + "text-shadow: 1px 1px 1px light-dark(#123456, #654321)", + ], + ["a custom property", "--x: light-dark(#123456, #654321)"], + ])("%s gets one rule holding both colours", (_name, declaration) => { + const rules = rulesFor(`.a { ${declaration}; }`, "a", inlineVariablesOff); + + expect(rules).toHaveLength(1); + expect(rules[0]?.m).toBeUndefined(); + expect(readLightDark([rules[0]?.d, rules[0]?.v])).toStrictEqual([ + "#123456", + "#654321", + ]); + }); + + test("a colour publishes the same value to its subtree", () => { + const [rule] = rulesFor(`.a { color: light-dark(#123456, #654321); }`); + + expect(rule?.v).toStrictEqual([ + ["__rn-css-color", [{}, "lightDark", ["#123456", "#654321"]]], + ]); + }); + + test("a :root custom property keeps both colours", () => { + expect( + compile( + `:root { --x: light-dark(#123456, #654321); }`, + inlineVariablesOff, + ).stylesheet().vr, + ).toStrictEqual([["x", [[[{}, "lightDark", ["#123456", "#654321"]]]]]]); + }); + + test("a branch that reads a variable delays the declaration", () => { + const [rule] = rulesFor( + `.a { color: light-dark(#123456, var(--d)); }`, + "a", + inlineVariablesOff, + ); + + expect(rule?.dv).toBe(1); + expect(readLightDark(rule?.d)).toStrictEqual([ + "#123456", + [{}, "var", "d", 1], + ]); + }); + + test("a media query keeps only its own condition", () => { + expect( + rulesFor( + `@media (min-width: 100px) { .a { color: light-dark(#123456, #654321); } }`, + ).map((rule) => rule.m), + ).toStrictEqual([[[">=", "width", 100]]]); + }); + + test("a pseudo-element maps it to its prop", () => { + expect( + rulesFor(`.a::placeholder { color: light-dark(#123456, #654321); }`).map( + (rule) => rule.d, + ), + ).toStrictEqual([ + [[[{}, "lightDark", ["#123456", "#654321"]], ["placeholderTextColor"]]], + ]); + }); +}); diff --git a/src/__tests__/native/light-dark.test.tsx b/src/__tests__/native/light-dark.test.tsx new file mode 100644 index 00000000..32166a59 --- /dev/null +++ b/src/__tests__/native/light-dark.test.tsx @@ -0,0 +1,157 @@ +import { act, render, screen } from "@testing-library/react-native"; +import type { CompilerOptions } from "react-native-css/compiler"; +import { TextInput } from "react-native-css/components/TextInput"; +import { View } from "react-native-css/components/View"; +import { registerCSS, testID } from "react-native-css/jest"; +import { colorScheme } from "react-native-css/runtime"; + +const parentTestID = "parent"; + +// An arm is a colour, which may nest one function inside another: hsl(0 100% 50% / var(--a)) +const ARGUMENT = String.raw`(?:[^(),]|\((?:[^()]|\([^()]*\))*\))+`; +const ARM = new RegExp( + String.raw`light-dark\((${ARGUMENT}),\s*(${ARGUMENT})\)`, + "g", +); + +/** The same stylesheet with every `light-dark()` replaced by one of its arms */ +const withArm = (css: string, arm: "light" | "dark"): string => + css.replaceAll(ARM, (_match, light: string, dark: string) => + arm === "light" ? light : dark, + ); + +const renderTree = (css: string, options: CompilerOptions) => { + registerCSS(css, options); + + const { unmount } = render( + + + , + ); + + const readStyles = () => ({ + parent: screen.getByTestId(parentTestID).props.style, + element: screen.getByTestId(testID).props.style, + }); + + return { readStyles, unmount }; +}; + +const scenes = [ + ["color", `.a { color: light-dark(#123456, #654321); }`], + [ + "background-color", + `.a { background-color: light-dark(#123456, #654321); }`, + ], + ["a border colour", `.a { border: 1px solid light-dark(#123456, #654321); }`], + [ + "a shadow colour", + `.a { box-shadow: 0 0 1px light-dark(#123456, #654321); }`, + ], + [ + "a text shadow colour", + `.a { text-shadow: 1px 1px 1px light-dark(#123456, #654321); }`, + ], + [ + "a :root custom property", + `:root { --ld-root: light-dark(#123456, #654321); } .a { color: var(--ld-root); }`, + ], + [ + "a universal custom property", + `* { --ld-universal: light-dark(#123456, #654321); } .a { color: var(--ld-universal); }`, + ], + [ + "an ancestor's custom property", + `.p { --x: light-dark(#123456, #654321); } .a { color: var(--x); }`, + ], + [ + "the element's own custom property", + `.a { --x: light-dark(#123456, #654321); color: var(--x); }`, + ], + [ + "currentcolor from an ancestor", + `.p { color: light-dark(#123456, #654321); } .a { border-width: 1px; border-color: currentcolor; }`, + ], + [ + "an ancestor's colour beside its other variables", + `.p { --other: 5px; color: light-dark(#123456, #654321); } .a { width: var(--other); background-color: currentcolor; }`, + ], + [ + "two declarations on one rule", + `.p { color: light-dark(#123456, #654321); background-color: light-dark(#111111, #222222); } .a { background-color: currentcolor; }`, + ], + [ + "a branch that reads a variable", + `.p { --d: #654321; } .a { background-color: light-dark(#123456, var(--d)); }`, + ], + [ + "two branches that read a variable", + `:root { --ld-alpha: 1; } .a { color: light-dark(hsl(0 100% 50% / var(--ld-alpha)), hsl(240 100% 50% / var(--ld-alpha))); background-color: light-dark(hsl(120 100% 25% / var(--ld-alpha)), hsl(60 100% 50% / var(--ld-alpha))); }`, + ], + ["!important", `.a { color: light-dark(#123456, #654321) !important; }`], + [ + "a media query", + `@media (min-width: 0px) { .a { color: light-dark(#123456, #654321); } }`, + ], +] as const; + +// CSS Color 5 §7: light-dark() is its first colour unless the used colour scheme is dark +describe.each([ + ["on", {}], + ["off", { inlineVariables: false }], +] as const satisfies readonly (readonly [string, CompilerOptions])[])( + "with inline variables %s", + (_mode, options) => { + test.each(scenes)( + "%s renders its light colour, then its dark colour", + (_name, css) => { + act(() => { + colorScheme.set("light"); + }); + + const expected = (arm: "light" | "dark") => { + const tree = renderTree(withArm(css, arm), options); + const styles = tree.readStyles(); + tree.unmount(); + return styles; + }; + + const lightStyles = expected("light"); + const darkStyles = expected("dark"); + + expect(darkStyles).not.toStrictEqual(lightStyles); + + const tree = renderTree(css, options); + + expect(tree.readStyles()).toStrictEqual(lightStyles); + + act(() => { + colorScheme.set("dark"); + }); + + expect(tree.readStyles()).toStrictEqual(darkStyles); + + act(() => { + colorScheme.set("light"); + }); + + expect(tree.readStyles()).toStrictEqual(lightStyles); + }, + ); + }, +); + +test("a pseudo-element's light-dark() reaches its prop, never the element", () => { + registerCSS(`.a::placeholder { color: light-dark(#123456, #654321); }`); + render(); + const component = screen.getByTestId(testID); + + expect(component.props.placeholderTextColor).toBe("#123456"); + + act(() => { + colorScheme.set("dark"); + }); + + expect(component.props.placeholderTextColor).toBe("#654321"); + expect(component.props.style).toStrictEqual({}); +}); diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index 9ca2aab8..e38eb089 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -38,11 +38,7 @@ import type { import { isStyleFunction } from "../utilities"; import { isMappingProperty } from "./atRules"; -import type { - StyleDescriptor, - StyleFunction, - StyleRule, -} from "./compiler.types"; +import type { StyleDescriptor, StyleFunction } from "./compiler.types"; import { parseEasingFunction, parseIterationCount } from "./keyframes"; import { toRNProperty } from "./selectors"; import type { StylesheetBuilder } from "./stylesheet"; @@ -316,8 +312,6 @@ function parseWithParser(declaration: Declaration, builder: StylesheetBuilder) { if (declaration.property in parsers) { const parser = parsers[declaration.property] as Parser; - builder.descriptorProperty = declaration.property; - builder.setWarningProperty(declaration.property); const value = parser(declaration, builder, declaration.property); @@ -981,8 +975,6 @@ export function parseUnparsedDeclaration( /** * Unparsed shorthand properties need to be parsed at runtime */ - builder.descriptorProperty = property; - if (unparsedRuntimeParsing.has(property)) { const args = parseUnparsed(declaration.value.value, builder, property); @@ -1678,7 +1670,10 @@ function nanToZero(value: number): number { return Number.isNaN(value) ? 0 : value; } -export function parseColor(cssColor: CssColor, builder: StylesheetBuilder) { +export function parseColor( + cssColor: CssColor, + builder: StylesheetBuilder, +): StyleDescriptor { if (typeof cssColor === "string") { if (namedColors.has(cssColor)) { return cssColor; @@ -1694,18 +1689,12 @@ export function parseColor(cssColor: CssColor, builder: StylesheetBuilder) { case "currentcolor": return [{}, "var", "__rn-css-color"] as const; case "light-dark": { - const extraRule: StyleRule = { - s: [], - m: [["=", "prefers-color-scheme", "dark"]], - }; - - builder.addUnnamedDescriptor( - parseColor(cssColor.dark, builder), - false, - extraRule, - ); - builder.addExtraRule(extraRule); - return parseColor(cssColor.light, builder); + const light = parseColor(cssColor.light, builder); + const dark = parseColor(cssColor.dark, builder); + + return light === undefined || dark === undefined + ? undefined + : ([{}, "lightDark", [light, dark]] as const); } case "rgb": { color = new Color({ @@ -2954,16 +2943,22 @@ export function parseUnresolvedColor( ], ]; case "light-dark": { - const extraRule = builder.extendRule({ - m: [["=", "prefers-color-scheme", "dark"]], - }); - builder.addUnnamedDescriptor( - reduceParseUnparsed(color.dark, builder, property, allowAuto), - false, - extraRule, + const light = reduceParseUnparsed( + color.light, + builder, + property, + allowAuto, + ); + const dark = reduceParseUnparsed( + color.dark, + builder, + property, + allowAuto, ); - builder.addExtraRule(extraRule); - return reduceParseUnparsed(color.light, builder, property, allowAuto); + + return light === undefined || dark === undefined + ? undefined + : [{}, "lightDark", [light, dark]]; } default: color satisfies never; diff --git a/src/compiler/stylesheet.ts b/src/compiler/stylesheet.ts index a77fdf89..1cb33d2a 100644 --- a/src/compiler/stylesheet.ts +++ b/src/compiler/stylesheet.ts @@ -34,8 +34,6 @@ const staticDeclarations = new WeakMap< Record >(); -const extraRules = new WeakMap[]>(); - const keywords = new Set(["unset"]); export class StylesheetBuilder { @@ -58,7 +56,6 @@ export class StylesheetBuilder { }, // Any default mapping should be included in the @nativeMapping parsing private mapping: StyleRuleMapping = {}, - public descriptorProperty?: string, private shared: { ruleSets: Record; rootVariables?: VariableRecord; @@ -102,7 +99,6 @@ export class StylesheetBuilder { mode, this.cloneRule(), { ...this.mapping }, - this.descriptorProperty, this.shared, selectors, ); @@ -121,25 +117,6 @@ export class StylesheetBuilder { return rule; } - private createRuleFromPartial(rule: StyleRule, partial: Partial) { - rule = this.cloneRule(rule); - - if (partial.m) { - rule.m ??= []; - rule.m.push(...partial.m); - } - - if (partial.d) { - rule.d = partial.d; - } - - return rule; - } - - extendRule(rule: Partial) { - return this.cloneRule({ ...this.rule, ...rule }); - } - getOptions(): CompilerOptions { return this.options; } @@ -267,16 +244,6 @@ export class StylesheetBuilder { this.newRule(mapping, { important }); } - /** Hack for light-dark, which requires adding a new rule without changing the current rule */ - addExtraRule(rule: Partial) { - let extraRuleArray = extraRules.get(this.rule); - if (!extraRuleArray) { - extraRuleArray = []; - extraRules.set(this.rule, extraRuleArray); - } - extraRuleArray.push(rule); - } - private addRuleToRuleSet(name: string, rule = this.rule) { if (this.shared.ruleSets[name]) { this.shared.ruleSets[name].push(rule); @@ -300,18 +267,6 @@ export class StylesheetBuilder { } } - addUnnamedDescriptor( - value: StyleDescriptor, - forceTuple?: boolean, - rule = this.rule, - ) { - if (this.descriptorProperty === undefined) { - return; - } - - this.addDescriptor(this.descriptorProperty, value, forceTuple, rule); - } - addDescriptor( property: string, value: StyleDescriptor, @@ -536,16 +491,6 @@ export class StylesheetBuilder { } this.addRuleToRuleSet(className, rule); - - const extraRulesArray = extraRules.get(this.rule); - if (extraRulesArray) { - for (const extraRule of extraRulesArray) { - this.addRuleToRuleSet( - className, - this.createRuleFromPartial(rule, extraRule), - ); - } - } } else { // These can only have variable declarations if (!this.rule.v) { diff --git a/src/native/styles/functions/index.ts b/src/native/styles/functions/index.ts index 02f9308d..0cd67871 100644 --- a/src/native/styles/functions/index.ts +++ b/src/native/styles/functions/index.ts @@ -6,3 +6,4 @@ export * from "./platform-functions"; export * from "./string-functions"; export * from "./transform-functions"; export * from "./color-mix"; +export * from "./light-dark"; diff --git a/src/native/styles/functions/light-dark.ts b/src/native/styles/functions/light-dark.ts new file mode 100644 index 00000000..9406e392 --- /dev/null +++ b/src/native/styles/functions/light-dark.ts @@ -0,0 +1,19 @@ +import { isStyleDescriptorArray } from "react-native-css/utilities"; + +import { colorScheme } from "../../reactivity"; +import type { StyleFunctionResolver } from "../resolve"; + +/** + * CSS Color 5 `light-dark()`: the first colour unless the used colour scheme is dark. Only + * that branch is resolved, so the element reads the other branch's variables only once the + * scheme changes + */ +export const lightDark: StyleFunctionResolver = (resolveValue, value, get) => { + const branches = value[2]; + + if (!isStyleDescriptorArray(branches) || branches.length !== 2) { + return; + } + + return resolveValue(get(colorScheme) === "dark" ? branches[1] : branches[0]); +}; From ed70a07172b33ee29f9eae7e13aee202885fde19 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 9 Oct 2026 13:36:58 +0300 Subject: [PATCH 3/4] test(compiler): drop a light-dark() with an unpaintable arm as its colour drops --- src/__tests__/compiler/light-dark.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/__tests__/compiler/light-dark.test.ts b/src/__tests__/compiler/light-dark.test.ts index 79481591..faac91b3 100644 --- a/src/__tests__/compiler/light-dark.test.ts +++ b/src/__tests__/compiler/light-dark.test.ts @@ -91,4 +91,16 @@ describe("light-dark() compiles to one value", () => { [[[{}, "lightDark", ["#123456", "#654321"]], ["placeholderTextColor"]]], ]); }); + + test.each([ + "light-dark(canvastext, #654321)", + "light-dark(#123456, canvastext)", + ])("%s drops the declaration as the colour it cannot paint does", (value) => { + expect( + rulesFor(`.a { color: light-dark(#123456, #654321); }`), + ).not.toStrictEqual([]); + expect(rulesFor(`.a { color: ${value}; }`)).toStrictEqual( + rulesFor(`.a { color: canvastext; }`), + ); + }); }); From bd350eeec1d96759897f2b34d9ef5e1b28da72cd Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 9 Oct 2026 14:46:40 +0300 Subject: [PATCH 4/4] feat: resolve light-dark() against the element's used color-scheme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CSS Color 5 §7 resolves light-dark() against the element's used colour scheme, which CSS Color Adjust 1 §2.3 derives from color-scheme: the preferred scheme when the element supports it, the one it supports otherwise, and light when it supports none. Chromium, Firefox and WebKit render it that way. color-scheme now compiles to a custom property the element publishes to its subtree, and the lightDark resolver reads it beside the preferred scheme, so a light-dark() declaration resolves with the variables in scope. BREAKING CHANGE: light-dark() renders its light colour unless color-scheme supports dark. Declare `color-scheme: light dark` on :root for light-dark() to follow the preferred scheme, as browsers require. --- src/__tests__/compiler/compiler.test.tsx | 5 +- src/__tests__/compiler/light-dark.test.ts | 53 +++- src/__tests__/native/light-dark.test.tsx | 287 ++++++++++++++++++++-- src/compiler/declarations.ts | 27 +- src/compiler/stylesheet.ts | 9 +- src/native/styles/functions/index.ts | 1 - src/native/styles/functions/light-dark.ts | 19 +- src/native/styles/resolve.ts | 7 +- src/utilities/color-scheme.ts | 47 ++++ src/utilities/index.ts | 1 + 10 files changed, 420 insertions(+), 36 deletions(-) create mode 100644 src/utilities/color-scheme.ts diff --git a/src/__tests__/compiler/compiler.test.tsx b/src/__tests__/compiler/compiler.test.tsx index 3868a5a1..1637849f 100644 --- a/src/__tests__/compiler/compiler.test.tsx +++ b/src/__tests__/compiler/compiler.test.tsx @@ -230,7 +230,10 @@ test("light-dark()", () => { "my-class", [ { - d: [[[{}, "lightDark", ["#333b3c", "#efefec"]], "backgroundColor"]], + d: [ + [[{}, "lightDark", ["#333b3c", "#efefec"]], "backgroundColor", 1], + ], + dv: 1, s: [1, 1], }, ], diff --git a/src/__tests__/compiler/light-dark.test.ts b/src/__tests__/compiler/light-dark.test.ts index faac91b3..1b1c1148 100644 --- a/src/__tests__/compiler/light-dark.test.ts +++ b/src/__tests__/compiler/light-dark.test.ts @@ -1,4 +1,5 @@ import { compile, type CompilerOptions } from "react-native-css/compiler"; +import { COLOR_SCHEME_VARIABLE } from "react-native-css/utilities"; import type { StyleRule } from "../../compiler/compiler.types"; @@ -88,7 +89,13 @@ describe("light-dark() compiles to one value", () => { (rule) => rule.d, ), ).toStrictEqual([ - [[[{}, "lightDark", ["#123456", "#654321"]], ["placeholderTextColor"]]], + [ + [ + [{}, "lightDark", ["#123456", "#654321"]], + ["placeholderTextColor"], + 1, + ], + ], ]); }); @@ -104,3 +111,47 @@ describe("light-dark() compiles to one value", () => { ); }); }); + +test("a light-dark() declaration reads the element's colour scheme", () => { + const [rule] = rulesFor(`.a { color: light-dark(#123456, #654321); }`); + + expect(rule?.dv).toBe(1); +}); + +// CSS Color Adjust 1 §2.2: color-scheme publishes the schemes it supports to its subtree and renders nothing of its own +describe("color-scheme", () => { + test.each([ + ["normal", "normal"], + ["light", "light"], + ["dark", "dark"], + ["light dark", "light dark"], + ["dark light", "light dark"], + ["only light", "light"], + ["only dark", "dark"], + ["only light dark", "light dark"], + ["brand", "normal"], + ["brand dark", "dark"], + ])("color-scheme: %s publishes %s", (value, support) => { + const compiled = compile(`.a { color-scheme: ${value}; }`); + + expect(compiled.stylesheet()).toStrictEqual({ + s: [["a", [{ s: [1, 1], v: [[COLOR_SCHEME_VARIABLE, support]] }]]], + }); + expect(compiled.warnings()).toStrictEqual({}); + }); + + test.each([ + ["var(--scheme)", [{}, "var", "scheme", 1]], + ["var(--scheme, dark)", [{}, "var", ["scheme", "dark"], 1]], + ])("color-scheme: %s publishes the reference", (value, reference) => { + expect( + rulesFor(`.a { color-scheme: ${value}; }`, "a", inlineVariablesOff), + ).toStrictEqual([{ s: [1, 1], v: [[COLOR_SCHEME_VARIABLE, reference]] }]); + }); + + test("a :root color-scheme reaches every element", () => { + expect( + compile(`:root { color-scheme: light dark; }`).stylesheet().vr, + ).toStrictEqual([[COLOR_SCHEME_VARIABLE, [["light dark"]]]]); + }); +}); diff --git a/src/__tests__/native/light-dark.test.tsx b/src/__tests__/native/light-dark.test.tsx index 32166a59..f44a4961 100644 --- a/src/__tests__/native/light-dark.test.tsx +++ b/src/__tests__/native/light-dark.test.tsx @@ -3,8 +3,17 @@ import type { CompilerOptions } from "react-native-css/compiler"; import { TextInput } from "react-native-css/components/TextInput"; import { View } from "react-native-css/components/View"; import { registerCSS, testID } from "react-native-css/jest"; +import { + rootVariables, + StyleCollection, + universalVariables, +} from "react-native-css/native-internal"; import { colorScheme } from "react-native-css/runtime"; +import { COLOR_SCHEME_VARIABLE } from "react-native-css/utilities"; +type Arm = "light" | "dark"; + +const grandparentTestID = "grandparent"; const parentTestID = "parent"; // An arm is a colour, which may nest one function inside another: hsl(0 100% 50% / var(--a)) @@ -14,22 +23,39 @@ const ARM = new RegExp( "g", ); -/** The same stylesheet with every `light-dark()` replaced by one of its arms */ -const withArm = (css: string, arm: "light" | "dark"): string => - css.replaceAll(ARM, (_match, light: string, dark: string) => +/** The same stylesheet with every `light-dark()` replaced by one of its arms, a nested one included */ +const withArm = (css: string, arm: Arm): string => { + const replaced = css.replaceAll(ARM, (_match, light: string, dark: string) => arm === "light" ? light : dark, ); + return replaced === css ? css : withArm(replaced, arm); +}; + +// registerCSS keeps every :root and * variable between tests, and this channel has one fixed name +beforeEach(() => { + rootVariables(COLOR_SCHEME_VARIABLE).set([]); + universalVariables(COLOR_SCHEME_VARIABLE).set([]); +}); + +const inlineVariableModes = [ + ["on", {}], + ["off", { inlineVariables: false }], +] as const satisfies readonly (readonly [string, CompilerOptions])[]; + const renderTree = (css: string, options: CompilerOptions) => { registerCSS(css, options); const { unmount } = render( - - + + + + , ); const readStyles = () => ({ + grandparent: screen.getByTestId(grandparentTestID).props.style, parent: screen.getByTestId(parentTestID).props.style, element: screen.getByTestId(testID).props.style, }); @@ -37,6 +63,16 @@ const renderTree = (css: string, options: CompilerOptions) => { return { readStyles, unmount }; }; +const renderedStyles = (css: string, options: CompilerOptions) => { + const tree = renderTree(css, options); + const styles = tree.readStyles(); + tree.unmount(); + return styles; +}; + +// CSS Color Adjust 1 §2.3: an element that supports both schemes uses the preferred one +const SUPPORTS_BOTH_SCHEMES = ":root { color-scheme: light dark; }"; + const scenes = [ ["color", `.a { color: light-dark(#123456, #654321); }`], [ @@ -44,6 +80,35 @@ const scenes = [ `.a { background-color: light-dark(#123456, #654321); }`, ], ["a border colour", `.a { border: 1px solid light-dark(#123456, #654321); }`], + [ + "four border colours", + `.a { border-width: 1px; border-color: light-dark(#111111, #222222) light-dark(#333333, #444444) light-dark(#555555, #666666) light-dark(#777777, #888888); }`, + ], + [ + "the inline border colours", + `.a { border-width: 1px; border-inline-color: light-dark(#123456, #654321) light-dark(#111111, #222222); }`, + ], + [ + "one border side", + `.a { border-top: 1px solid light-dark(#123456, #654321); }`, + ], + [ + "the block border", + `.a { border-block: 1px solid light-dark(#123456, #654321); }`, + ], + [ + "the inline-start border", + `.a { border-inline-start: 1px solid light-dark(#123456, #654321); }`, + ], + ["outline-color", `.a { outline-color: light-dark(#123456, #654321); }`], + [ + "text-decoration-color", + `.a { text-decoration-line: underline; text-decoration-color: light-dark(#123456, #654321); }`, + ], + [ + "the text-decoration shorthand", + `.a { text-decoration: underline light-dark(#123456, #654321); }`, + ], [ "a shadow colour", `.a { box-shadow: 0 0 1px light-dark(#123456, #654321); }`, @@ -52,6 +117,14 @@ const scenes = [ "a text shadow colour", `.a { text-shadow: 1px 1px 1px light-dark(#123456, #654321); }`, ], + [ + "a nested light-dark()", + `.a { color: light-dark(light-dark(#123456, #654321), #778899); }`, + ], + [ + "a var() fallback", + `.a { color: var(--ld-missing, light-dark(#123456, #654321)); }`, + ], [ "a :root custom property", `:root { --ld-root: light-dark(#123456, #654321); } .a { color: var(--ld-root); }`, @@ -95,29 +168,21 @@ const scenes = [ ], ] as const; -// CSS Color 5 §7: light-dark() is its first colour unless the used colour scheme is dark -describe.each([ - ["on", {}], - ["off", { inlineVariables: false }], -] as const satisfies readonly (readonly [string, CompilerOptions])[])( +// CSS Color 5 §7: light-dark() is its first colour unless the element's used colour scheme is dark +describe.each(inlineVariableModes)( "with inline variables %s", (_mode, options) => { test.each(scenes)( "%s renders its light colour, then its dark colour", - (_name, css) => { + (_name, scene) => { + const css = `${SUPPORTS_BOTH_SCHEMES} ${scene}`; + act(() => { colorScheme.set("light"); }); - const expected = (arm: "light" | "dark") => { - const tree = renderTree(withArm(css, arm), options); - const styles = tree.readStyles(); - tree.unmount(); - return styles; - }; - - const lightStyles = expected("light"); - const darkStyles = expected("dark"); + const lightStyles = renderedStyles(withArm(css, "light"), options); + const darkStyles = renderedStyles(withArm(css, "dark"), options); expect(darkStyles).not.toStrictEqual(lightStyles); @@ -141,8 +206,154 @@ describe.each([ }, ); +const followsPreference = { light: "light", dark: "dark" } as const; +const alwaysLight = { light: "light", dark: "light" } as const; +const alwaysDark = { light: "dark", dark: "dark" } as const; + +// The arm each scene takes under each preference is the one Chromium 153, Firefox 155 and WebKit 26.6 render +const colorSchemeScenes = [ + [ + "no color-scheme", + `.a { color: light-dark(#123456, #654321); }`, + alwaysLight, + ], + [ + "color-scheme: normal", + `:root { color-scheme: normal; } .a { color: light-dark(#123456, #654321); }`, + alwaysLight, + ], + [ + "color-scheme: light dark", + `:root { color-scheme: light dark; } .a { color: light-dark(#123456, #654321); }`, + followsPreference, + ], + [ + "color-scheme: dark light", + `:root { color-scheme: dark light; } .a { color: light-dark(#123456, #654321); }`, + followsPreference, + ], + [ + "color-scheme: only light dark", + `:root { color-scheme: only light dark; } .a { color: light-dark(#123456, #654321); }`, + followsPreference, + ], + [ + "color-scheme: dark on the element", + `.a { color-scheme: dark; color: light-dark(#123456, #654321); }`, + alwaysDark, + ], + [ + "color-scheme: light on the element, under a root that supports both", + `:root { color-scheme: light dark; } .a { color-scheme: light; color: light-dark(#123456, #654321); }`, + alwaysLight, + ], + [ + "color-scheme: only light", + `:root { color-scheme: only light; } .a { color: light-dark(#123456, #654321); }`, + alwaysLight, + ], + [ + "color-scheme: only dark", + `:root { color-scheme: only dark; } .a { color: light-dark(#123456, #654321); }`, + alwaysDark, + ], + [ + "color-scheme inherited from an ancestor", + `.p { color-scheme: dark; } .a { color: light-dark(#123456, #654321); }`, + alwaysDark, + ], + [ + "color-scheme: normal under a dark ancestor", + `.p { color-scheme: dark; } .a { color-scheme: normal; color: light-dark(#123456, #654321); }`, + alwaysLight, + ], + [ + "a custom ident alone", + `:root { color-scheme: brand; } .a { color: light-dark(#123456, #654321); }`, + alwaysLight, + ], + [ + "a custom ident beside dark", + `:root { color-scheme: brand dark; } .a { color: light-dark(#123456, #654321); }`, + alwaysDark, + ], + [ + "the later color-scheme", + `.a { color-scheme: dark; color: light-dark(#123456, #654321); } .a { color-scheme: light; }`, + alwaysLight, + ], + [ + "an !important color-scheme", + `.a { color-scheme: dark !important; color: light-dark(#123456, #654321); } .a { color-scheme: light; }`, + alwaysDark, + ], + [ + "a color-scheme in a media query", + `.a { color: light-dark(#123456, #654321); } @media (min-width: 0px) { .a { color-scheme: dark; } }`, + alwaysDark, + ], + [ + "a custom property resolved where it is used", + `.p { color-scheme: dark; --x: light-dark(#123456, #654321); } .a { color-scheme: light; color: var(--x); }`, + alwaysLight, + ], + [ + "a universal color-scheme", + `* { color-scheme: dark; } .a { color: light-dark(#123456, #654321); }`, + alwaysDark, + ], + [ + "color-scheme from var()", + `:root { --scheme: dark; } .a { color-scheme: var(--scheme); color: light-dark(#123456, #654321); }`, + alwaysDark, + ], + [ + "both schemes from var()", + `:root { --scheme: light dark; } .a { color-scheme: var(--scheme); color: light-dark(#123456, #654321); }`, + followsPreference, + ], + [ + "a var() fallback color-scheme", + `.a { color-scheme: var(--scheme-missing, dark); color: light-dark(#123456, #654321); }`, + alwaysDark, + ], + [ + "color-scheme: initial", + `.p { color-scheme: dark; } .a { color-scheme: initial; color: light-dark(#123456, #654321); }`, + alwaysLight, + ], +] as const satisfies readonly (readonly [ + string, + string, + Readonly>, +])[]; + +// CSS Color Adjust 1 §2.3: light-dark() reads the used colour scheme, which color-scheme decides and the preference only picks within +describe.each(inlineVariableModes)( + "the used colour scheme, with inline variables %s", + (_mode, options) => { + test.each(colorSchemeScenes)("%s", (_name, css, arms) => { + expect(renderedStyles(withArm(css, "light"), options)).not.toStrictEqual( + renderedStyles(withArm(css, "dark"), options), + ); + + for (const preference of ["light", "dark"] as const) { + act(() => { + colorScheme.set(preference); + }); + + expect(renderedStyles(css, options)).toStrictEqual( + renderedStyles(withArm(css, arms[preference]), options), + ); + } + }); + }, +); + test("a pseudo-element's light-dark() reaches its prop, never the element", () => { - registerCSS(`.a::placeholder { color: light-dark(#123456, #654321); }`); + registerCSS( + `${SUPPORTS_BOTH_SCHEMES} .a::placeholder { color: light-dark(#123456, #654321); }`, + ); render(); const component = screen.getByTestId(testID); @@ -155,3 +366,37 @@ test("a pseudo-element's light-dark() reaches its prop, never the element", () = expect(component.props.placeholderTextColor).toBe("#654321"); expect(component.props.style).toStrictEqual({}); }); + +test("a stylesheet whose light-dark() lacks an arm renders no colour", () => { + StyleCollection.inject({ + s: [ + [ + "a", + [ + { + s: [1, 1], + d: [[[{}, "lightDark", ["#123456"]], "color", 1]], + dv: 1, + }, + ], + ], + ], + }); + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({}); +}); + +// Chromium, Firefox and WebKit render the light arm when the platform reports no preference +test.each([ + [":root { color-scheme: light dark; }", "#123456"], + [":root { color-scheme: dark; }", "#654321"], +])("with no preference, %s renders %s", (root, color) => { + act(() => { + colorScheme.set(null); + }); + registerCSS(`${root} .a { color: light-dark(#123456, #654321); }`); + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ color }); +}); diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index e38eb089..a704d71e 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -36,7 +36,12 @@ import type { UnresolvedColor, } from "lightningcss"; -import { isStyleFunction } from "../utilities"; +import { + COLOR_SCHEME_VARIABLE, + describeColorSchemeSupport, + isStyleFunction, + LIGHT_DARK_FUNCTION, +} from "../utilities"; import { isMappingProperty } from "./atRules"; import type { StyleDescriptor, StyleFunction } from "./compiler.types"; import { parseEasingFunction, parseIterationCount } from "./keyframes"; @@ -176,6 +181,7 @@ const parsers: { "box-sizing": parseBoxSizing, "caret-color": parseColorOrAutoDeclaration, "color": parseFontColorDeclaration, + "color-scheme": parseColorScheme, "column-gap": parseGap, "container": parseContainer, "container-name": parseContainerName, @@ -983,6 +989,11 @@ export function parseUnparsedDeclaration( } else { builder.addDescriptor(property, [{}, toRNProperty(property), args, 1]); } + } else if (property === "color-scheme") { + builder.addDescriptor( + `--${COLOR_SCHEME_VARIABLE}`, + parseUnparsed(declaration.value.value, builder, property), + ); } else { const value = parseUnparsed(declaration.value.value, builder, property); @@ -1694,7 +1705,7 @@ export function parseColor( return light === undefined || dark === undefined ? undefined - : ([{}, "lightDark", [light, dark]] as const); + : ([{}, LIGHT_DARK_FUNCTION, [light, dark]] as const); } case "rgb": { color = new Color({ @@ -2545,6 +2556,16 @@ export function parseDisplay( } } +function parseColorScheme( + declaration: DeclarationType<"color-scheme">, + builder: StylesheetBuilder, +) { + builder.addDescriptor( + `--${COLOR_SCHEME_VARIABLE}`, + describeColorSchemeSupport(declaration.value), + ); +} + export function parseDirection( declaration: DeclarationType<"direction">, builder: StylesheetBuilder, @@ -2958,7 +2979,7 @@ export function parseUnresolvedColor( return light === undefined || dark === undefined ? undefined - : [{}, "lightDark", [light, dark]]; + : [{}, LIGHT_DARK_FUNCTION, [light, dark]]; } default: color satisfies never; diff --git a/src/compiler/stylesheet.ts b/src/compiler/stylesheet.ts index 1cb33d2a..99526462 100644 --- a/src/compiler/stylesheet.ts +++ b/src/compiler/stylesheet.ts @@ -2,6 +2,7 @@ import type { SelectorList } from "lightningcss"; import { isStyleDescriptorArray, + LIGHT_DARK_FUNCTION, Specificity, specificityCompareFn, } from "../utilities"; @@ -562,6 +563,11 @@ function isStyleFunction( ); } +/** Style functions that read a custom property without naming it: `light-dark()` reads the element's colour scheme */ +const IMPLICIT_VARIABLE_READERS: ReadonlySet = new Set([ + LIGHT_DARK_FUNCTION, +]); + function postProcessStyleFunction(value: StyleDescriptor): [ // Should it be delayed boolean, @@ -586,7 +592,8 @@ function postProcessStyleFunction(value: StyleDescriptor): [ let [shouldDelay, usesVariables] = postProcessStyleFunction(value[2]); - usesVariables ||= value[1] === "var"; + usesVariables ||= + value[1] === "var" || IMPLICIT_VARIABLE_READERS.has(value[1]); shouldDelay ||= value[3] === 1 || usesVariables; if (shouldDelay) { diff --git a/src/native/styles/functions/index.ts b/src/native/styles/functions/index.ts index 0cd67871..02f9308d 100644 --- a/src/native/styles/functions/index.ts +++ b/src/native/styles/functions/index.ts @@ -6,4 +6,3 @@ export * from "./platform-functions"; export * from "./string-functions"; export * from "./transform-functions"; export * from "./color-mix"; -export * from "./light-dark"; diff --git a/src/native/styles/functions/light-dark.ts b/src/native/styles/functions/light-dark.ts index 9406e392..e66fadfb 100644 --- a/src/native/styles/functions/light-dark.ts +++ b/src/native/styles/functions/light-dark.ts @@ -1,13 +1,13 @@ -import { isStyleDescriptorArray } from "react-native-css/utilities"; +import { + COLOR_SCHEME_VARIABLE, + isStyleDescriptorArray, + resolveUsedColorScheme, +} from "react-native-css/utilities"; import { colorScheme } from "../../reactivity"; import type { StyleFunctionResolver } from "../resolve"; -/** - * CSS Color 5 `light-dark()`: the first colour unless the used colour scheme is dark. Only - * that branch is resolved, so the element reads the other branch's variables only once the - * scheme changes - */ +/** CSS Color 5 §7: the first colour unless the element's used colour scheme is dark; only the chosen arm is resolved */ export const lightDark: StyleFunctionResolver = (resolveValue, value, get) => { const branches = value[2]; @@ -15,5 +15,10 @@ export const lightDark: StyleFunctionResolver = (resolveValue, value, get) => { return; } - return resolveValue(get(colorScheme) === "dark" ? branches[1] : branches[0]); + const usedColorScheme = resolveUsedColorScheme( + resolveValue([{}, "var", COLOR_SCHEME_VARIABLE]), + get(colorScheme), + ); + + return resolveValue(usedColorScheme === "dark" ? branches[1] : branches[0]); }; diff --git a/src/native/styles/resolve.ts b/src/native/styles/resolve.ts index 0cb40385..ce914ead 100644 --- a/src/native/styles/resolve.ts +++ b/src/native/styles/resolve.ts @@ -5,13 +5,17 @@ import type { StyleDescriptor, StyleFunction, } from "react-native-css/compiler"; -import { isStyleDescriptorArray } from "react-native-css/utilities"; +import { + isStyleDescriptorArray, + LIGHT_DARK_FUNCTION, +} from "react-native-css/utilities"; import type { RenderGuard } from "../conditions/guards"; import { type Getter, type VariableContextValue } from "../reactivity"; import type { calculateProps } from "./calculate-props"; import { transformKeys } from "./defaults"; import * as functions from "./functions"; +import { lightDark } from "./functions/light-dark"; import { lineHeight } from "./line-height"; import { scaleFactor } from "./scale-factor"; import * as shorthands from "./shorthands"; @@ -40,6 +44,7 @@ export type StyleResolver = ( const functionResolvers = { ...shorthands, ...functions, + [LIGHT_DARK_FUNCTION]: lightDark, lineHeight, em, rem, diff --git a/src/utilities/color-scheme.ts b/src/utilities/color-scheme.ts new file mode 100644 index 00000000..435ee2ee --- /dev/null +++ b/src/utilities/color-scheme.ts @@ -0,0 +1,47 @@ +export const LIGHT_DARK_FUNCTION = "lightDark"; + +/** The custom property an element's `color-scheme` publishes to its subtree */ +export const COLOR_SCHEME_VARIABLE = "__rn-css-color-scheme"; + +export type UsedColorScheme = "light" | "dark"; + +/** CSS Color Adjust 1 §2.2: the schemes a `color-scheme` value supports; `normal` and a custom ident support none */ +export function describeColorSchemeSupport(value: { + readonly light: boolean; + readonly dark: boolean; +}): string { + const supported: UsedColorScheme[] = []; + + if (value.light) { + supported.push("light"); + } + + if (value.dark) { + supported.push("dark"); + } + + return supported.length > 0 ? supported.join(" ") : "normal"; +} + +/** CSS Color Adjust 1 §2.3: the preferred scheme when the element supports it, else the one it supports, else light */ +export function resolveUsedColorScheme( + support: unknown, + preference: unknown, +): UsedColorScheme { + const supported = readSupportedSchemes(support); + const preferred: UsedColorScheme = preference === "dark" ? "dark" : "light"; + + if (supported.includes(preferred)) { + return preferred; + } + + return supported.includes("dark") ? "dark" : "light"; +} + +function readSupportedSchemes(support: unknown): readonly unknown[] { + if (Array.isArray(support)) { + return support; + } + + return typeof support === "string" ? support.split(" ") : []; +} diff --git a/src/utilities/index.ts b/src/utilities/index.ts index 0c95da63..12d13132 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -1,3 +1,4 @@ export * from "./specificity"; export * from "./style-descriptor"; export * from "./dot-notation.types"; +export * from "./color-scheme";