@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.
- package/lib/commonjs/hooks/useControllableState.js +35 -0
- package/lib/commonjs/hooks/useControllableState.js.map +1 -0
- package/lib/commonjs/theme/BloomThemeProvider.js +148 -112
- package/lib/commonjs/theme/BloomThemeProvider.js.map +1 -1
- package/lib/commonjs/theme/build-theme.js +110 -0
- package/lib/commonjs/theme/build-theme.js.map +1 -0
- package/lib/commonjs/theme/color-presets.js +15 -0
- package/lib/commonjs/theme/color-presets.js.map +1 -1
- package/lib/commonjs/theme/index.js +15 -1
- package/lib/commonjs/theme/index.js.map +1 -1
- package/lib/commonjs/theme/persistence.js +147 -0
- package/lib/commonjs/theme/persistence.js.map +1 -0
- package/lib/commonjs/theme/use-isomorphic-layout-effect.js +15 -0
- package/lib/commonjs/theme/use-isomorphic-layout-effect.js.map +1 -0
- package/lib/module/hooks/useControllableState.js +31 -0
- package/lib/module/hooks/useControllableState.js.map +1 -0
- package/lib/module/theme/BloomThemeProvider.js +149 -112
- package/lib/module/theme/BloomThemeProvider.js.map +1 -1
- package/lib/module/theme/build-theme.js +105 -0
- package/lib/module/theme/build-theme.js.map +1 -0
- package/lib/module/theme/color-presets.js +15 -0
- package/lib/module/theme/color-presets.js.map +1 -1
- package/lib/module/theme/index.js +3 -1
- package/lib/module/theme/index.js.map +1 -1
- package/lib/module/theme/persistence.js +139 -0
- package/lib/module/theme/persistence.js.map +1 -0
- package/lib/module/theme/use-isomorphic-layout-effect.js +12 -0
- package/lib/module/theme/use-isomorphic-layout-effect.js.map +1 -0
- package/lib/typescript/commonjs/hooks/useControllableState.d.ts +19 -0
- package/lib/typescript/commonjs/hooks/useControllableState.d.ts.map +1 -0
- package/lib/typescript/commonjs/theme/BloomThemeProvider.d.ts +35 -12
- package/lib/typescript/commonjs/theme/BloomThemeProvider.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/build-theme.d.ts +20 -0
- package/lib/typescript/commonjs/theme/build-theme.d.ts.map +1 -0
- package/lib/typescript/commonjs/theme/color-presets.d.ts +18 -3
- package/lib/typescript/commonjs/theme/color-presets.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/index.d.ts +7 -4
- package/lib/typescript/commonjs/theme/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/persistence.d.ts +50 -0
- package/lib/typescript/commonjs/theme/persistence.d.ts.map +1 -0
- package/lib/typescript/commonjs/theme/use-isomorphic-layout-effect.d.ts +8 -0
- package/lib/typescript/commonjs/theme/use-isomorphic-layout-effect.d.ts.map +1 -0
- package/lib/typescript/module/hooks/useControllableState.d.ts +19 -0
- package/lib/typescript/module/hooks/useControllableState.d.ts.map +1 -0
- package/lib/typescript/module/theme/BloomThemeProvider.d.ts +35 -12
- package/lib/typescript/module/theme/BloomThemeProvider.d.ts.map +1 -1
- package/lib/typescript/module/theme/build-theme.d.ts +20 -0
- package/lib/typescript/module/theme/build-theme.d.ts.map +1 -0
- package/lib/typescript/module/theme/color-presets.d.ts +18 -3
- package/lib/typescript/module/theme/color-presets.d.ts.map +1 -1
- package/lib/typescript/module/theme/index.d.ts +7 -4
- package/lib/typescript/module/theme/index.d.ts.map +1 -1
- package/lib/typescript/module/theme/persistence.d.ts +50 -0
- package/lib/typescript/module/theme/persistence.d.ts.map +1 -0
- package/lib/typescript/module/theme/use-isomorphic-layout-effect.d.ts +8 -0
- package/lib/typescript/module/theme/use-isomorphic-layout-effect.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/__tests__/BloomThemeProvider.modes.test.tsx +159 -0
- package/src/__tests__/BloomThemeProvider.persistence.test.tsx +321 -0
- package/src/__tests__/persistence.test.ts +155 -0
- package/src/__tests__/theme.test.ts +1 -1
- package/src/hooks/useControllableState.ts +44 -0
- package/src/theme/BloomThemeProvider.tsx +251 -157
- package/src/theme/build-theme.ts +128 -0
- package/src/theme/color-presets.ts +19 -3
- package/src/theme/index.ts +16 -4
- package/src/theme/persistence.ts +174 -0
- 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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
//
|
|
165
|
-
|
|
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(
|
|
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
|
|
186
|
-
(
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
|
194
|
-
|
|
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
|
-
*
|
|
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
|
|
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:
|
|
7
|
-
dark:
|
|
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',
|
package/src/theme/index.ts
CHANGED
|
@@ -1,9 +1,21 @@
|
|
|
1
|
-
export { BloomThemeProvider, BloomColorScope
|
|
2
|
-
export type {
|
|
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 {
|
|
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';
|