@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.
- package/lib/commonjs/hooks/useControllableState.js +35 -0
- package/lib/commonjs/hooks/useControllableState.js.map +1 -0
- package/lib/commonjs/theme/BloomThemeProvider.js +134 -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 +127 -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 +135 -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 +120 -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 +29 -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 +48 -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 +29 -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 +48 -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 +217 -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 +219 -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 +149 -0
- 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/
|
|
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
|
-
|
|
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
|
-
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
|
-
|
|
101
|
-
|
|
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
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
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
|
-
|
|
66
|
+
persistKey?: string;
|
|
129
67
|
/**
|
|
130
|
-
*
|
|
131
|
-
*
|
|
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
|
|
155
|
-
|
|
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
|
-
|
|
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;
|
|
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(
|
|
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
|
|
186
|
-
(
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
|
194
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
}
|