@adea-ai/ui 0.123.1 → 0.124.0

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 (57) hide show
  1. package/dist/NOTICE +18 -0
  2. package/dist/components/theme/appearance-panel.js +1 -1
  3. package/dist/components/theme/index.d.ts +1 -1
  4. package/dist/components/theme/index.d.ts.map +1 -1
  5. package/dist/components/theme/index.js +5 -2
  6. package/dist/components/theme/theme-context.d.ts +13 -0
  7. package/dist/components/theme/theme-context.d.ts.map +1 -0
  8. package/dist/components/theme/theme-context.js +41 -0
  9. package/dist/components/theme/theme-context.js.map +1 -0
  10. package/dist/components/theme/theme-document.d.ts +76 -0
  11. package/dist/components/theme/theme-document.d.ts.map +1 -0
  12. package/dist/components/theme/theme-document.js +93 -0
  13. package/dist/components/theme/theme-document.js.map +1 -0
  14. package/dist/components/theme/theme-persistence.d.ts +50 -0
  15. package/dist/components/theme/theme-persistence.d.ts.map +1 -0
  16. package/dist/components/theme/theme-persistence.js +86 -0
  17. package/dist/components/theme/theme-persistence.js.map +1 -0
  18. package/dist/components/theme/theme-provider.d.ts +43 -75
  19. package/dist/components/theme/theme-provider.d.ts.map +1 -1
  20. package/dist/components/theme/theme-provider.js +50 -116
  21. package/dist/components/theme/theme-provider.js.map +1 -1
  22. package/dist/components/theme/theme-state.d.ts +142 -0
  23. package/dist/components/theme/theme-state.d.ts.map +1 -0
  24. package/dist/components/theme/theme-state.js +104 -0
  25. package/dist/components/theme/theme-state.js.map +1 -0
  26. package/dist/components/theme/theme-toggle.js +1 -1
  27. package/dist/index.d.ts +1 -1
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +6 -3
  30. package/dist/lib/appearance-font-settings.d.ts +1 -1
  31. package/dist/lib/appearance-font-settings.d.ts.map +1 -1
  32. package/dist/lib/appearance-font-settings.js.map +1 -1
  33. package/dist/lib/themes.d.ts +48 -0
  34. package/dist/lib/themes.d.ts.map +1 -1
  35. package/dist/lib/themes.js +150 -36
  36. package/dist/lib/themes.js.map +1 -1
  37. package/dist/r/registry.json +20 -0
  38. package/dist/r/src/components/theme/index.ts +3 -0
  39. package/dist/r/src/components/theme/theme-context.ts +43 -0
  40. package/dist/r/src/components/theme/theme-document.ts +168 -0
  41. package/dist/r/src/components/theme/theme-persistence.ts +140 -0
  42. package/dist/r/src/components/theme/theme-provider.tsx +126 -253
  43. package/dist/r/src/components/theme/theme-state.ts +225 -0
  44. package/dist/r/src/lib/appearance-font-settings.ts +1 -1
  45. package/dist/r/src/lib/themes.ts +181 -40
  46. package/dist/r/theming.json +20 -0
  47. package/package.json +1 -1
  48. package/registry.json +20 -0
  49. package/src/components/theme/index.ts +3 -0
  50. package/src/components/theme/theme-context.ts +43 -0
  51. package/src/components/theme/theme-document.ts +168 -0
  52. package/src/components/theme/theme-persistence.ts +140 -0
  53. package/src/components/theme/theme-provider.tsx +126 -253
  54. package/src/components/theme/theme-state.ts +225 -0
  55. package/src/index.ts +3 -0
  56. package/src/lib/appearance-font-settings.ts +1 -1
  57. package/src/lib/themes.ts +181 -40
@@ -0,0 +1,140 @@
1
+ import { accentPresets } from '#lib/tokens'
2
+ import { normalizeAppearanceEditorFontSettings } from '#lib/appearance-font-settings'
3
+ import { defaultDarkThemeId, defaultLightThemeId, isThemeAccentId, themeById } from '#lib/themes'
4
+ import { defaultThemeSelection, resolvedDensity, type ThemeSelection } from './theme-state'
5
+
6
+ /**
7
+ * Where a selection is kept between sessions.
8
+ *
9
+ * The provider never touches storage itself; it asks a `ThemePersistence`. The
10
+ * default is {@link localStoragePersistence}, which is what the provider always did.
11
+ * A host that stores the preference in a different shape, under a versioned
12
+ * envelope, or somewhere that is not `localStorage` supplies its own and the
13
+ * resolution and application rules stay the shared ones.
14
+ *
15
+ * The contract is deliberately narrow, so that an implementation cannot make the
16
+ * provider less safe than it was:
17
+ *
18
+ * - `read` is synchronous and runs once, when the provider is created. It may
19
+ * repair or quarantine what it finds, and it must not throw: the provider
20
+ * treats a throw as "nothing stored" and starts from the defaults.
21
+ * - `write` runs only from `setSelection`. Never on mount, never from `preview`,
22
+ * never as a side effect of reading, so the first write to a store is always a
23
+ * user's decision. A throw is swallowed, because a full or blocked store must
24
+ * not break the switch.
25
+ */
26
+ export type ThemePersistence = {
27
+ read(): Partial<ThemeSelection> | undefined
28
+ write(next: ThemeSelection): void
29
+ }
30
+
31
+ /**
32
+ * The preference store, or nothing.
33
+ *
34
+ * Only ever called inside a `try`: where storage is blocked — a sandboxed iframe, a
35
+ * privacy mode, a denied site-data permission — merely *reading* the `localStorage`
36
+ * global throws ("Access is denied", `SecurityError`), so even the `typeof` guard
37
+ * has to sit inside the `try` rather than in front of it. Without a store the
38
+ * preference lives only in the provider's signal for the session.
39
+ */
40
+ function preferenceStore(): Storage | undefined {
41
+ return typeof localStorage === 'undefined' ? undefined : localStorage
42
+ }
43
+
44
+ /** The stored preference, validated against the catalogue. */
45
+ function readSelection(storageKey: string): ThemeSelection {
46
+ try {
47
+ const raw = preferenceStore()?.getItem(storageKey)
48
+ if (!raw) return defaultThemeSelection
49
+ const parsed = JSON.parse(raw) as Partial<ThemeSelection>
50
+ const fonts = normalizeAppearanceEditorFontSettings(parsed.fonts, parsed.font).settings
51
+ return {
52
+ appearance:
53
+ parsed.appearance === 'light' ||
54
+ parsed.appearance === 'dark' ||
55
+ parsed.appearance === 'system'
56
+ ? parsed.appearance
57
+ : defaultThemeSelection.appearance,
58
+ // An unknown id would leave the document with no theme at all, so a stale
59
+ // preference from an older catalogue falls back rather than applying nothing.
60
+ lightThemeId:
61
+ themeById(parsed.lightThemeId ?? '')?.appearance === 'light'
62
+ ? (parsed.lightThemeId as string)
63
+ : defaultLightThemeId,
64
+ darkThemeId:
65
+ themeById(parsed.darkThemeId ?? '')?.appearance === 'dark'
66
+ ? (parsed.darkThemeId as string)
67
+ : defaultDarkThemeId,
68
+ // A theme accent is kept whenever it names a slot, even one the stored pair
69
+ // cannot offer: the id is role-shaped so it survives a theme switch, and an
70
+ // unoffered slot already resolves to the theme's own primary when applied.
71
+ accent:
72
+ typeof parsed.accent === 'string' &&
73
+ (accentPresets.some((preset) => preset.id === parsed.accent) ||
74
+ isThemeAccentId(parsed.accent))
75
+ ? parsed.accent
76
+ : defaultThemeSelection.accent,
77
+ font: fonts.ui.family,
78
+ fonts,
79
+ // Every axis has to be read back here, or it is lost twice: the provider's
80
+ // document effect removes the `data-density` that `themeScript` set for the first paint, and
81
+ // the next `setSelection` rewrites the store without it.
82
+ density: resolvedDensity(parsed as ThemeSelection),
83
+ }
84
+ } catch {
85
+ return defaultThemeSelection
86
+ }
87
+ }
88
+
89
+ /**
90
+ * The default persistence: one JSON document in `localStorage` under `key`.
91
+ *
92
+ * `read` is the provider's original `readSelection`, moved here unchanged — every
93
+ * stored id is validated against the catalogue, so a stale preference falls back to
94
+ * a default rather than reaching the document. `key` may be a function so a
95
+ * provider whose `storageKey` prop changes writes to the key it is now given.
96
+ *
97
+ * Blocked, full or absent storage is survivable in both directions: a read answers
98
+ * with the defaults and a write is dropped, so the preference simply lasts the
99
+ * session.
100
+ */
101
+ export function localStoragePersistence(key: string | (() => string)): ThemePersistence {
102
+ const keyOf = () => (typeof key === 'function' ? key() : key)
103
+ return {
104
+ read: () => readSelection(keyOf()),
105
+ write(next) {
106
+ try {
107
+ preferenceStore()?.setItem(keyOf(), JSON.stringify(next))
108
+ } catch {
109
+ // A full, blocked or unavailable store must not break the switch: the
110
+ // preference simply does not survive the reload.
111
+ }
112
+ },
113
+ }
114
+ }
115
+
116
+ /**
117
+ * What a persistence knows, or nothing.
118
+ *
119
+ * The seam's `read` must not throw, and the provider does not rely on that: a host
120
+ * persistence is code the design system has not seen, and a theme provider that
121
+ * fails to mount because a store misbehaved has turned a lost preference into a
122
+ * blank application.
123
+ */
124
+ export function readPersisted(persistence: ThemePersistence): Partial<ThemeSelection> | undefined {
125
+ try {
126
+ return persistence.read()
127
+ } catch {
128
+ return undefined
129
+ }
130
+ }
131
+
132
+ /** Hand a selection to the store; a store that throws loses the preference, not the switch. */
133
+ export function writePersisted(persistence: ThemePersistence, next: ThemeSelection): void {
134
+ try {
135
+ persistence.write(next)
136
+ } catch {
137
+ // Same contract as a full or blocked `localStorage`: the choice holds for the
138
+ // session and does not survive the reload.
139
+ }
140
+ }
@@ -1,35 +1,39 @@
1
1
  import type { JSX } from 'solid-js'
2
- import {
3
- createContext,
4
- createEffect,
5
- createMemo,
6
- createSignal,
7
- onCleanup,
8
- splitProps,
9
- useContext,
10
- } from 'solid-js'
11
- import { accentPresets } from '#lib/tokens'
2
+ import { createEffect, createMemo, createSignal, onCleanup, splitProps } from 'solid-js'
12
3
  import { safeScriptStringLiteral } from '#lib/safe-script'
4
+ import { fontSettingsBootstrapScript } from '#lib/appearance-font-settings'
13
5
  import {
14
- applyAppearanceFontSettings,
15
- DEFAULT_APPEARANCE_EDITOR_FONT_SETTINGS,
16
- fontSettingsBootstrapScript,
17
- normalizeAppearanceEditorFontSettings,
18
- type AppearanceEditorFontSettings,
19
- } from '#lib/appearance-font-settings'
20
- import type { AccentPreset } from '@adea-ai/themes'
21
- import {
22
- accentVariables,
23
6
  builtinThemes,
24
- defaultDarkThemeId,
25
- defaultLightThemeId,
26
- isThemeAccentId,
27
7
  themeAccentsFor,
28
- themeById,
29
- themeCssVariables,
30
8
  type ThemeAppearance,
31
9
  type ThemeVariant,
32
10
  } from '#lib/themes'
11
+ import { themeContext } from './theme-context'
12
+ import { applyThemeToRoot, applyWithoutTransitions } from './theme-document'
13
+ import {
14
+ localStoragePersistence,
15
+ readPersisted,
16
+ writePersisted,
17
+ type ThemePersistence,
18
+ } from './theme-persistence'
19
+ import {
20
+ initialThemeSelection,
21
+ mergeRegistry,
22
+ mergeThemeSelection,
23
+ resolveThemeState,
24
+ type ThemeContextValue,
25
+ type ThemeSelection,
26
+ } from './theme-state'
27
+
28
+ export { useOptionalTheme, useTheme } from './theme-context'
29
+ export { localStoragePersistence, type ThemePersistence } from './theme-persistence'
30
+ export {
31
+ defaultThemeSelection,
32
+ resolvedDensity,
33
+ type ThemeContextValue,
34
+ type ThemeDensity,
35
+ type ThemeSelection,
36
+ } from './theme-state'
33
37
 
34
38
  /**
35
39
  * ThemeProvider.
@@ -39,19 +43,17 @@ import {
39
43
  *
40
44
  * ## What a preference is
41
45
  *
42
- * Four independent choices, and keeping them independent is the point:
46
+ * Four independent choices (`ThemeSelection`), and keeping them independent is the
47
+ * point: appearance, a theme per appearance, an accent and fonts. They compose: a
48
+ * user who wants Nord with a violet accent and the system font should not need
49
+ * someone to author that combination.
43
50
  *
44
- * - **appearance** — light, dark, or follow the system.
45
- * - **a theme per appearance** — so a user can run Catppuccin Mocha by night and
46
- * Solarized Light by day without re-choosing when the system flips.
47
- * - **an accent** — adea's six presets, or one of the accents the selected theme
48
- * pair carries itself (`ansi-blue`…), which re-colour the interactive roles on
49
- * top of whichever theme is active.
50
- * - **fonts** — separate family and size choices for UI, content and code.
51
+ * ## Where the rules live
51
52
  *
52
- * Four axes rather than one list of presets, because they compose: a user who wants
53
- * Nord with a violet accent and the system font should not need someone to author
54
- * that combination.
53
+ * This component is wiring. What a selection merges to and resolves to is
54
+ * `theme-state.ts`; what lands on `<html>` is `theme-document.ts`; where the
55
+ * preference is kept is `theme-persistence.ts`. Each is plain code a test can call,
56
+ * which is the reason none of it is inlined here.
55
57
  *
56
58
  * ## Why the variants are applied as inline properties
57
59
  *
@@ -62,167 +64,6 @@ import {
62
64
  * stylesheet. `theme.css` keeps the default theme's values so the first paint is
63
65
  * correct before any script runs.
64
66
  */
65
- export type ThemeSelection = {
66
- /** `system` follows `prefers-color-scheme` and updates when it changes. */
67
- appearance: ThemeAppearance | 'system'
68
- /** The variant to use when the resolved appearance is light. */
69
- lightThemeId: string
70
- /** The variant to use when the resolved appearance is dark. */
71
- darkThemeId: string
72
- /**
73
- * An `accentPresets` id, a theme accent id from `themeAccents()` (`ansi-blue`…),
74
- * or `theme` for the variant's own primary. A theme accent the current pair does
75
- * not offer falls back to the variant's own primary.
76
- */
77
- accent: string
78
- /** UI-family alias for consumers of the original single-font preference. */
79
- font: string
80
- /** UI, content and code font settings. Omitted legacy records are migrated from `font`. */
81
- fonts?: AppearanceEditorFontSettings
82
- /**
83
- * `comfortable` is the system's default geometry; `compact` moves the control
84
- * ladder down one rung.
85
- *
86
- * Optional, and defaulted on read, so a preference stored before density existed
87
- * keeps working and `ThemeSelection` stays assignable without it.
88
- */
89
- density?: ThemeDensity
90
- }
91
-
92
- export const defaultThemeSelection: ThemeSelection = {
93
- appearance: 'system',
94
- lightThemeId: defaultLightThemeId,
95
- darkThemeId: defaultDarkThemeId,
96
- accent: 'theme',
97
- font: 'system',
98
- fonts: DEFAULT_APPEARANCE_EDITOR_FONT_SETTINGS,
99
- density: 'comfortable',
100
- }
101
-
102
- /**
103
- * The two density rungs.
104
- *
105
- * `comfortable` is the geometry in `theme.css`, so it is the absence of an
106
- * attribute rather than a value: a document that ships no `data-density` at all
107
- * renders correctly, which is what keeps this additive.
108
- */
109
- export type ThemeDensity = 'comfortable' | 'compact'
110
-
111
- /**
112
- * A stored selection's density, defaulted.
113
- *
114
- * The stored value is read through here rather than trusted, because a preference
115
- * written before density existed has no `density` key and a hand-edited store may
116
- * hold anything.
117
- */
118
- export function resolvedDensity(selection: ThemeSelection): ThemeDensity {
119
- return selection.density === 'compact' ? 'compact' : 'comfortable'
120
- }
121
-
122
- export type ThemeContextValue = {
123
- /** The preference as stored. */
124
- selection: () => ThemeSelection
125
- /** What the preference resolves to right now, after `system`. */
126
- resolvedAppearance: () => ThemeAppearance
127
- /** The variant in use. */
128
- variant: () => ThemeVariant
129
- /** Update part of the preference. */
130
- setSelection: (patch: Partial<ThemeSelection>) => void
131
- /** The catalogue, so a picker does not import it separately. */
132
- themes: readonly ThemeVariant[]
133
- /**
134
- * The accents the selected light/dark pair carries, offered beside the presets.
135
- * Follows the selection: a different theme pair may offer different slots.
136
- */
137
- themeAccents: () => readonly AccentPreset[]
138
- }
139
-
140
- const ThemeContext = createContext<ThemeContextValue>()
141
-
142
- export function useTheme(): ThemeContextValue {
143
- const value = useContext(ThemeContext)
144
- if (!value) throw new Error('useTheme must be called inside a <ThemeProvider>.')
145
- return value
146
- }
147
-
148
- /**
149
- * The preference store, or nothing.
150
- *
151
- * Only ever called inside a `try`: where storage is blocked — a sandboxed iframe, a
152
- * privacy mode, a denied site-data permission — merely *reading* the `localStorage`
153
- * global throws ("Access is denied", `SecurityError`), so even the `typeof` guard
154
- * has to sit inside the `try` rather than in front of it. Without a store the
155
- * preference lives only in the provider's signal for the session.
156
- */
157
- function preferenceStore(): Storage | undefined {
158
- return typeof localStorage === 'undefined' ? undefined : localStorage
159
- }
160
-
161
- /** The stored preference, validated against the catalogue. */
162
- function readSelection(storageKey: string): ThemeSelection {
163
- try {
164
- const raw = preferenceStore()?.getItem(storageKey)
165
- if (!raw) return defaultThemeSelection
166
- const parsed = JSON.parse(raw) as Partial<ThemeSelection>
167
- const fonts = normalizeAppearanceEditorFontSettings(parsed.fonts, parsed.font).settings
168
- return {
169
- appearance:
170
- parsed.appearance === 'light' ||
171
- parsed.appearance === 'dark' ||
172
- parsed.appearance === 'system'
173
- ? parsed.appearance
174
- : defaultThemeSelection.appearance,
175
- // An unknown id would leave the document with no theme at all, so a stale
176
- // preference from an older catalogue falls back rather than applying nothing.
177
- lightThemeId:
178
- themeById(parsed.lightThemeId ?? '')?.appearance === 'light'
179
- ? (parsed.lightThemeId as string)
180
- : defaultLightThemeId,
181
- darkThemeId:
182
- themeById(parsed.darkThemeId ?? '')?.appearance === 'dark'
183
- ? (parsed.darkThemeId as string)
184
- : defaultDarkThemeId,
185
- // A theme accent is kept whenever it names a slot, even one the stored pair
186
- // cannot offer: the id is role-shaped so it survives a theme switch, and an
187
- // unoffered slot already resolves to the theme's own primary when applied.
188
- accent:
189
- typeof parsed.accent === 'string' &&
190
- (accentPresets.some((preset) => preset.id === parsed.accent) ||
191
- isThemeAccentId(parsed.accent))
192
- ? parsed.accent
193
- : defaultThemeSelection.accent,
194
- font: fonts.ui.family,
195
- fonts,
196
- // Every axis has to be read back here, or it is lost twice: the effect below
197
- // removes the `data-density` that `themeScript` set for the first paint, and
198
- // the next `setSelection` rewrites the store without it.
199
- density: resolvedDensity(parsed as ThemeSelection),
200
- }
201
- } catch {
202
- return defaultThemeSelection
203
- }
204
- }
205
-
206
- /** Keep the deprecated single-font view synchronized with the UI axis. */
207
- function mergeThemeSelection(base: ThemeSelection, patch: Partial<ThemeSelection>): ThemeSelection {
208
- const merged = { ...base, ...patch }
209
- let fonts: AppearanceEditorFontSettings
210
-
211
- if (patch.fonts !== undefined) {
212
- fonts = normalizeAppearanceEditorFontSettings(patch.fonts).settings
213
- } else if (patch.font !== undefined) {
214
- const currentFonts = normalizeAppearanceEditorFontSettings(base.fonts, base.font).settings
215
- fonts = normalizeAppearanceEditorFontSettings({
216
- ...currentFonts,
217
- ui: { ...currentFonts.ui, family: patch.font },
218
- }).settings
219
- } else {
220
- fonts = normalizeAppearanceEditorFontSettings(merged.fonts, merged.font).settings
221
- }
222
-
223
- return { ...merged, fonts, font: fonts.ui.family }
224
- }
225
-
226
67
  export type ThemeProviderProps = {
227
68
  children?: JSX.Element
228
69
  /** Where the preference is persisted. Distinct per application. */
@@ -242,14 +83,60 @@ export type ThemeProviderProps = {
242
83
  * seeds from `initial`, and persists every `setSelection`.
243
84
  */
244
85
  selection?: Partial<ThemeSelection>
86
+ /**
87
+ * Where the preference is kept, in place of `localStorage` under `storageKey`.
88
+ *
89
+ * Read once when the provider is created, so changing the prop later does not
90
+ * move the store. `storageKey` is ignored when this is given.
91
+ */
92
+ persistence?: ThemePersistence
93
+ /**
94
+ * Variants the host adds to the built-in catalogue — themes a user imported.
95
+ *
96
+ * A function is read inside the provider's reactive scope, so one that reads a
97
+ * signal keeps the picker and the document current when the set changes. A
98
+ * stored id that names no variant, built in or added, paints the default of its
99
+ * appearance and stays in the selection untouched.
100
+ */
101
+ themes?: readonly ThemeVariant[] | (() => readonly ThemeVariant[])
102
+ /**
103
+ * Rewrites the custom properties of each pass before they are written to
104
+ * `<html>`: the variant's roles with the accent over them. `tokensAsHex` from
105
+ * `lib/themes` is the one the system ships.
106
+ */
107
+ transformTokens?: (tokens: Record<string, string>) => Record<string, string>
108
+ /**
109
+ * Switch CSS transitions off for the frame that carries a change, so a theme
110
+ * switch cannot animate the whole page. Off by default: it adds a `<style>`
111
+ * element to `<head>` for two frames on every change.
112
+ */
113
+ disableTransitionOnChange?: boolean
245
114
  }
246
115
 
247
116
  export function ThemeProvider(props: ThemeProviderProps) {
248
- const [local] = splitProps(props, ['children', 'storageKey', 'initial', 'selection'])
117
+ const [local] = splitProps(props, [
118
+ 'children',
119
+ 'storageKey',
120
+ 'initial',
121
+ 'selection',
122
+ 'persistence',
123
+ 'themes',
124
+ 'transformTokens',
125
+ 'disableTransitionOnChange',
126
+ ])
249
127
  const storageKey = () => local.storageKey ?? 'adea-appearance'
128
+ const persistence = local.persistence ?? localStoragePersistence(storageKey)
129
+
130
+ // Read before the first resolution and before the store is, so a persistence whose
131
+ // answer depends on the extras (an id it only accepts if the theme exists) sees
132
+ // them.
133
+ const extraThemes = createMemo(() =>
134
+ typeof local.themes === 'function' ? local.themes() : (local.themes ?? [])
135
+ )
136
+ const registry = createMemo(() => mergeRegistry(builtinThemes, extraThemes()))
250
137
 
251
138
  const [selection, setSelectionState] = createSignal<ThemeSelection>(
252
- mergeThemeSelection(readSelection(storageKey()), local.initial ?? {})
139
+ initialThemeSelection(readPersisted(persistence), local.initial)
253
140
  )
254
141
 
255
142
  // The controlled half of the contract. A patch from the owner replaces the
@@ -272,60 +159,43 @@ export function ThemeProvider(props: ThemeProviderProps) {
272
159
  onCleanup(() => query.removeEventListener('change', onChange))
273
160
  }
274
161
 
275
- const resolvedAppearance = createMemo<ThemeAppearance>(() => {
276
- const preference = selection().appearance
277
- if (preference === 'system') return systemAppearance()
278
- return preference
279
- })
280
-
281
- const variant = createMemo<ThemeVariant>(() => {
282
- const id = resolvedAppearance() === 'dark' ? selection().darkThemeId : selection().lightThemeId
283
- return themeById(id) ?? builtinThemes[0]!
162
+ // What the stored preference resolves to. `selection()`, `variant()` and
163
+ // `resolvedAppearance()` answer from this and never from a preview, so a picker
164
+ // that previews a draft still reads back what is committed.
165
+ const committed = createMemo(() => resolveThemeState(selection(), systemAppearance(), registry()))
166
+ const resolvedAppearance = createMemo<ThemeAppearance>(() => committed().appearance)
167
+ const variant = createMemo<ThemeVariant>(() => committed().variant)
168
+
169
+ const [draft, setDraft] = createSignal<Partial<ThemeSelection> | undefined>()
170
+ const shownSelection = createMemo(() => {
171
+ const preview = draft()
172
+ return preview ? mergeThemeSelection(selection(), preview) : selection()
284
173
  })
174
+ const shown = createMemo(() =>
175
+ draft() ? resolveThemeState(shownSelection(), systemAppearance(), registry()) : committed()
176
+ )
285
177
 
178
+ let written: ReadonlySet<string> | undefined
179
+ let restoreTransitions: (() => void) | undefined
286
180
  createEffect(() => {
287
181
  if (typeof document === 'undefined') return
288
- const root = document.documentElement
289
- const active = variant()
290
-
291
- // The variant's roles, as properties. `--primary` and `--ring` are written here
292
- // too, and an accent selection is layered over them below.
293
- //
294
- // The accent is applied *here* rather than left to the `[data-accent]` blocks in
295
- // `theme.css`, because this loop writes inline custom properties and an inline
296
- // declaration outranks every stylesheet selector however specific it is. The
297
- // blocks still exist and are still correct — they are what styles the window
298
- // before this runs — but they cannot win from here, so leaving the accent to
299
- // them meant the attribute was set, the rules were present, and nothing
300
- // changed on screen.
301
- const properties = { ...themeCssVariables(active) }
302
- const accent = accentVariables(selection().accent, resolvedAppearance(), selection())
303
- if (accent) Object.assign(properties, accent)
304
-
305
- for (const [name, value] of Object.entries(properties)) {
306
- root.style.setProperty(name, value)
182
+ const state = {
183
+ selection: shownSelection(),
184
+ appearance: shown().appearance,
185
+ variant: shown().variant,
186
+ }
187
+ const options = { transformTokens: local.transformTokens }
188
+ const apply = () => {
189
+ written = applyThemeToRoot(document.documentElement, state, options, written)
190
+ }
191
+ if (local.disableTransitionOnChange) {
192
+ restoreTransitions?.()
193
+ restoreTransitions = applyWithoutTransitions(document, apply)
194
+ } else {
195
+ apply()
307
196
  }
308
-
309
- root.classList.toggle('dark', resolvedAppearance() === 'dark')
310
- root.dataset['theme'] = active.id
311
- root.dataset['appearance'] = resolvedAppearance()
312
- root.style.colorScheme = resolvedAppearance()
313
-
314
- if (!accent) root.removeAttribute('data-accent')
315
- else root.dataset['accent'] = selection().accent
316
-
317
- applyAppearanceFontSettings(
318
- root,
319
- normalizeAppearanceEditorFontSettings(selection().fonts, selection().font).settings
320
- )
321
-
322
- // Density is a pure token shift, so unlike the colour axes there is nothing to
323
- // compute: the attribute selects a block in `theme.css`. It is applied here for
324
- // the same reason as the others — this is the one place that knows what the
325
- // current selection is, and it is the only writer of these attributes.
326
- if (resolvedDensity(selection()) === 'comfortable') root.removeAttribute('data-density')
327
- else root.dataset['density'] = resolvedDensity(selection())
328
197
  })
198
+ onCleanup(() => restoreTransitions?.())
329
199
 
330
200
  const themeAccents = createMemo(() =>
331
201
  themeAccentsFor(selection().lightThemeId, selection().darkThemeId)
@@ -337,19 +207,22 @@ export function ThemeProvider(props: ThemeProviderProps) {
337
207
  variant,
338
208
  themes: builtinThemes,
339
209
  themeAccents,
210
+ allThemes: registry,
211
+ preview(next) {
212
+ setDraft(next ? { ...next } : undefined)
213
+ },
214
+ previewing: () => draft() !== undefined,
340
215
  setSelection(patch) {
341
216
  const next = mergeThemeSelection(selection(), patch)
342
217
  setSelectionState(next)
343
- try {
344
- preferenceStore()?.setItem(storageKey(), JSON.stringify(next))
345
- } catch {
346
- // A full, blocked or unavailable store must not break the switch: the
347
- // preference simply does not survive the reload.
348
- }
218
+ // The committed selection is now what should be showing.
219
+ setDraft(undefined)
220
+ writePersisted(persistence, next)
349
221
  },
350
222
  }
351
223
 
352
- return <ThemeContext.Provider value={value}>{local.children}</ThemeContext.Provider>
224
+ const Context = themeContext()
225
+ return <Context.Provider value={value}>{local.children}</Context.Provider>
353
226
  }
354
227
 
355
228
  /**