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-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-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.test.ios.tsx b/src/__tests__/native/root-color-seed.test.ios.tsx
new file mode 100644
index 00000000..ce0a25dc
--- /dev/null
+++ b/src/__tests__/native/root-color-seed.test.ios.tsx
@@ -0,0 +1,57 @@
+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 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("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: "#000000",
+ });
+ });
+
+ 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(`
+ .parent { color: red; }
+ .child { color: currentcolor; }
+ `);
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId(testID).props.style).toStrictEqual({
+ color: "#f00",
+ });
+ });
+});
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/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/__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 e45a7d11..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";
@@ -33,12 +31,43 @@ export const rootVariables = rootVariableFamily();
export const universalVariables = rootVariableFamily();
rootVariables("__rn-css-rem").set([[14]]);
-// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
+
+/**
+ * The ultimate fallback for every `currentcolor` (and `color: inherit`)
+ * resolution that reaches the root with no ancestor- or theme-published
+ * `--__rn-css-color`.
+ *
+ * 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.
+ *
+ * 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.
+ */
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);
+ ["#FFFFFF", [["=", "prefers-color-scheme", "dark"]]],
+ ["#000000"],
+]);