@oxyhq/bloom 0.6.13 → 0.6.14

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 +134 -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 +127 -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 +135 -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 +120 -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 +29 -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 +48 -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 +29 -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 +48 -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 +217 -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 +219 -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 +149 -0
  68. package/src/theme/use-isomorphic-layout-effect.ts +10 -0
@@ -0,0 +1,155 @@
1
+ import {
2
+ readPersistedTheme,
3
+ readPersistedThemeSync,
4
+ writePersistedTheme,
5
+ type BloomThemeStorage,
6
+ } from '../theme/persistence';
7
+
8
+ function createSyncStorage(initial?: Record<string, string>): BloomThemeStorage & {
9
+ store: Record<string, string>;
10
+ } {
11
+ const store: Record<string, string> = { ...initial };
12
+ return {
13
+ store,
14
+ getItem: (key) => (key in store ? store[key]! : null),
15
+ setItem: (key, value) => {
16
+ store[key] = value;
17
+ },
18
+ };
19
+ }
20
+
21
+ function createAsyncStorage(initial?: Record<string, string>): BloomThemeStorage & {
22
+ store: Record<string, string>;
23
+ } {
24
+ const store: Record<string, string> = { ...initial };
25
+ return {
26
+ store,
27
+ getItem: (key) => Promise.resolve(key in store ? store[key]! : null),
28
+ setItem: (key, value) => {
29
+ store[key] = value;
30
+ return Promise.resolve();
31
+ },
32
+ };
33
+ }
34
+
35
+ describe('readPersistedThemeSync', () => {
36
+ it('returns kind=none when persistKey is missing', () => {
37
+ expect(readPersistedThemeSync(undefined, createSyncStorage())).toEqual({ kind: 'none' });
38
+ });
39
+
40
+ it('returns kind=none when storage is missing', () => {
41
+ expect(readPersistedThemeSync('k', undefined)).toEqual({ kind: 'none' });
42
+ });
43
+
44
+ it('detects async adapters', () => {
45
+ expect(readPersistedThemeSync('k', createAsyncStorage())).toEqual({ kind: 'async' });
46
+ });
47
+
48
+ it('parses valid sync payloads', () => {
49
+ const storage = createSyncStorage({ k: JSON.stringify({ mode: 'dark', colorPreset: 'blue' }) });
50
+ expect(readPersistedThemeSync('k', storage)).toEqual({
51
+ kind: 'sync',
52
+ state: { mode: 'dark', colorPreset: 'blue' },
53
+ });
54
+ });
55
+
56
+ it('returns sync/null on empty storage', () => {
57
+ expect(readPersistedThemeSync('k', createSyncStorage())).toEqual({ kind: 'sync', state: null });
58
+ });
59
+
60
+ it('rejects malformed JSON', () => {
61
+ const storage = createSyncStorage({ k: 'not-json' });
62
+ expect(readPersistedThemeSync('k', storage)).toEqual({ kind: 'sync', state: null });
63
+ });
64
+
65
+ it('rejects unknown color presets', () => {
66
+ const storage = createSyncStorage({ k: JSON.stringify({ colorPreset: 'not-a-preset' }) });
67
+ expect(readPersistedThemeSync('k', storage)).toEqual({ kind: 'sync', state: null });
68
+ });
69
+
70
+ it('rejects unknown modes', () => {
71
+ const storage = createSyncStorage({ k: JSON.stringify({ mode: 'rainbow' }) });
72
+ expect(readPersistedThemeSync('k', storage)).toEqual({ kind: 'sync', state: null });
73
+ });
74
+
75
+ it('accepts partial payloads (mode only)', () => {
76
+ const storage = createSyncStorage({ k: JSON.stringify({ mode: 'dark' }) });
77
+ expect(readPersistedThemeSync('k', storage)).toEqual({
78
+ kind: 'sync',
79
+ state: { mode: 'dark', colorPreset: undefined },
80
+ });
81
+ });
82
+
83
+ it('accepts partial payloads (preset only)', () => {
84
+ const storage = createSyncStorage({ k: JSON.stringify({ colorPreset: 'purple' }) });
85
+ expect(readPersistedThemeSync('k', storage)).toEqual({
86
+ kind: 'sync',
87
+ state: { mode: undefined, colorPreset: 'purple' },
88
+ });
89
+ });
90
+
91
+ it('handles thrown getItem as sync/null', () => {
92
+ const storage: BloomThemeStorage = {
93
+ getItem: () => {
94
+ throw new Error('boom');
95
+ },
96
+ setItem: () => undefined,
97
+ };
98
+ expect(readPersistedThemeSync('k', storage)).toEqual({ kind: 'sync', state: null });
99
+ });
100
+ });
101
+
102
+ describe('readPersistedTheme', () => {
103
+ it('reads async storage', async () => {
104
+ const storage = createAsyncStorage({ k: JSON.stringify({ mode: 'dark' }) });
105
+ await expect(readPersistedTheme('k', storage)).resolves.toEqual({
106
+ mode: 'dark',
107
+ colorPreset: undefined,
108
+ });
109
+ });
110
+
111
+ it('returns null when persistence is off', async () => {
112
+ await expect(readPersistedTheme(undefined, createAsyncStorage())).resolves.toBeNull();
113
+ await expect(readPersistedTheme('k', undefined)).resolves.toBeNull();
114
+ });
115
+
116
+ it('swallows storage errors', async () => {
117
+ const storage: BloomThemeStorage = {
118
+ getItem: () => Promise.reject(new Error('boom')),
119
+ setItem: () => Promise.resolve(),
120
+ };
121
+ await expect(readPersistedTheme('k', storage)).resolves.toBeNull();
122
+ });
123
+ });
124
+
125
+ describe('writePersistedTheme', () => {
126
+ it('serializes only defined keys', async () => {
127
+ const storage = createSyncStorage();
128
+ await writePersistedTheme('k', storage, { mode: 'dark' });
129
+ expect(JSON.parse(storage.store.k!)).toEqual({ mode: 'dark' });
130
+ });
131
+
132
+ it('serializes both keys when both are present', async () => {
133
+ const storage = createSyncStorage();
134
+ await writePersistedTheme('k', storage, { mode: 'light', colorPreset: 'oxy' });
135
+ expect(JSON.parse(storage.store.k!)).toEqual({ mode: 'light', colorPreset: 'oxy' });
136
+ });
137
+
138
+ it('is a no-op when persistence is off', async () => {
139
+ const storage = createSyncStorage();
140
+ await writePersistedTheme(undefined, storage, { mode: 'dark' });
141
+ expect(storage.store.k).toBeUndefined();
142
+ });
143
+
144
+ it('swallows storage errors', async () => {
145
+ const storage: BloomThemeStorage = {
146
+ getItem: () => null,
147
+ setItem: () => {
148
+ throw new Error('boom');
149
+ },
150
+ };
151
+ await expect(
152
+ writePersistedTheme('k', storage, { mode: 'dark' }),
153
+ ).resolves.toBeUndefined();
154
+ });
155
+ });
@@ -4,7 +4,7 @@ import {
4
4
  HEX_TO_APP_COLOR,
5
5
  hexToAppColorName,
6
6
  } from '../theme/color-presets';
7
- import { buildTheme } from '../theme/BloomThemeProvider';
7
+ import { buildTheme } from '../theme/build-theme';
8
8
  import type { Theme, ThemeColors, ThemeMode } from '../theme/types';
9
9
 
10
10
  describe('Theme system', () => {
@@ -0,0 +1,44 @@
1
+ import { useCallback, useRef, useState } from 'react';
2
+
3
+ export interface UseControllableStateOptions<T> {
4
+ /** Controlled value. When defined, the hook does not own internal state. */
5
+ value?: T;
6
+ /** Initial value used when uncontrolled. */
7
+ defaultValue: T;
8
+ /** Notified whenever the value changes, controlled or not. */
9
+ onChange?: (next: T) => void;
10
+ }
11
+
12
+ /**
13
+ * Canonical Radix/shadcn-style controllable state hook. Returns a tuple of the
14
+ * current value and a setter that:
15
+ * - updates internal state when uncontrolled, and
16
+ * - always calls `onChange` (so controlled parents stay in sync).
17
+ *
18
+ * Switching between controlled and uncontrolled at runtime is supported but
19
+ * discouraged; the hook keeps the latest `value` for reads either way.
20
+ */
21
+ export function useControllableState<T>({
22
+ value,
23
+ defaultValue,
24
+ onChange,
25
+ }: UseControllableStateOptions<T>): [T, (next: T) => void] {
26
+ const [internal, setInternal] = useState<T>(defaultValue);
27
+ const isControlled = value !== undefined;
28
+ const current = isControlled ? (value as T) : internal;
29
+
30
+ const onChangeRef = useRef(onChange);
31
+ onChangeRef.current = onChange;
32
+
33
+ const setValue = useCallback(
34
+ (next: T) => {
35
+ if (!isControlled) {
36
+ setInternal(next);
37
+ }
38
+ onChangeRef.current?.(next);
39
+ },
40
+ [isControlled],
41
+ );
42
+
43
+ return [current, setValue];
44
+ }
@@ -3,107 +3,37 @@
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
-
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
6
 
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
+ writePersistedTheme,
28
+ type BloomThemeStorage,
29
+ type SyncReadResult,
30
+ } from './persistence';
31
+ import { setColorSchemeSafe } from './set-color-scheme-safe';
32
+ import { useIsomorphicLayoutEffect } from './use-isomorphic-layout-effect';
33
+ import type { Theme, ThemeMode } from './types';
99
34
 
100
- return {
101
- mode: resolved,
102
- colors: themeColors,
103
- isDark,
104
- isLight: !isDark,
105
- };
106
- }
35
+ const DEFAULT_PRESET: AppColorName = 'oxy';
36
+ const DEFAULT_MODE: ThemeMode = 'system';
107
37
 
108
38
  export interface BloomThemeContextValue {
109
39
  theme: Theme;
@@ -116,97 +46,235 @@ export interface BloomThemeContextValue {
116
46
  export const BloomThemeContext = createContext<BloomThemeContextValue | null>(null);
117
47
 
118
48
  export interface BloomThemeProviderProps {
49
+ /** Controlled mode. Omit to use Bloom's internal state (with optional persistence). */
119
50
  mode?: ThemeMode;
51
+ /** Controlled color preset. Omit to use Bloom's internal state. */
120
52
  colorPreset?: AppColorName;
53
+ /** Initial mode when uncontrolled and nothing is persisted yet. */
54
+ defaultMode?: ThemeMode;
55
+ /** Initial color preset when uncontrolled and nothing is persisted yet. */
56
+ defaultColorPreset?: AppColorName;
57
+
121
58
  onModeChange?: (mode: ThemeMode) => void;
122
59
  onColorPresetChange?: (preset: AppColorName) => void;
60
+
123
61
  /**
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.
62
+ * Persist mode + preset under this storage key. Bloom becomes the single
63
+ * source of truth apps don't need their own theme store. Has no effect
64
+ * without `storage`.
127
65
  */
128
- fonts?: boolean;
66
+ persistKey?: string;
129
67
  /**
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.
68
+ * Storage adapter paired with `persistKey`. Use `webLocalStorage` on web,
69
+ * or pass an `AsyncStorage`-compatible adapter on native.
133
70
  */
71
+ storage?: BloomThemeStorage;
72
+ /**
73
+ * Block rendering until persisted state has been read. Default `true` when
74
+ * both `persistKey` and `storage` are provided, ensuring native apps don't
75
+ * flash the default palette. Web hydrates synchronously so this is a no-op
76
+ * there.
77
+ */
78
+ awaitHydration?: boolean;
79
+ /** Rendered while async hydration is pending. */
80
+ onHydrating?: React.ReactNode;
81
+
82
+ /** Load and inject Bloom's font system. Default `true`. */
83
+ fonts?: boolean;
84
+ /** Rendered while native fonts load. Ignored on web. */
134
85
  onFontsLoading?: React.ReactNode;
86
+
135
87
  children: React.ReactNode;
136
88
  }
137
89
 
90
+ interface ThemeStateOptions {
91
+ controlledMode?: ThemeMode;
92
+ controlledPreset?: AppColorName;
93
+ defaultMode: ThemeMode;
94
+ defaultPreset: AppColorName;
95
+ persistKey?: string;
96
+ storage?: BloomThemeStorage;
97
+ onModeChange?: (mode: ThemeMode) => void;
98
+ onColorPresetChange?: (preset: AppColorName) => void;
99
+ }
100
+
101
+ interface ThemeStateResult {
102
+ mode: ThemeMode;
103
+ colorPreset: AppColorName;
104
+ setMode: (mode: ThemeMode) => void;
105
+ setColorPreset: (preset: AppColorName) => void;
106
+ hydrated: boolean;
107
+ }
108
+
109
+ function useThemeState({
110
+ controlledMode,
111
+ controlledPreset,
112
+ defaultMode,
113
+ defaultPreset,
114
+ persistKey,
115
+ storage,
116
+ onModeChange,
117
+ onColorPresetChange,
118
+ }: ThemeStateOptions): ThemeStateResult {
119
+ // Synchronous read happens once on first render. Succeeds on web with
120
+ // localStorage-backed adapters; async adapters rehydrate via the effect
121
+ // below. Lazy-initialized via `useState` so it runs exactly once per mount.
122
+ const [syncResult] = useState<SyncReadResult>(() =>
123
+ readPersistedThemeSync(persistKey, storage),
124
+ );
125
+ const syncState = syncResult.kind === 'sync' ? syncResult.state : null;
126
+
127
+ const initialMode = syncState?.mode ?? defaultMode;
128
+ const initialPreset = syncState?.colorPreset ?? defaultPreset;
129
+
130
+ // Hydrated immediately when persistence is off or the adapter resolved
131
+ // synchronously (including a null hit — that's a valid "no value" answer).
132
+ const [hydrated, setHydrated] = useState<boolean>(syncResult.kind !== 'async');
133
+
134
+ const [mode, setModeInternal] = useControllableState<ThemeMode>({
135
+ value: controlledMode,
136
+ defaultValue: initialMode,
137
+ });
138
+ const [colorPreset, setPresetInternal] = useControllableState<AppColorName>({
139
+ value: controlledPreset,
140
+ defaultValue: initialPreset,
141
+ });
142
+
143
+ // Refs let setMode/setColorPreset stay referentially stable. Callbacks
144
+ // memoized only by setters and storage identity won't churn the context
145
+ // value on every theme change.
146
+ const modeRef = useRef(mode);
147
+ modeRef.current = mode;
148
+ const presetRef = useRef(colorPreset);
149
+ presetRef.current = colorPreset;
150
+
151
+ // Async hydration for adapters that can't be read synchronously
152
+ // (AsyncStorage, MMKV via JSI fallback, etc).
153
+ useEffect(() => {
154
+ if (hydrated) return;
155
+ if (!persistKey || !storage) {
156
+ setHydrated(true);
157
+ return;
158
+ }
159
+
160
+ let cancelled = false;
161
+ readPersistedTheme(persistKey, storage).then((state) => {
162
+ if (cancelled) return;
163
+ if (state?.mode && controlledMode === undefined) {
164
+ setModeInternal(state.mode);
165
+ onModeChange?.(state.mode);
166
+ }
167
+ if (state?.colorPreset && controlledPreset === undefined) {
168
+ setPresetInternal(state.colorPreset);
169
+ onColorPresetChange?.(state.colorPreset);
170
+ }
171
+ setHydrated(true);
172
+ });
173
+
174
+ return () => {
175
+ cancelled = true;
176
+ };
177
+ // Hydration runs once per storage instance. controlledMode/Preset are
178
+ // captured by closure intentionally — switching controlled-ness mid-flight
179
+ // is unsupported and would invalidate the in-flight hydration anyway.
180
+ // eslint-disable-next-line react-hooks/exhaustive-deps
181
+ }, [persistKey, storage]);
182
+
183
+ const setMode = useCallback(
184
+ (next: ThemeMode) => {
185
+ setModeInternal(next);
186
+ onModeChange?.(next);
187
+ void writePersistedTheme(persistKey, storage, {
188
+ mode: next,
189
+ colorPreset: presetRef.current,
190
+ });
191
+ },
192
+ [setModeInternal, onModeChange, persistKey, storage],
193
+ );
194
+
195
+ const setColorPreset = useCallback(
196
+ (next: AppColorName) => {
197
+ setPresetInternal(next);
198
+ onColorPresetChange?.(next);
199
+ void writePersistedTheme(persistKey, storage, {
200
+ mode: modeRef.current,
201
+ colorPreset: next,
202
+ });
203
+ },
204
+ [setPresetInternal, onColorPresetChange, persistKey, storage],
205
+ );
206
+
207
+ return { mode, colorPreset, setMode, setColorPreset, hydrated };
208
+ }
209
+
138
210
  export function BloomThemeProvider({
139
211
  mode: controlledMode,
140
212
  colorPreset: controlledPreset,
213
+ defaultMode = DEFAULT_MODE,
214
+ defaultColorPreset = DEFAULT_PRESET,
141
215
  onModeChange,
142
216
  onColorPresetChange,
217
+ persistKey,
218
+ storage,
219
+ awaitHydration,
220
+ onHydrating,
143
221
  fonts = true,
144
222
  onFontsLoading,
145
223
  children,
146
224
  }: BloomThemeProviderProps) {
147
225
  const rnScheme = useRNColorScheme();
148
- const [internalPreset, setInternalPreset] = useState<AppColorName>(controlledPreset ?? 'oxy');
149
-
150
- if (controlledPreset !== undefined && controlledPreset !== internalPreset) {
151
- setInternalPreset(controlledPreset);
152
- }
153
226
 
154
- const appColor = internalPreset;
155
- const mode = controlledMode ?? 'system';
227
+ const { mode, colorPreset, setMode, setColorPreset, hydrated } = useThemeState({
228
+ controlledMode,
229
+ controlledPreset,
230
+ defaultMode,
231
+ defaultPreset: defaultColorPreset,
232
+ persistKey,
233
+ storage,
234
+ onModeChange,
235
+ onColorPresetChange,
236
+ });
156
237
 
157
238
  const isAdaptive = mode === 'adaptive';
158
- const effectiveMode = isAdaptive ? 'system' : mode;
239
+ const effectiveMode: Exclude<ThemeMode, 'adaptive'> = isAdaptive ? 'system' : mode;
159
240
  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;
241
+ effectiveMode === 'system' ? (rnScheme === 'dark' ? 'dark' : 'light') : effectiveMode;
242
+
243
+ // Apply native color scheme, dark class, and CSS vars whenever the resolved
244
+ // mode or preset changes. `useIsomorphicLayoutEffect` runs before paint on
245
+ // both native and web, eliminating the previous render-time side effect.
246
+ useIsomorphicLayoutEffect(() => {
172
247
  setColorSchemeSafe(effectiveMode);
173
248
  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
- );
249
+ applyColorPresetVars(colorPreset, resolved);
250
+ }, [effectiveMode, resolved, colorPreset]);
184
251
 
185
- const setColorPreset = useCallback(
186
- (preset: AppColorName) => {
187
- setInternalPreset(preset);
188
- onColorPresetChange?.(preset);
189
- },
190
- [onColorPresetChange],
252
+ const contextValue = useMemo<BloomThemeContextValue>(
253
+ () => ({
254
+ theme: buildTheme(colorPreset, resolved, isAdaptive),
255
+ mode,
256
+ colorPreset,
257
+ setMode,
258
+ setColorPreset,
259
+ }),
260
+ [colorPreset, resolved, isAdaptive, mode, setMode, setColorPreset],
191
261
  );
192
262
 
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]);
263
+ const shouldAwait = awaitHydration ?? Boolean(persistKey && storage);
264
+ const isGated = shouldAwait && !hydrated;
197
265
 
198
266
  return (
199
267
  <BloomThemeContext.Provider value={contextValue}>
200
268
  <FontLoader enabled={fonts} fallback={onFontsLoading}>
201
- {children}
269
+ {isGated ? onHydrating ?? null : children}
202
270
  </FontLoader>
203
271
  </BloomThemeContext.Provider>
204
272
  );
205
273
  }
206
274
 
207
275
  /**
208
- * Scoped color override for a subtree.
209
- * Inherits mode/dark from the parent BloomThemeProvider but overrides the color preset.
276
+ * Scoped color override for a subtree. Inherits the resolved mode from the
277
+ * parent `BloomThemeProvider` but renders descendants with a different preset.
210
278
  */
211
279
  export interface BloomColorScopeProps {
212
280
  colorPreset: AppColorName;
@@ -220,17 +288,11 @@ export function BloomColorScope({ colorPreset, children }: BloomColorScopeProps)
220
288
  }
221
289
 
222
290
  const contextValue = useMemo<BloomThemeContextValue>(() => {
223
- const theme = buildTheme(colorPreset, parent.theme.mode as 'light' | 'dark');
224
- return {
225
- ...parent,
226
- theme,
227
- colorPreset,
228
- };
291
+ const theme = buildTheme(colorPreset, parent.theme.mode);
292
+ return { ...parent, theme, colorPreset };
229
293
  }, [colorPreset, parent]);
230
294
 
231
295
  return (
232
- <BloomThemeContext.Provider value={contextValue}>
233
- {children}
234
- </BloomThemeContext.Provider>
296
+ <BloomThemeContext.Provider value={contextValue}>{children}</BloomThemeContext.Provider>
235
297
  );
236
298
  }