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"], +]);