import { useEffect, useState } from 'react'; import { Platform, useColorScheme } from 'react-native'; import { cssColorToSrgb, oklchToSrgb } from '../lib/color'; /** * The semantic color tokens exported by rn-motion-ui/tokens.css. * * Each key corresponds to a `--color-` CSS custom property. * * Surfaces follow an elevation ladder: `surface-1` (the page) * through `surface-3`, plus the `surface-hover` / `surface-selected` * translucent state overlays. `surface-3` is the resting level for contained * content (cards, inputs) and the float level for modals and elevated menus. * * Status tokens come in pairs: `danger`, `success`, `warning` and `info` are * vivid filled backgrounds; `*-foreground` is white for legible text/icons on * that fill. * * `white` and `black` are the two absolute colors: identical in both schemes, so * they do NOT flip with the theme. They exist for the places that legitimately * need a fixed color — a glyph on a vivid status fill, a gloss highlight, a * scrim — which would otherwise be a hardcoded `#fff` / `#000` literal. */ type ThemeToken = | 'surface-1' | 'surface-2' | 'surface-3' | 'surface-hover' | 'surface-selected' | 'foreground' | 'muted' | 'muted-foreground' | 'border' | 'input' | 'primary' | 'primary-foreground' | 'secondary' | 'secondary-foreground' | 'accent' | 'accent-foreground' | 'success' | 'success-foreground' | 'warning' | 'warning-foreground' | 'info' | 'info-foreground' | 'danger' | 'danger-foreground' | 'white' | 'black' | 'background' | 'glass'; /** * OKLCH definitions mirroring the tokens.css @theme block — [L, C, H, alpha?]. * * These are the single native source of truth: the sRGB maps used on native * (and as the web SSR fallback) are derived from them at module load via * lib/color.ts, so there are no hand-converted hex values to drift out of * sync. Keep these tuples identical to the values in tokens.css whenever the * @theme block changes. */ type Oklch = readonly [number, number, number, number?]; const NEUTRAL_HUE = 270; // the hue of the neutral axis, in degrees const LIGHT_OKLCH: Record = { 'surface-1': [0.97, 0, 0], 'surface-2': [0.985, 0, 0], 'surface-3': [1, 0, 0], 'surface-hover': [0, 0, 0, 0.04], 'surface-selected': [0, 0, 0, 0.08], foreground: [0.18, 0.004, NEUTRAL_HUE], muted: [0.9, 0, 0], 'muted-foreground': [0.5, 0.004, NEUTRAL_HUE], border: [0, 0, 0, 0.1], input: [1, 0, 0], primary: [0.22, 0, 0], 'primary-foreground': [0.98, 0.002, NEUTRAL_HUE], secondary: [0.92, 0, 0], 'secondary-foreground': [0.32, 0.004, NEUTRAL_HUE], accent: [0.91, 0, 0], 'accent-foreground': [0.22, 0.004, NEUTRAL_HUE], success: [0.7, 0.18, 155], 'success-foreground': [1, 0, 0], warning: [0.58, 0.18, 40], 'warning-foreground': [1, 0, 0], info: [0.58, 0.18, 255], 'info-foreground': [1, 0, 0], danger: [0.62, 0.22, 25], 'danger-foreground': [1, 0, 0], white: [1, 0, 0], black: [0, 0, 0], background: [0.95, 0.004, NEUTRAL_HUE], glass: [0.99, 0, 0, 0.6], }; const DARK_OKLCH: Record = { 'surface-1': [0.205, 0.004, NEUTRAL_HUE], 'surface-2': [0.235, 0.004, NEUTRAL_HUE], 'surface-3': [0.264, 0.004, NEUTRAL_HUE], 'surface-hover': [1, 0, 0, 0.04], 'surface-selected': [1, 0, 0, 0.08], foreground: [0.94, 0.004, NEUTRAL_HUE], muted: [0.28, 0.004, NEUTRAL_HUE], 'muted-foreground': [0.73, 0.004, NEUTRAL_HUE], border: [1, 0, 0, 0.1], input: [0.22, 0, 0], primary: [0.95, 0.004, NEUTRAL_HUE], 'primary-foreground': [0.13, 0.004, NEUTRAL_HUE], secondary: [0.35, 0.004, NEUTRAL_HUE], 'secondary-foreground': [0.94, 0.004, NEUTRAL_HUE], accent: [0.31, 0.004, NEUTRAL_HUE], 'accent-foreground': [0.95, 0.004, NEUTRAL_HUE], success: [0.72, 0.18, 155], 'success-foreground': [1, 0, 0], warning: [0.58, 0.18, 40], 'warning-foreground': [1, 0, 0], info: [0.58, 0.18, 255], 'info-foreground': [1, 0, 0], danger: [0.66, 0.22, 25], 'danger-foreground': [1, 0, 0], // Absolute colors — same in both schemes, on purpose. white: [1, 0, 0], black: [0, 0, 0], background: [0, 0, 0], glass: [0.2, 0.004, NEUTRAL_HUE, 0.5], }; /** Resolve an OKLCH definition table to concrete sRGB strings. */ function buildSrgbMap(source: Record): Record { // biome-ignore lint/plugin: {} as Record is safe — the loop below populates every key of the fully-typed source table const map = {} as Record; // biome-ignore lint/plugin: Object.entries over a Record yields exactly the ThemeToken keys for (const [token, [lightness, chroma, hue, alpha]] of Object.entries(source) as [ThemeToken, Oklch][]) { map[token] = oklchToSrgb(lightness, chroma, hue, alpha); } return map; } // Static maps — used on native (where CSS vars are unavailable) and as the web // SSR fallback. React Native only accepts sRGB color formats, so the oklch // definitions are converted once at module load. const LIGHT: Record = buildSrgbMap(LIGHT_OKLCH); const DARK: Record = buildSrgbMap(DARK_OKLCH); /** * Force a re-render whenever the active theme changes on web. * * `getComputedStyle` reads the CSS var at the moment of the call, so without a * subscription the resolved color is frozen at whatever the theme was at the * last commit. `useColorScheme()` only tracks the OS `prefers-color-scheme` * media query — it does NOT notice a manual `.dark`/`.light` class toggle on * `` (which is how the Storybook toolbar, and most app theme toggles, * switch themes). The result: components flipped black → white (or vice versa) * a frame late, or never, because no re-render was scheduled after the swap. * * This subscribes to both signals — the media query AND class-attribute * mutations on `` — and bumps a tick to re-render so `getComputedStyle` * re-reads the now-current value. Native and SSR are no-ops (the guard returns * before touching `window`/`document`/`MutationObserver`, which don't exist * there); on native the `useColorScheme()` call in each hook already drives * re-renders. */ function useWebThemeChange(): void { const [, bump] = useState(0); // biome-ignore lint/plugin: subscribing to the OS media query + class mutations is an external side effect — no derived state or RN event handler can replace a matchMedia/MutationObserver subscription useEffect(() => { if (Platform.OS !== 'web' || typeof document === 'undefined') return; const mq = globalThis.window.matchMedia('(prefers-color-scheme: dark)'); const onChange = () => bump((t) => t + 1); mq.addEventListener('change', onChange); const observer = new MutationObserver(onChange); // Only `class` flips theme tokens — observing every attribute would also // fire for unrelated attribute writes (data-*, aria-*, dir, …). observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }); return () => { mq.removeEventListener('change', onChange); observer.disconnect(); }; }, []); } /** * Resolve a semantic token to a concrete color string at runtime. * * - **Web**: reads the CSS custom property (`--color-`) from the document * root via `getComputedStyle`, so it always reflects the active Tailwind theme * (including any consumer `@theme` overrides). oklch values are resolved to * sRGB via the pure formula in lib/color.ts so they're usable by Reanimated * colour animations, which can't parse oklch. Re-renders when the theme * changes — via the OS `prefers-color-scheme` media query or a manual * `.dark`/`.light` class on `` — so the value never goes stale on a * theme toggle. Falls back to the static map during SSR when the DOM is * unavailable. * - **Native**: uses `useColorScheme()` to pick between the light/dark static * maps derived from the same oklch definitions. No provider or setup required. * * @example * // Pass resolved color to a Reanimated worklet: * const spinnerColor = useThemeColor('primary'); * const style = useAnimatedStyle(() => ({ borderColor: spinnerColor })); * * @example * // Pass resolved color to a react-native-svg prop: * const fill = useThemeColor('success-foreground'); * return ; */ export function useThemeColor(token: ThemeToken): string { // useColorScheme is called unconditionally to satisfy the rules-of-hooks. // Platform.OS is a build-time constant so the branch below never flips at // runtime, but the hook must still be called on every render path. const scheme = useColorScheme(); useWebThemeChange(); if (Platform.OS !== 'web') return (scheme === 'dark' ? DARK : LIGHT)[token]; // Web: read the live CSS custom property so consumer @theme overrides are // respected. Falls back to the static light-mode value during SSR. if (typeof document === 'undefined') return LIGHT[token]; const raw = getComputedStyle(document.documentElement).getPropertyValue(`--color-${token}`).trim(); return raw ? cssColorToSrgb(raw) : LIGHT[token]; } /** * Snapshot of all token values for the current color scheme. * * Useful when multiple tokens are needed at once (avoids N separate * `getComputedStyle` calls on web). */ export function useThemeColors(): Record { const scheme = useColorScheme(); useWebThemeChange(); if (Platform.OS !== 'web') return scheme === 'dark' ? DARK : LIGHT; if (typeof document === 'undefined') return LIGHT; const style = getComputedStyle(document.documentElement); // biome-ignore lint/plugin: {} as Record is safe — we populate every key in the loop below const result = {} as Record; // biome-ignore lint/plugin: Object.keys(LIGHT) is exactly ThemeToken[] — LIGHT is typed Record for (const token of Object.keys(LIGHT) as ThemeToken[]) { const raw = style.getPropertyValue(`--color-${token}`).trim(); result[token] = raw ? cssColorToSrgb(raw) : LIGHT[token]; } return result; } export type { ThemeToken };