@oxyhq/bloom 0.6.13 → 0.6.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/lib/commonjs/hooks/useControllableState.js +35 -0
  2. package/lib/commonjs/hooks/useControllableState.js.map +1 -0
  3. package/lib/commonjs/theme/BloomThemeProvider.js +148 -112
  4. package/lib/commonjs/theme/BloomThemeProvider.js.map +1 -1
  5. package/lib/commonjs/theme/build-theme.js +110 -0
  6. package/lib/commonjs/theme/build-theme.js.map +1 -0
  7. package/lib/commonjs/theme/color-presets.js +15 -0
  8. package/lib/commonjs/theme/color-presets.js.map +1 -1
  9. package/lib/commonjs/theme/index.js +15 -1
  10. package/lib/commonjs/theme/index.js.map +1 -1
  11. package/lib/commonjs/theme/persistence.js +147 -0
  12. package/lib/commonjs/theme/persistence.js.map +1 -0
  13. package/lib/commonjs/theme/use-isomorphic-layout-effect.js +15 -0
  14. package/lib/commonjs/theme/use-isomorphic-layout-effect.js.map +1 -0
  15. package/lib/module/hooks/useControllableState.js +31 -0
  16. package/lib/module/hooks/useControllableState.js.map +1 -0
  17. package/lib/module/theme/BloomThemeProvider.js +149 -112
  18. package/lib/module/theme/BloomThemeProvider.js.map +1 -1
  19. package/lib/module/theme/build-theme.js +105 -0
  20. package/lib/module/theme/build-theme.js.map +1 -0
  21. package/lib/module/theme/color-presets.js +15 -0
  22. package/lib/module/theme/color-presets.js.map +1 -1
  23. package/lib/module/theme/index.js +3 -1
  24. package/lib/module/theme/index.js.map +1 -1
  25. package/lib/module/theme/persistence.js +139 -0
  26. package/lib/module/theme/persistence.js.map +1 -0
  27. package/lib/module/theme/use-isomorphic-layout-effect.js +12 -0
  28. package/lib/module/theme/use-isomorphic-layout-effect.js.map +1 -0
  29. package/lib/typescript/commonjs/hooks/useControllableState.d.ts +19 -0
  30. package/lib/typescript/commonjs/hooks/useControllableState.d.ts.map +1 -0
  31. package/lib/typescript/commonjs/theme/BloomThemeProvider.d.ts +35 -12
  32. package/lib/typescript/commonjs/theme/BloomThemeProvider.d.ts.map +1 -1
  33. package/lib/typescript/commonjs/theme/build-theme.d.ts +20 -0
  34. package/lib/typescript/commonjs/theme/build-theme.d.ts.map +1 -0
  35. package/lib/typescript/commonjs/theme/color-presets.d.ts +18 -3
  36. package/lib/typescript/commonjs/theme/color-presets.d.ts.map +1 -1
  37. package/lib/typescript/commonjs/theme/index.d.ts +7 -4
  38. package/lib/typescript/commonjs/theme/index.d.ts.map +1 -1
  39. package/lib/typescript/commonjs/theme/persistence.d.ts +50 -0
  40. package/lib/typescript/commonjs/theme/persistence.d.ts.map +1 -0
  41. package/lib/typescript/commonjs/theme/use-isomorphic-layout-effect.d.ts +8 -0
  42. package/lib/typescript/commonjs/theme/use-isomorphic-layout-effect.d.ts.map +1 -0
  43. package/lib/typescript/module/hooks/useControllableState.d.ts +19 -0
  44. package/lib/typescript/module/hooks/useControllableState.d.ts.map +1 -0
  45. package/lib/typescript/module/theme/BloomThemeProvider.d.ts +35 -12
  46. package/lib/typescript/module/theme/BloomThemeProvider.d.ts.map +1 -1
  47. package/lib/typescript/module/theme/build-theme.d.ts +20 -0
  48. package/lib/typescript/module/theme/build-theme.d.ts.map +1 -0
  49. package/lib/typescript/module/theme/color-presets.d.ts +18 -3
  50. package/lib/typescript/module/theme/color-presets.d.ts.map +1 -1
  51. package/lib/typescript/module/theme/index.d.ts +7 -4
  52. package/lib/typescript/module/theme/index.d.ts.map +1 -1
  53. package/lib/typescript/module/theme/persistence.d.ts +50 -0
  54. package/lib/typescript/module/theme/persistence.d.ts.map +1 -0
  55. package/lib/typescript/module/theme/use-isomorphic-layout-effect.d.ts +8 -0
  56. package/lib/typescript/module/theme/use-isomorphic-layout-effect.d.ts.map +1 -0
  57. package/package.json +1 -1
  58. package/src/__tests__/BloomThemeProvider.modes.test.tsx +159 -0
  59. package/src/__tests__/BloomThemeProvider.persistence.test.tsx +321 -0
  60. package/src/__tests__/persistence.test.ts +155 -0
  61. package/src/__tests__/theme.test.ts +1 -1
  62. package/src/hooks/useControllableState.ts +44 -0
  63. package/src/theme/BloomThemeProvider.tsx +251 -157
  64. package/src/theme/build-theme.ts +128 -0
  65. package/src/theme/color-presets.ts +19 -3
  66. package/src/theme/index.ts +16 -4
  67. package/src/theme/persistence.ts +174 -0
  68. package/src/theme/use-isomorphic-layout-effect.ts +10 -0
@@ -3,107 +3,38 @@
3
3
  // throw "Cannot manually set color scheme, as dark mode is type 'media'" the
4
4
  // first time Bloom toggles the dark class on <html>. See ./init-css-interop.
5
5
  import './init-css-interop';
6
- import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react';
7
- import { useColorScheme as useRNColorScheme, Platform } from 'react-native';
8
- import { APP_COLOR_PRESETS, type AppColorName } from './color-presets';
9
- import { getAdaptiveColors } from './adaptive-colors';
10
- import { applyDarkClass, applyColorPresetVars } from './apply-dark-class';
11
- import { setColorSchemeSafe } from './set-color-scheme-safe';
12
- import { FontLoader } from '../fonts/FontLoader';
13
- import type { Theme, ThemeColors, ThemeMode } from './types';
14
-
15
- function hslVarToCSS(value: string): string {
16
- const parts = value.split('/').map((s) => s.trim());
17
- if (parts.length === 2) {
18
- const alpha = parseFloat(parts[1] ?? '100') / 100;
19
- return `hsla(${(parts[0] ?? '').replace(/ /g, ', ')}, ${alpha})`;
20
- }
21
- return `hsl(${value.replace(/ /g, ', ')})`;
22
- }
23
-
24
- function extractHue(hslVar: string): number {
25
- return parseInt(hslVar.split(' ')[0] ?? '0', 10);
26
- }
27
-
28
- function hsl(h: number, s: number, l: number): string {
29
- return `hsl(${h}, ${s}%, ${l}%)`;
30
- }
31
6
 
32
- /** Build a Theme object from a color preset name and resolved light/dark mode. */
33
- export function buildTheme(appColor: AppColorName, resolved: 'light' | 'dark', isAdaptive: boolean = false): Theme {
34
- const isDark = resolved === 'dark';
35
-
36
- let themeColors: ThemeColors | undefined;
37
-
38
- if (isAdaptive && Platform.OS !== 'web') {
39
- const adaptive = getAdaptiveColors();
40
- if (adaptive) {
41
- themeColors = adaptive;
42
- }
43
- }
7
+ import React, {
8
+ createContext,
9
+ useCallback,
10
+ useContext,
11
+ useEffect,
12
+ useMemo,
13
+ useRef,
14
+ useState,
15
+ } from 'react';
16
+ import { useColorScheme as useRNColorScheme } from 'react-native';
17
+
18
+ import { useControllableState } from '../hooks/useControllableState';
19
+ import { FontLoader } from '../fonts/FontLoader';
44
20
 
45
- if (!themeColors) {
46
- const preset = APP_COLOR_PRESETS[appColor];
47
- const vars = resolved === 'light' ? preset.light : preset.dark;
48
- const primaryHue = extractHue(vars['--primary'] ?? '0 0% 0%');
49
- const destructiveHue = extractHue(vars['--destructive'] ?? '0 0% 0%');
50
-
51
- const surface = hslVarToCSS(vars['--surface'] ?? '0 0% 0%');
52
- const background = hslVarToCSS(vars['--background'] ?? '0 0% 0%');
53
- const mutedForeground = hslVarToCSS(vars['--muted-foreground'] ?? '0 0% 50%');
54
-
55
- const primaryColor = hslVarToCSS(vars['--primary'] ?? '0 0% 50%');
56
- const primaryForeground = hslVarToCSS(vars['--primary-foreground'] ?? '0 0% 100%');
57
-
58
- themeColors = {
59
- background,
60
- backgroundSecondary: surface,
61
- backgroundTertiary: hslVarToCSS(vars['--muted'] ?? '0 0% 0%'),
62
-
63
- text: hslVarToCSS(vars['--foreground'] ?? '0 0% 100%'),
64
- textSecondary: mutedForeground,
65
- textTertiary: mutedForeground,
66
-
67
- border: hslVarToCSS(vars['--border'] ?? '0 0% 20%'),
68
- borderLight: hslVarToCSS(vars['--input'] ?? '0 0% 20%'),
69
-
70
- primary: primaryColor,
71
- primaryForeground,
72
- primaryLight: surface,
73
- primaryDark: background,
74
-
75
- secondary: primaryColor,
76
-
77
- tint: primaryColor,
78
- icon: mutedForeground,
79
- iconActive: primaryColor,
80
-
81
- success: '#10B981',
82
- error: '#EF4444',
83
- warning: '#F59E0B',
84
- info: '#3B82F6',
85
-
86
- primarySubtle: isDark ? hsl(primaryHue, 50, 10) : hsl(primaryHue, 70, 93),
87
- primarySubtleForeground: isDark ? hsl(primaryHue, 70, 65) : hsl(primaryHue, 90, 25),
88
- negative: hsl(destructiveHue, 84, 45),
89
- negativeForeground: '#FFFFFF',
90
- negativeSubtle: isDark ? hsl(destructiveHue, 50, 10) : hsl(destructiveHue, 90, 95),
91
- negativeSubtleForeground: isDark ? hsl(destructiveHue, 70, 65) : hsl(destructiveHue, 80, 40),
92
- contrast50: isDark ? hsl(primaryHue, 15, 12) : hsl(primaryHue, 10, 93),
93
-
94
- card: surface,
95
- shadow: isDark ? 'rgba(0, 0, 0, 0.3)' : 'rgba(0, 0, 0, 0.1)',
96
- overlay: 'rgba(0, 0, 0, 0.5)',
97
- };
98
- }
21
+ import { applyDarkClass, applyColorPresetVars } from './apply-dark-class';
22
+ import { buildTheme } from './build-theme';
23
+ import { type AppColorName } from './color-presets';
24
+ import {
25
+ readPersistedTheme,
26
+ readPersistedThemeSync,
27
+ removePersistedTheme,
28
+ writePersistedTheme,
29
+ type BloomThemeStorage,
30
+ type SyncReadResult,
31
+ } from './persistence';
32
+ import { setColorSchemeSafe } from './set-color-scheme-safe';
33
+ import { useIsomorphicLayoutEffect } from './use-isomorphic-layout-effect';
34
+ import type { Theme, ThemeMode } from './types';
99
35
 
100
- return {
101
- mode: resolved,
102
- colors: themeColors,
103
- isDark,
104
- isLight: !isDark,
105
- };
106
- }
36
+ const DEFAULT_PRESET: AppColorName = 'oxy';
37
+ const DEFAULT_MODE: ThemeMode = 'system';
107
38
 
108
39
  export interface BloomThemeContextValue {
109
40
  theme: Theme;
@@ -111,102 +42,271 @@ export interface BloomThemeContextValue {
111
42
  colorPreset: AppColorName;
112
43
  setMode: (mode: ThemeMode) => void;
113
44
  setColorPreset: (preset: AppColorName) => void;
45
+ /**
46
+ * Restore mode and color preset to the provider defaults
47
+ * (`defaultMode` / `defaultColorPreset`) and clear the persisted entry.
48
+ * Use this when signing out or otherwise resetting per-user state.
49
+ */
50
+ resetTheme: () => void;
114
51
  }
115
52
 
116
53
  export const BloomThemeContext = createContext<BloomThemeContextValue | null>(null);
117
54
 
118
55
  export interface BloomThemeProviderProps {
56
+ /** Controlled mode. Omit to use Bloom's internal state (with optional persistence). */
119
57
  mode?: ThemeMode;
58
+ /** Controlled color preset. Omit to use Bloom's internal state. */
120
59
  colorPreset?: AppColorName;
60
+ /** Initial mode when uncontrolled and nothing is persisted yet. */
61
+ defaultMode?: ThemeMode;
62
+ /** Initial color preset when uncontrolled and nothing is persisted yet. */
63
+ defaultColorPreset?: AppColorName;
64
+
121
65
  onModeChange?: (mode: ThemeMode) => void;
122
66
  onColorPresetChange?: (preset: AppColorName) => void;
67
+
123
68
  /**
124
- * Load and inject the Bloom font system (BlomusModernus + Inter Variable
125
- * + Geist Mono Variable). Default true. Set to false to opt out e.g.
126
- * apps that already ship their own font loader.
69
+ * Persist mode + preset under this storage key. Bloom becomes the single
70
+ * source of truth apps don't need their own theme store. Has no effect
71
+ * without `storage`.
127
72
  */
128
- fonts?: boolean;
73
+ persistKey?: string;
74
+ /**
75
+ * Storage adapter paired with `persistKey`. Use `webLocalStorage` on web,
76
+ * or pass an `AsyncStorage`-compatible adapter on native.
77
+ */
78
+ storage?: BloomThemeStorage;
129
79
  /**
130
- * Rendered while native fonts load. Ignored on web. Useful for matching
131
- * an app-level splash screen so consumers don't see a system-font flash
132
- * before the bundled fonts resolve.
80
+ * Block rendering until persisted state has been read. Default `true` when
81
+ * both `persistKey` and `storage` are provided, ensuring native apps don't
82
+ * flash the default palette. Web hydrates synchronously so this is a no-op
83
+ * there.
133
84
  */
85
+ awaitHydration?: boolean;
86
+ /** Rendered while async hydration is pending. */
87
+ onHydrating?: React.ReactNode;
88
+
89
+ /** Load and inject Bloom's font system. Default `true`. */
90
+ fonts?: boolean;
91
+ /** Rendered while native fonts load. Ignored on web. */
134
92
  onFontsLoading?: React.ReactNode;
93
+
135
94
  children: React.ReactNode;
136
95
  }
137
96
 
97
+ interface ThemeStateOptions {
98
+ controlledMode?: ThemeMode;
99
+ controlledPreset?: AppColorName;
100
+ defaultMode: ThemeMode;
101
+ defaultPreset: AppColorName;
102
+ persistKey?: string;
103
+ storage?: BloomThemeStorage;
104
+ onModeChange?: (mode: ThemeMode) => void;
105
+ onColorPresetChange?: (preset: AppColorName) => void;
106
+ }
107
+
108
+ interface ThemeStateResult {
109
+ mode: ThemeMode;
110
+ colorPreset: AppColorName;
111
+ setMode: (mode: ThemeMode) => void;
112
+ setColorPreset: (preset: AppColorName) => void;
113
+ resetTheme: () => void;
114
+ hydrated: boolean;
115
+ }
116
+
117
+ function useThemeState({
118
+ controlledMode,
119
+ controlledPreset,
120
+ defaultMode,
121
+ defaultPreset,
122
+ persistKey,
123
+ storage,
124
+ onModeChange,
125
+ onColorPresetChange,
126
+ }: ThemeStateOptions): ThemeStateResult {
127
+ // Synchronous read happens once on first render. Succeeds on web with
128
+ // localStorage-backed adapters; async adapters rehydrate via the effect
129
+ // below. Lazy-initialized via `useState` so it runs exactly once per mount.
130
+ const [syncResult] = useState<SyncReadResult>(() =>
131
+ readPersistedThemeSync(persistKey, storage),
132
+ );
133
+ const syncState = syncResult.kind === 'sync' ? syncResult.state : null;
134
+
135
+ const initialMode = syncState?.mode ?? defaultMode;
136
+ const initialPreset = syncState?.colorPreset ?? defaultPreset;
137
+
138
+ // Hydrated immediately when persistence is off or the adapter resolved
139
+ // synchronously (including a null hit — that's a valid "no value" answer).
140
+ const [hydrated, setHydrated] = useState<boolean>(syncResult.kind !== 'async');
141
+
142
+ const [mode, setModeInternal] = useControllableState<ThemeMode>({
143
+ value: controlledMode,
144
+ defaultValue: initialMode,
145
+ });
146
+ const [colorPreset, setPresetInternal] = useControllableState<AppColorName>({
147
+ value: controlledPreset,
148
+ defaultValue: initialPreset,
149
+ });
150
+
151
+ // Refs let setMode/setColorPreset stay referentially stable. Callbacks
152
+ // memoized only by setters and storage identity won't churn the context
153
+ // value on every theme change.
154
+ const modeRef = useRef(mode);
155
+ modeRef.current = mode;
156
+ const presetRef = useRef(colorPreset);
157
+ presetRef.current = colorPreset;
158
+
159
+ // Async hydration for adapters that can't be read synchronously
160
+ // (AsyncStorage, MMKV via JSI fallback, etc).
161
+ useEffect(() => {
162
+ if (hydrated) return;
163
+ if (!persistKey || !storage) {
164
+ setHydrated(true);
165
+ return;
166
+ }
167
+
168
+ let cancelled = false;
169
+ readPersistedTheme(persistKey, storage).then((state) => {
170
+ if (cancelled) return;
171
+ if (state?.mode && controlledMode === undefined) {
172
+ setModeInternal(state.mode);
173
+ onModeChange?.(state.mode);
174
+ }
175
+ if (state?.colorPreset && controlledPreset === undefined) {
176
+ setPresetInternal(state.colorPreset);
177
+ onColorPresetChange?.(state.colorPreset);
178
+ }
179
+ setHydrated(true);
180
+ });
181
+
182
+ return () => {
183
+ cancelled = true;
184
+ };
185
+ // Hydration runs once per storage instance. controlledMode/Preset are
186
+ // captured by closure intentionally — switching controlled-ness mid-flight
187
+ // is unsupported and would invalidate the in-flight hydration anyway.
188
+ // eslint-disable-next-line react-hooks/exhaustive-deps
189
+ }, [persistKey, storage]);
190
+
191
+ const setMode = useCallback(
192
+ (next: ThemeMode) => {
193
+ setModeInternal(next);
194
+ onModeChange?.(next);
195
+ void writePersistedTheme(persistKey, storage, {
196
+ mode: next,
197
+ colorPreset: presetRef.current,
198
+ });
199
+ },
200
+ [setModeInternal, onModeChange, persistKey, storage],
201
+ );
202
+
203
+ const setColorPreset = useCallback(
204
+ (next: AppColorName) => {
205
+ setPresetInternal(next);
206
+ onColorPresetChange?.(next);
207
+ void writePersistedTheme(persistKey, storage, {
208
+ mode: modeRef.current,
209
+ colorPreset: next,
210
+ });
211
+ },
212
+ [setPresetInternal, onColorPresetChange, persistKey, storage],
213
+ );
214
+
215
+ const resetTheme = useCallback(() => {
216
+ if (controlledMode === undefined) {
217
+ setModeInternal(defaultMode);
218
+ onModeChange?.(defaultMode);
219
+ }
220
+ if (controlledPreset === undefined) {
221
+ setPresetInternal(defaultPreset);
222
+ onColorPresetChange?.(defaultPreset);
223
+ }
224
+ void removePersistedTheme(persistKey, storage);
225
+ }, [
226
+ controlledMode,
227
+ controlledPreset,
228
+ defaultMode,
229
+ defaultPreset,
230
+ setModeInternal,
231
+ setPresetInternal,
232
+ onModeChange,
233
+ onColorPresetChange,
234
+ persistKey,
235
+ storage,
236
+ ]);
237
+
238
+ return { mode, colorPreset, setMode, setColorPreset, resetTheme, hydrated };
239
+ }
240
+
138
241
  export function BloomThemeProvider({
139
242
  mode: controlledMode,
140
243
  colorPreset: controlledPreset,
244
+ defaultMode = DEFAULT_MODE,
245
+ defaultColorPreset = DEFAULT_PRESET,
141
246
  onModeChange,
142
247
  onColorPresetChange,
248
+ persistKey,
249
+ storage,
250
+ awaitHydration,
251
+ onHydrating,
143
252
  fonts = true,
144
253
  onFontsLoading,
145
254
  children,
146
255
  }: BloomThemeProviderProps) {
147
256
  const rnScheme = useRNColorScheme();
148
- const [internalPreset, setInternalPreset] = useState<AppColorName>(controlledPreset ?? 'oxy');
149
257
 
150
- if (controlledPreset !== undefined && controlledPreset !== internalPreset) {
151
- setInternalPreset(controlledPreset);
152
- }
153
-
154
- const appColor = internalPreset;
155
- const mode = controlledMode ?? 'system';
258
+ const { mode, colorPreset, setMode, setColorPreset, resetTheme, hydrated } = useThemeState({
259
+ controlledMode,
260
+ controlledPreset,
261
+ defaultMode,
262
+ defaultPreset: defaultColorPreset,
263
+ persistKey,
264
+ storage,
265
+ onModeChange,
266
+ onColorPresetChange,
267
+ });
156
268
 
157
269
  const isAdaptive = mode === 'adaptive';
158
- const effectiveMode = isAdaptive ? 'system' : mode;
270
+ const effectiveMode: Exclude<ThemeMode, 'adaptive'> = isAdaptive ? 'system' : mode;
159
271
  const resolved: 'light' | 'dark' =
160
- effectiveMode === 'system'
161
- ? (rnScheme === 'dark' ? 'dark' : 'light')
162
- : effectiveMode;
163
-
164
- // Apply native color scheme and CSS vars synchronously on first render
165
- // and whenever the resolved mode or preset changes. Using a ref to track
166
- // the last-applied values avoids calling side effects on every render
167
- // while still applying them before the first paint.
168
- const lastApplied = useRef<string>('');
169
- const applyKey = `${resolved}:${appColor}`;
170
- if (lastApplied.current !== applyKey) {
171
- lastApplied.current = applyKey;
272
+ effectiveMode === 'system' ? (rnScheme === 'dark' ? 'dark' : 'light') : effectiveMode;
273
+
274
+ // Apply native color scheme, dark class, and CSS vars whenever the resolved
275
+ // mode or preset changes. `useIsomorphicLayoutEffect` runs before paint on
276
+ // both native and web, eliminating the previous render-time side effect.
277
+ useIsomorphicLayoutEffect(() => {
172
278
  setColorSchemeSafe(effectiveMode);
173
279
  applyDarkClass(resolved);
174
- applyColorPresetVars(appColor, resolved);
175
- }
176
-
177
- const setMode = useCallback(
178
- (newMode: ThemeMode) => {
179
- setColorSchemeSafe(newMode);
180
- onModeChange?.(newMode);
181
- },
182
- [onModeChange],
183
- );
280
+ applyColorPresetVars(colorPreset, resolved);
281
+ }, [effectiveMode, resolved, colorPreset]);
184
282
 
185
- const setColorPreset = useCallback(
186
- (preset: AppColorName) => {
187
- setInternalPreset(preset);
188
- onColorPresetChange?.(preset);
189
- },
190
- [onColorPresetChange],
283
+ const contextValue = useMemo<BloomThemeContextValue>(
284
+ () => ({
285
+ theme: buildTheme(colorPreset, resolved, isAdaptive),
286
+ mode,
287
+ colorPreset,
288
+ setMode,
289
+ setColorPreset,
290
+ resetTheme,
291
+ }),
292
+ [colorPreset, resolved, isAdaptive, mode, setMode, setColorPreset, resetTheme],
191
293
  );
192
294
 
193
- const contextValue = useMemo<BloomThemeContextValue>(() => {
194
- const theme = buildTheme(appColor, resolved, isAdaptive);
195
- return { theme, mode, colorPreset: appColor, setMode, setColorPreset };
196
- }, [resolved, appColor, isAdaptive, mode, setMode, setColorPreset]);
295
+ const shouldAwait = awaitHydration ?? Boolean(persistKey && storage);
296
+ const isGated = shouldAwait && !hydrated;
197
297
 
198
298
  return (
199
299
  <BloomThemeContext.Provider value={contextValue}>
200
300
  <FontLoader enabled={fonts} fallback={onFontsLoading}>
201
- {children}
301
+ {isGated ? onHydrating ?? null : children}
202
302
  </FontLoader>
203
303
  </BloomThemeContext.Provider>
204
304
  );
205
305
  }
206
306
 
207
307
  /**
208
- * Scoped color override for a subtree.
209
- * Inherits mode/dark from the parent BloomThemeProvider but overrides the color preset.
308
+ * Scoped color override for a subtree. Inherits the resolved mode from the
309
+ * parent `BloomThemeProvider` but renders descendants with a different preset.
210
310
  */
211
311
  export interface BloomColorScopeProps {
212
312
  colorPreset: AppColorName;
@@ -220,17 +320,11 @@ export function BloomColorScope({ colorPreset, children }: BloomColorScopeProps)
220
320
  }
221
321
 
222
322
  const contextValue = useMemo<BloomThemeContextValue>(() => {
223
- const theme = buildTheme(colorPreset, parent.theme.mode as 'light' | 'dark');
224
- return {
225
- ...parent,
226
- theme,
227
- colorPreset,
228
- };
323
+ const theme = buildTheme(colorPreset, parent.theme.mode);
324
+ return { ...parent, theme, colorPreset };
229
325
  }, [colorPreset, parent]);
230
326
 
231
327
  return (
232
- <BloomThemeContext.Provider value={contextValue}>
233
- {children}
234
- </BloomThemeContext.Provider>
328
+ <BloomThemeContext.Provider value={contextValue}>{children}</BloomThemeContext.Provider>
235
329
  );
236
330
  }
@@ -0,0 +1,128 @@
1
+ import { Platform } from 'react-native';
2
+ import { APP_COLOR_PRESETS, type AppColorName, type PresetTokens } from './color-presets';
3
+ import { getAdaptiveColors } from './adaptive-colors';
4
+ import type { Theme, ThemeColors } from './types';
5
+
6
+ /**
7
+ * Status colors used across the design system. Independent of the accent
8
+ * preset so semantic intent stays stable across themes.
9
+ */
10
+ export const STATUS_COLORS = {
11
+ success: '#10B981',
12
+ error: '#EF4444',
13
+ warning: '#F59E0B',
14
+ info: '#3B82F6',
15
+ } as const;
16
+
17
+ /**
18
+ * Convert a shadcn-style HSL CSS variable (`'H S% L%'` or `'H S% L% / A'`)
19
+ * into a fully-resolved `hsl()` / `hsla()` color string consumable by both
20
+ * web `style` and React Native.
21
+ */
22
+ function hslVarToColor(hslVar: string): string {
23
+ const parts = hslVar.split('/').map((p) => p.trim());
24
+ const triple = parts[0] ?? '0 0% 0%';
25
+ const components = triple.replace(/\s+/g, ', ');
26
+
27
+ if (parts.length === 2) {
28
+ const alpha = parseFloat(parts[1] ?? '100') / 100;
29
+ return `hsla(${components}, ${alpha})`;
30
+ }
31
+ return `hsl(${components})`;
32
+ }
33
+
34
+ function extractHue(hslVar: string): number {
35
+ const first = hslVar.split(/\s+/)[0] ?? '0';
36
+ const hue = parseInt(first, 10);
37
+ return Number.isFinite(hue) ? hue : 0;
38
+ }
39
+
40
+ function hsl(h: number, s: number, l: number): string {
41
+ return `hsl(${h}, ${s}%, ${l}%)`;
42
+ }
43
+
44
+ function readToken(tokens: PresetTokens, key: string, fallback = '0 0% 0%'): string {
45
+ return hslVarToColor(tokens[key] ?? fallback);
46
+ }
47
+
48
+ function buildColorsFromPreset(
49
+ preset: AppColorName,
50
+ resolved: 'light' | 'dark',
51
+ ): ThemeColors {
52
+ const config = APP_COLOR_PRESETS[preset];
53
+ const tokens = resolved === 'dark' ? config.dark : config.light;
54
+ const isDark = resolved === 'dark';
55
+
56
+ const primaryHue = extractHue(tokens['--primary'] ?? '0 0% 50%');
57
+ const destructiveHue = extractHue(tokens['--destructive'] ?? '0 0% 0%');
58
+
59
+ const background = readToken(tokens, '--background');
60
+ const surface = readToken(tokens, '--surface');
61
+ const mutedForeground = readToken(tokens, '--muted-foreground', '0 0% 50%');
62
+ const primaryColor = readToken(tokens, '--primary', '0 0% 50%');
63
+ const primaryForeground = readToken(tokens, '--primary-foreground', '0 0% 100%');
64
+
65
+ return {
66
+ background,
67
+ backgroundSecondary: surface,
68
+ backgroundTertiary: readToken(tokens, '--muted'),
69
+
70
+ text: readToken(tokens, '--foreground', '0 0% 100%'),
71
+ textSecondary: mutedForeground,
72
+ textTertiary: mutedForeground,
73
+
74
+ border: readToken(tokens, '--border', '0 0% 20%'),
75
+ borderLight: readToken(tokens, '--input', '0 0% 20%'),
76
+
77
+ primary: primaryColor,
78
+ primaryForeground,
79
+ // Legacy aliases retained for backwards compatibility with downstream
80
+ // consumers. `primaryLight` should be a brand tint, not the surface, but
81
+ // changing this is a breaking change handled in a separate major.
82
+ primaryLight: surface,
83
+ primaryDark: background,
84
+
85
+ // `secondary` historically mirrored `primary`. Retained for compatibility.
86
+ secondary: primaryColor,
87
+
88
+ tint: primaryColor,
89
+ icon: mutedForeground,
90
+ iconActive: primaryColor,
91
+
92
+ ...STATUS_COLORS,
93
+
94
+ primarySubtle: isDark ? hsl(primaryHue, 50, 10) : hsl(primaryHue, 70, 93),
95
+ primarySubtleForeground: isDark ? hsl(primaryHue, 70, 65) : hsl(primaryHue, 90, 25),
96
+ negative: hsl(destructiveHue, 84, 45),
97
+ negativeForeground: '#FFFFFF',
98
+ negativeSubtle: isDark ? hsl(destructiveHue, 50, 10) : hsl(destructiveHue, 90, 95),
99
+ negativeSubtleForeground: isDark ? hsl(destructiveHue, 70, 65) : hsl(destructiveHue, 80, 40),
100
+ contrast50: isDark ? hsl(primaryHue, 15, 12) : hsl(primaryHue, 10, 93),
101
+
102
+ card: surface,
103
+ shadow: isDark ? 'rgba(0, 0, 0, 0.3)' : 'rgba(0, 0, 0, 0.1)',
104
+ overlay: 'rgba(0, 0, 0, 0.5)',
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Build a `Theme` from a color preset and a resolved light/dark mode.
110
+ *
111
+ * When `isAdaptive` is true and the platform exposes adaptive (Material You /
112
+ * iOS dynamic) colors, those override the preset-derived palette.
113
+ */
114
+ export function buildTheme(
115
+ preset: AppColorName,
116
+ resolved: 'light' | 'dark',
117
+ isAdaptive: boolean = false,
118
+ ): Theme {
119
+ const adaptive = isAdaptive && Platform.OS !== 'web' ? getAdaptiveColors() : undefined;
120
+ const colors = adaptive ?? buildColorsFromPreset(preset, resolved);
121
+
122
+ return {
123
+ mode: resolved,
124
+ colors,
125
+ isDark: resolved === 'dark',
126
+ isLight: resolved === 'light',
127
+ };
128
+ }
@@ -1,13 +1,29 @@
1
1
  export type AppColorName = 'teal' | 'blue' | 'green' | 'amber' | 'yellow' | 'red' | 'purple' | 'pink' | 'sky' | 'orange' | 'mint' | 'oxy' | 'faircoin';
2
2
 
3
+ /**
4
+ * Palette tokens for a single mode (light or dark) of a color preset.
5
+ *
6
+ * Keys carry a leading `--` for historical reasons (shadcn-style CSS variable
7
+ * names) — keep in mind the values are platform-agnostic **raw HSL triples**
8
+ * (e.g. `'185 100% 20%'` or `'185 100% 20% / 0.5'`), not CSS-resolved colors.
9
+ * The same map drives both:
10
+ * - the web layer (written verbatim into `document.documentElement.style`
11
+ * so Tailwind's `hsl(var(--primary))` plumbing picks them up), and
12
+ * - the native layer (`buildTheme` resolves them into `hsl(...)` strings
13
+ * consumable by React Native styles).
14
+ *
15
+ * The `--` prefix is an implementation detail we will drop in a future major.
16
+ */
17
+ export type PresetTokens = Record<string, string>;
18
+
3
19
  export interface AppColorPreset {
4
20
  name: AppColorName;
5
21
  hex: string;
6
- light: Record<string, string>;
7
- dark: Record<string, string>;
22
+ light: PresetTokens;
23
+ dark: PresetTokens;
8
24
  }
9
25
 
10
- export const APP_COLOR_NAMES: AppColorName[] = ['teal', 'blue', 'green', 'amber', 'yellow', 'red', 'purple', 'pink', 'sky', 'orange', 'mint', 'oxy', 'faircoin'];
26
+ export const APP_COLOR_NAMES: readonly AppColorName[] = ['teal', 'blue', 'green', 'amber', 'yellow', 'red', 'purple', 'pink', 'sky', 'orange', 'mint', 'oxy', 'faircoin'];
11
27
 
12
28
  export const HEX_TO_APP_COLOR: Record<string, AppColorName> = {
13
29
  '#005c67': 'teal',
@@ -1,9 +1,21 @@
1
- export { BloomThemeProvider, BloomColorScope, buildTheme } from './BloomThemeProvider';
2
- export type { BloomThemeProviderProps, BloomThemeContextValue, BloomColorScopeProps } from './BloomThemeProvider';
1
+ export { BloomThemeProvider, BloomColorScope } from './BloomThemeProvider';
2
+ export type {
3
+ BloomThemeProviderProps,
4
+ BloomThemeContextValue,
5
+ BloomColorScopeProps,
6
+ } from './BloomThemeProvider';
7
+ export { buildTheme, STATUS_COLORS } from './build-theme';
3
8
  export { useTheme, useThemeColor, useBloomTheme } from './use-theme';
4
9
  export type { Theme, ThemeColors, ThemeMode } from './types';
5
- export type { AppColorName, AppColorPreset } from './color-presets';
6
- export { APP_COLOR_NAMES, APP_COLOR_PRESETS, HEX_TO_APP_COLOR, hexToAppColorName } from './color-presets';
10
+ export type { AppColorName, AppColorPreset, PresetTokens } from './color-presets';
11
+ export {
12
+ APP_COLOR_NAMES,
13
+ APP_COLOR_PRESETS,
14
+ HEX_TO_APP_COLOR,
15
+ hexToAppColorName,
16
+ } from './color-presets';
7
17
  export { applyDarkClass } from './apply-dark-class';
8
18
  export { setColorSchemeSafe } from './set-color-scheme-safe';
9
19
  export { initCssInteropDarkMode } from './init-css-interop';
20
+ export type { BloomThemeStorage, PersistedThemeState } from './persistence';
21
+ export { webLocalStorage } from './persistence';