From 2dc7f5acdbaa0f9197c3e5fe93c0a8323657c2d8 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Thu, 23 Jul 2026 19:50:03 +0300 Subject: [PATCH 1/3] fix(native): seed a concrete scheme-aware Android __rn-css-color On Android the root `__rn-css-color` fallback was PlatformColor('?attr/textColorPrimary'), which resolves to a ColorStateList; RN's ColorPropConverter returns the resource *reference* rather than an ARGB int, so it silently never paints. Every color resolving through the root fallback -- default ring-* / inset-ring-* (Tailwind's default ring color is currentcolor), text-current, bg-current -- was invisible on Android. iOS's PlatformColor('label') is fine. Seed a concrete color on Android instead, made scheme-aware through the root observable's existing prefers-color-scheme evaluation (no extra Appearance listener). The root variable is only the ultimate fallback -- any ancestor-published or themed --__rn-css-color overrides it -- so a binary black/white default is spec-faithful. Splitting the platforms also drops the file's `as any` and two eslint suppressions: the Android media-query seed types cleanly, and the iOS PlatformColor uses an isolated `as unknown as StyleDescriptor` bridge. Adds android + ios tests (currentcolor, live scheme reactivity, a default ring, ancestor override) that resolve the ColorStateList reference before the fix and concrete colors after. --- .../native/root-color-seed-android.test.tsx | 121 ++++++++++++++++++ .../native/root-color-seed-ios.test.tsx | 36 ++++++ .../vendor/tailwind/ring-color-seed.test.tsx | 42 ++++++ src/native-internal/root.ts | 41 ++++-- 4 files changed, 231 insertions(+), 9 deletions(-) create mode 100644 src/__tests__/native/root-color-seed-android.test.tsx create mode 100644 src/__tests__/native/root-color-seed-ios.test.tsx create mode 100644 src/__tests__/vendor/tailwind/ring-color-seed.test.tsx diff --git a/src/__tests__/native/root-color-seed-android.test.tsx b/src/__tests__/native/root-color-seed-android.test.tsx new file mode 100644 index 00000000..83f4f3ba --- /dev/null +++ b/src/__tests__/native/root-color-seed-android.test.tsx @@ -0,0 +1,121 @@ +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"; + +// The root `__rn-css-color` seed is a module-load side effect gated on +// Platform.OS, and jest-expo defaults to ios. Mock android so the runtime +// (react-native-css/jest -> native-internal/root) takes the Android branch; +// babel-jest hoists this jest.mock above the imports above. +jest.mock("react-native", () => { + const ReactNative = + jest.requireActual("react-native"); + ReactNative.Platform.OS = "android"; + return ReactNative; +}); + +describe("android root __rn-css-color seed", () => { + test("currentcolor with no ancestor resolves to a concrete color", () => { + // The bug: the Android seed was PlatformColor('?attr/textColorPrimary'), + // which resolves to a ColorStateList reference that never paints — so + // currentcolor / default rings / text-current rendered nothing. It is now a + // concrete color that always paints. + colorScheme.set("light"); + registerCSS(`.c { color: currentcolor; }`); + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + color: "#000000", + }); + }); + + test("the seed is scheme-aware — white in dark mode — via prefers-color-scheme", () => { + // Reactivity comes from the root observable's existing media-query + // evaluation reading the `colorScheme` observable — no extra Appearance + // listener is added. + colorScheme.set("dark"); + registerCSS(`.c { color: currentcolor; }`); + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + color: "#FFFFFF", + }); + }); + + test("an ancestor-published color still overrides the root seed", () => { + // The seed is only the *ultimate* fallback; a nearer published color wins, + // so the fix changes nothing for content that already has a color context. + colorScheme.set("light"); + registerCSS(` + .parent { color: red; } + .child { color: currentcolor; } + `); + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + color: "#f00", + }); + }); + + test("responds live to a colorScheme change — reactive, not seeded once", () => { + // Proves the reactivity claim: the same element re-resolves on a scheme + // flip, through the root observable's media-query evaluation alone. + colorScheme.set("light"); + registerCSS(`.c { color: currentcolor; }`); + render(); + const element = screen.getByTestId(testID); + + expect(element.props.style).toStrictEqual({ color: "#000000" }); + + act(() => { + colorScheme.set("dark"); + }); + + expect(element.props.style).toStrictEqual({ color: "#FFFFFF" }); + }); + + test("a default ring (box-shadow currentcolor) paints with the seed color", () => { + // The reported symptom: Tailwind's default ring color is currentcolor, so + // with the old ColorStateList seed every ring was invisible on Android. + colorScheme.set("light"); + registerCSS( + `.ring { --my-ring: 0 0 0 2px currentcolor; box-shadow: var(--my-ring); }`, + ); + render(); + + const boxShadow = screen.getByTestId(testID).props.style.boxShadow as [ + { color: string }, + ]; + expect(boxShadow[0].color).toBe("#000000"); + }); + + test("a default inset-ring paints with the seed color", () => { + colorScheme.set("light"); + registerCSS( + `.ir { --my-ring: inset 0 0 0 2px currentcolor; box-shadow: var(--my-ring); }`, + ); + render(); + + const boxShadow = screen.getByTestId(testID).props.style.boxShadow as [ + { inset: boolean; color: string }, + ]; + expect(boxShadow[0].inset).toBe(true); + expect(boxShadow[0].color).toBe("#000000"); + }); + + test("no color-scheme preference (null) falls back to the light default", () => { + // Appearance.getColorScheme() can be null; the dark media query then fails, + // so the seed resolves to its unconditioned light value rather than nothing. + colorScheme.set(null); + registerCSS(`.c { color: currentcolor; }`); + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + color: "#000000", + }); + }); +}); diff --git a/src/__tests__/native/root-color-seed-ios.test.tsx b/src/__tests__/native/root-color-seed-ios.test.tsx new file mode 100644 index 00000000..cac45c23 --- /dev/null +++ b/src/__tests__/native/root-color-seed-ios.test.tsx @@ -0,0 +1,36 @@ +import { render, screen } from "@testing-library/react-native"; +import { View } from "react-native-css/components/View"; +import { registerCSS, testID } from "react-native-css/jest"; + +// jest-expo runs with Platform.OS === "ios" by default (no mock needed), so +// native-internal/root takes the iOS branch when the runtime loads here. +describe("ios root __rn-css-color seed", () => { + test("keeps PlatformColor('label') — the first-class dynamic system color", () => { + // iOS is unchanged: PlatformColor('label') already tracks the system + // appearance, so only Android needed the concrete scheme-aware seed. + registerCSS(`.c { color: currentcolor; }`); + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + color: { semantic: ["label", "labelColor"] }, + }); + }); + + test("an ancestor-published color still overrides the PlatformColor seed", () => { + // The resolution machinery is platform-agnostic; a nearer published color + // wins on iOS too, so the seed remains only the ultimate fallback. + registerCSS(` + .parent { color: red; } + .child { color: currentcolor; } + `); + render( + + + , + ); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + color: "#f00", + }); + }); +}); diff --git a/src/__tests__/vendor/tailwind/ring-color-seed.test.tsx b/src/__tests__/vendor/tailwind/ring-color-seed.test.tsx new file mode 100644 index 00000000..4b06bdc5 --- /dev/null +++ b/src/__tests__/vendor/tailwind/ring-color-seed.test.tsx @@ -0,0 +1,42 @@ +import { colorScheme } from "react-native-css/runtime"; + +import { renderSimple } from "./_tailwind"; + +// The root `__rn-css-color` seed is a module-load side effect gated on +// Platform.OS, and jest-expo defaults to ios. Mock android so the runtime takes +// the Android branch, then drive it with *real* Tailwind output — the default +// `ring-*` color is `currentcolor`, which resolves through the root seed. +jest.mock("react-native", () => { + const ReactNative = + jest.requireActual("react-native"); + ReactNative.Platform.OS = "android"; + return ReactNative; +}); + +const ringColor = (props: { style?: unknown }): string => { + const style = props.style as { boxShadow: [{ color: string }] }; + return style.boxShadow[0].color; +}; + +describe("android default ring paints (real Tailwind → root color seed)", () => { + test("ring-2 with no explicit color resolves to the seed, not an invisible ColorStateList", async () => { + // The reported bug: Tailwind's default ring color is currentcolor, and with + // the old PlatformColor('?attr/textColorPrimary') seed the ring never + // painted on Android. It now resolves to the concrete seed. + colorScheme.set("light"); + const { props } = await renderSimple({ className: "ring-2" }); + expect(ringColor(props)).toBe("#000000"); + }); + + test("the default ring is scheme-aware (white in dark mode)", async () => { + colorScheme.set("dark"); + const { props } = await renderSimple({ className: "ring" }); + expect(ringColor(props)).toBe("#FFFFFF"); + }); + + test("an explicit ring color still wins over the currentcolor default", async () => { + colorScheme.set("light"); + const { props } = await renderSimple({ className: "ring-2 ring-red-500" }); + expect(ringColor(props)).toBe("#fb2c36"); + }); +}); diff --git a/src/native-internal/root.ts b/src/native-internal/root.ts index e45a7d11..332fb43d 100644 --- a/src/native-internal/root.ts +++ b/src/native-internal/root.ts @@ -33,12 +33,35 @@ export const rootVariables = rootVariableFamily(); export const universalVariables = rootVariableFamily(); rootVariables("__rn-css-rem").set([[14]]); -// eslint-disable-next-line @typescript-eslint/no-unsafe-argument -rootVariables("__rn-css-color").set([ - [ - Platform.OS === "ios" - ? PlatformColor("label", "labelColor") - : PlatformColor("?attr/textColorPrimary", "SystemBaseHighColor"), - ], - // eslint-disable-next-line @typescript-eslint/no-explicit-any -] as any); + +/** + * The ultimate fallback for every `currentcolor` (and `color: inherit`) + * resolution that reaches the root with no ancestor- or theme-published + * `--__rn-css-color`. + * + * iOS keeps `PlatformColor('label')` — a first-class dynamic color that already + * tracks the system appearance. + * + * Android's `PlatformColor('?attr/textColorPrimary')` resolves to a + * ColorStateList, and RN's `ColorPropConverter` returns the resource + * *reference* rather than an ARGB int, so it silently never paints — default + * `ring-*` / `inset-ring-*` / `text-current` render nothing. Seed a concrete + * color instead, made scheme-aware through this same root observable's + * `prefers-color-scheme` evaluation (no extra `Appearance` listener). A binary + * black/white default is spec-faithful: the root value is only the ultimate + * fallback, so any ancestor-published or themed `--__rn-css-color` overrides it. + */ +if (Platform.OS === "ios") { + // PlatformColor returns an OpaqueColorValue that isn't in the StyleDescriptor + // union, but the native runtime consumes it as a color. + const iosLabelColor = PlatformColor( + "label", + "labelColor", + ) as unknown as StyleDescriptor; + rootVariables("__rn-css-color").set([[iosLabelColor]]); +} else { + rootVariables("__rn-css-color").set([ + ["#FFFFFF", [["=", "prefers-color-scheme", "dark"]]], + ["#000000"], + ]); +} From 96f11f3aec5a55b2553d44007d9451c23fe16006 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 14 Aug 2026 21:24:30 +0300 Subject: [PATCH 2/3] test(native): pin that a :root color overrides the seed, and follow the platform-suffix convention The seed hardcodes black and white, and the only thing that makes that defensible is that an app which themes its own text colour wins. The suite proved that for an ancestor element and never for a stylesheet `:root` rule, which is how an app actually does it. The two new cases live in their own file deliberately: `inject` replaces a root variable outright and nothing puts it back, so a `:root { color }` in the existing file leaks into every test after it. `root-color-seed-ios.test.tsx` becomes `root-color-seed.test.ios.tsx`, matching env / styled / text-shadow. --- .../native/root-color-seed-override.test.tsx | 47 +++++++++++++++++++ ....test.tsx => root-color-seed.test.ios.tsx} | 0 2 files changed, 47 insertions(+) create mode 100644 src/__tests__/native/root-color-seed-override.test.tsx rename src/__tests__/native/{root-color-seed-ios.test.tsx => root-color-seed.test.ios.tsx} (100%) diff --git a/src/__tests__/native/root-color-seed-override.test.tsx b/src/__tests__/native/root-color-seed-override.test.tsx new file mode 100644 index 00000000..ef9205e7 --- /dev/null +++ b/src/__tests__/native/root-color-seed-override.test.tsx @@ -0,0 +1,47 @@ +import { 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"; + +// The seed is only defensible if an app can override it, so these pin that it can. +// They live in their own file because `inject` replaces a root variable outright and +// nothing puts it back — `react-native-css/jest`'s beforeEach clears +// StyleCollection.styles, not the root registry — so a `:root { color }` here would +// otherwise leak into every later test in the same file. +jest.mock("react-native", () => { + const ReactNative = + jest.requireActual("react-native"); + ReactNative.Platform.OS = "android"; + return ReactNative; +}); + +test("a stylesheet :root color replaces the seed outright", () => { + // An app that themes its text colour has to win, and a :root rule is how it does + // that — not via an ancestor element, which is all the sibling suite covers + colorScheme.set("light"); + registerCSS(` + :root { color: #123456; } + .c { color: currentcolor; } + `); + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + color: "#123456", + }); +}); + +test("a scheme-conditioned :root color overrides the seed per scheme", () => { + colorScheme.set("dark"); + registerCSS(` + :root { color: #123456; } + @media (prefers-color-scheme: dark) { + :root { color: #eeeeee; } + } + .c { color: currentcolor; } + `); + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + color: "#eee", + }); +}); diff --git a/src/__tests__/native/root-color-seed-ios.test.tsx b/src/__tests__/native/root-color-seed.test.ios.tsx similarity index 100% rename from src/__tests__/native/root-color-seed-ios.test.tsx rename to src/__tests__/native/root-color-seed.test.ios.tsx From d3047d557b4d57f9c0b57c371d0da4be42135ba5 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Thu, 20 Aug 2026 20:15:47 +0300 Subject: [PATCH 3/3] fix(native): seed a concrete colour on iOS too, not just Android MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The root `__rn-css-color` seed is the ultimate fallback behind every `currentcolor` and `color: inherit` that reaches the root with nothing published above it. It feeds `color`, `border-color`, `outline-color`, ring and shadow alike. Android already seeded a concrete scheme-aware colour here, because `PlatformColor('?attr/textColorPrimary')` resolves to a ColorStateList and `ColorPropConverter` hands back the resource reference rather than an ARGB int, so default `ring-*` / `inset-ring-*` / `text-current` painted nothing. iOS kept `PlatformColor('label')` on the reasoning that it already tracks the system appearance. It does — but only where the platform resolves it. A semantic colour is a dynamic `UIColor`, which has to be resolved against a trait collection before it can become a `CGColor`, and `RCTViewComponentView.mm` does that in exactly one place: the background. Border, outline and shadow take `RCTUIColorFromSharedColor(...).CGColor` directly. facebook/react-native#57836 tracks the consequence — a dynamic `borderColor` / `outlineColor` resolves against the system appearance and ignores `overrideUserInterfaceStyle`, painting a variant that is indistinguishable from no border when it matches what is behind it. Reported on an iPhone: `border-current` painted nothing while a literal border beside it painted normally. So both platforms had the same shape of defect — a value that reaches the prop and then fails silently somewhere the paint path does not resolve it. Neither raises an error; both fall back to transparent or to the wrong variant. A fallback is the one value that must not depend on the platform getting a dynamic colour right, because when it is wrong nothing reports it. One concrete scheme-aware colour on every platform removes that class of failure from the fallback entirely, and the `Platform.OS` branch with it. The iOS test is rewritten rather than dropped. It asserted that `{semantic: ["label","labelColor"]}` reached `props.style`, which pins arrival and not paint — the same shape the Android sibling asserted before its fix, on the build that painted no ring on a device. It now asserts the resolved colour and drives both schemes, because the iOS arm has a scheme dependence for the first time: `PlatformColor` used to absorb that and the observable answers it now. Four other suites pinned the same descriptor for `currentcolor` consumers — drop-shadow, text-shadow, caret and text colour — and now assert the resolved colour. Suite: 1063 passed, 21 skipped. The three failures are the pre-existing Windows-only relative-import cases in the babel tests, unchanged by this. --- src/__tests__/native/box-shadow.test.tsx | 7 ++- src/__tests__/native/filters.test.tsx | 6 +- .../native/root-color-seed.test.ios.tsx | 35 ++++++++--- src/__tests__/native/text-shadow.test.ios.tsx | 5 +- .../vendor/tailwind/interactivity.test.tsx | 5 +- .../vendor/tailwind/typography.test.tsx | 5 +- src/native-internal/root.ts | 58 ++++++++++--------- 7 files changed, 73 insertions(+), 48 deletions(-) diff --git a/src/__tests__/native/box-shadow.test.tsx b/src/__tests__/native/box-shadow.test.tsx index 85a809a5..ea824432 100644 --- a/src/__tests__/native/box-shadow.test.tsx +++ b/src/__tests__/native/box-shadow.test.tsx @@ -798,7 +798,7 @@ describe("@property defaults with shadow variables", () => { ]); }); - test("currentcolor resolves to platform color object", () => { + test("currentcolor resolves to the concrete root seed", () => { registerCSS(` @property --my-shadow { syntax: "*"; @@ -827,8 +827,9 @@ describe("@property defaults with shadow variables", () => { blurRadius: 0, spreadDistance: 2, }); - // currentcolor resolves to a platform color object, not a string - expect(typeof component.props.style.boxShadow[0].color).toBe("object"); + // currentcolor resolves through the root seed, which is a concrete colour on every + // platform — a value a paint path can take, rather than an object it must resolve first. + expect(component.props.style.boxShadow[0].color).toBe("#000000"); }); test("three vars with two transparent (Tailwind ring pattern)", () => { diff --git a/src/__tests__/native/filters.test.tsx b/src/__tests__/native/filters.test.tsx index 9ff55e48..59665c70 100644 --- a/src/__tests__/native/filters.test.tsx +++ b/src/__tests__/native/filters.test.tsx @@ -122,15 +122,15 @@ describe("filter: drop-shadow()", () => { render(); const component = screen.getByTestId(testID); - // currentcolor resolves to a PlatformColor object — requires - // "color" type (not "string") in the shorthand handler pattern + // currentcolor resolves through the root seed, which is a concrete + // scheme-aware colour on every platform — the light arm here. expect(component.props.style.filter).toStrictEqual([ { dropShadow: { offsetX: 0, offsetY: 4, standardDeviation: 6, - color: { semantic: ["label", "labelColor"] }, + color: "#000000", }, }, ]); diff --git a/src/__tests__/native/root-color-seed.test.ios.tsx b/src/__tests__/native/root-color-seed.test.ios.tsx index cac45c23..ce0a25dc 100644 --- a/src/__tests__/native/root-color-seed.test.ios.tsx +++ b/src/__tests__/native/root-color-seed.test.ios.tsx @@ -1,22 +1,43 @@ import { 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"; -// jest-expo runs with Platform.OS === "ios" by default (no mock needed), so -// native-internal/root takes the iOS branch when the runtime loads here. +// jest-expo runs with Platform.OS === "ios" by default (no mock needed), so this +// file is the seed as iOS actually loads it. There is no longer an iOS BRANCH to +// take — that is what it exists to prove. describe("ios root __rn-css-color seed", () => { - test("keeps PlatformColor('label') — the first-class dynamic system color", () => { - // iOS is unchanged: PlatformColor('label') already tracks the system - // appearance, so only Android needed the concrete scheme-aware seed. + test("resolves to a concrete color, the same one every other platform gets", () => { + // This file used to assert that PlatformColor's descriptor reached props.style. + // That pins ARRIVAL, not paint, and cannot tell the two apart: pre-fix, the + // Android sibling's props bag held the same shape on the build that painted no + // ring and invisible text-current on a device. A concrete color is asserted + // instead, because a value that is wrong is then visibly wrong here. + // + // The scheme is pinned because the seed is scheme-aware on iOS for the first + // time — PlatformColor used to absorb that, and now the observable answers it. + colorScheme.set("light"); registerCSS(`.c { color: currentcolor; }`); render(); expect(screen.getByTestId(testID).props.style).toStrictEqual({ - color: { semantic: ["label", "labelColor"] }, + color: "#000000", }); }); - test("an ancestor-published color still overrides the PlatformColor seed", () => { + test("follows the scheme, so neither arm is a constant", () => { + // A seed that had stopped resolving and simply held one branch would satisfy the + // test above whenever it asked for that branch. Driving both refutes it. + colorScheme.set("dark"); + registerCSS(`.c { color: currentcolor; }`); + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + color: "#FFFFFF", + }); + }); + + test("an ancestor-published color still overrides the seed", () => { // The resolution machinery is platform-agnostic; a nearer published color // wins on iOS too, so the seed remains only the ultimate fallback. registerCSS(` diff --git a/src/__tests__/native/text-shadow.test.ios.tsx b/src/__tests__/native/text-shadow.test.ios.tsx index 17623ada..411b1174 100644 --- a/src/__tests__/native/text-shadow.test.ios.tsx +++ b/src/__tests__/native/text-shadow.test.ios.tsx @@ -14,9 +14,8 @@ describe("text-shadow", () => { render(); expect(screen.getByTestId(testID).props.style).toStrictEqual({ - textShadowColor: { - semantic: ["label", "labelColor"], - }, + // The root seed, concrete on every platform — light arm. + textShadowColor: "#000000", textShadowOffset: { height: 10, width: 10, diff --git a/src/__tests__/vendor/tailwind/interactivity.test.tsx b/src/__tests__/vendor/tailwind/interactivity.test.tsx index dd2082f6..2eb13178 100644 --- a/src/__tests__/vendor/tailwind/interactivity.test.tsx +++ b/src/__tests__/vendor/tailwind/interactivity.test.tsx @@ -61,9 +61,8 @@ describe("Interactivity - Caret Color", () => { test("caret-current", async () => { expect(await renderCurrentTest()).toStrictEqual({ props: { - cursorColor: { - semantic: ["label", "labelColor"], - }, + // The root seed, concrete on every platform — light arm. + cursorColor: "#000000", style: {}, }, }); diff --git a/src/__tests__/vendor/tailwind/typography.test.tsx b/src/__tests__/vendor/tailwind/typography.test.tsx index 00d184b7..9aaf781c 100644 --- a/src/__tests__/vendor/tailwind/typography.test.tsx +++ b/src/__tests__/vendor/tailwind/typography.test.tsx @@ -314,9 +314,8 @@ describe("Typography - Text Color", () => { expect(await renderCurrentTest()).toStrictEqual({ props: { style: { - color: { - semantic: ["label", "labelColor"], - }, + // The root seed, concrete on every platform — light arm. + color: "#000000", }, }, }); diff --git a/src/native-internal/root.ts b/src/native-internal/root.ts index 332fb43d..c4803b66 100644 --- a/src/native-internal/root.ts +++ b/src/native-internal/root.ts @@ -1,5 +1,3 @@ -import { Platform, PlatformColor } from "react-native"; - import type { StyleDescriptor, VariableValue } from "react-native-css/compiler"; import { testMediaQuery } from "../native/conditions/media-query"; @@ -39,29 +37,37 @@ rootVariables("__rn-css-rem").set([[14]]); * resolution that reaches the root with no ancestor- or theme-published * `--__rn-css-color`. * - * iOS keeps `PlatformColor('label')` — a first-class dynamic color that already - * tracks the system appearance. + * A CONCRETE scheme-aware color on every platform, never `PlatformColor`. This + * value feeds every `currentcolor` consumer there is — `color`, `border-color`, + * `outline-color`, ring and shadow — and on both platforms a semantic color + * reaches at least one of those props by a path that never resolves it, failing + * silently rather than throwing: + * + * - **Android.** `PlatformColor('?attr/textColorPrimary')` resolves to a + * ColorStateList, and `ColorPropConverter` returns the resource *reference* + * rather than an ARGB int, so default `ring-*` / `inset-ring-*` / + * `text-current` render nothing. + * - **iOS.** A semantic color is a DYNAMIC `UIColor`, which must be resolved + * against a trait collection before it can become a `CGColor`. + * `RCTViewComponentView.mm` does that in exactly one place — the background — + * while border, outline and shadow take `RCTUIColorFromSharedColor(...)` + * `.CGColor` directly. facebook/react-native#57836 tracks the consequence: + * a dynamic `borderColor` / `outlineColor` resolves against the system + * appearance and ignores `overrideUserInterfaceStyle`, which paints the wrong + * variant — indistinguishable from no border when that variant happens to + * match what is behind it. + * + * A fallback is the one value that must not depend on the platform getting a + * dynamic color right, because when it is wrong nothing reports it: both + * platforms fail to a transparent or same-as-background paint, never an error. + * A concrete color removes that class of failure from the fallback entirely. * - * Android's `PlatformColor('?attr/textColorPrimary')` resolves to a - * ColorStateList, and RN's `ColorPropConverter` returns the resource - * *reference* rather than an ARGB int, so it silently never paints — default - * `ring-*` / `inset-ring-*` / `text-current` render nothing. Seed a concrete - * color instead, made scheme-aware through this same root observable's - * `prefers-color-scheme` evaluation (no extra `Appearance` listener). A binary - * black/white default is spec-faithful: the root value is only the ultimate - * fallback, so any ancestor-published or themed `--__rn-css-color` overrides it. + * Scheme awareness comes from this same root observable's `prefers-color-scheme` + * evaluation rather than a second `Appearance` listener. A binary black/white + * default is spec-faithful: the root value is only the ultimate fallback, so any + * ancestor-published or themed `--__rn-css-color` overrides it. */ -if (Platform.OS === "ios") { - // PlatformColor returns an OpaqueColorValue that isn't in the StyleDescriptor - // union, but the native runtime consumes it as a color. - const iosLabelColor = PlatformColor( - "label", - "labelColor", - ) as unknown as StyleDescriptor; - rootVariables("__rn-css-color").set([[iosLabelColor]]); -} else { - rootVariables("__rn-css-color").set([ - ["#FFFFFF", [["=", "prefers-color-scheme", "dark"]]], - ["#000000"], - ]); -} +rootVariables("__rn-css-color").set([ + ["#FFFFFF", [["=", "prefers-color-scheme", "dark"]]], + ["#000000"], +]);