@adea-ai/ui 0.123.1 → 0.125.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 (64) hide show
  1. package/dist/NOTICE +36 -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 +82 -0
  11. package/dist/components/theme/theme-document.d.ts.map +1 -0
  12. package/dist/components/theme/theme-document.js +98 -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 +87 -0
  17. package/dist/components/theme/theme-persistence.js.map +1 -0
  18. package/dist/components/theme/theme-provider.d.ts +52 -75
  19. package/dist/components/theme/theme-provider.d.ts.map +1 -1
  20. package/dist/components/theme/theme-provider.js +60 -116
  21. package/dist/components/theme/theme-provider.js.map +1 -1
  22. package/dist/components/theme/theme-state.d.ts +167 -0
  23. package/dist/components/theme/theme-state.d.ts.map +1 -0
  24. package/dist/components/theme/theme-state.js +122 -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 +2 -1
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +7 -3
  30. package/dist/lib/accent-roles.d.ts +124 -0
  31. package/dist/lib/accent-roles.d.ts.map +1 -0
  32. package/dist/lib/accent-roles.js +163 -0
  33. package/dist/lib/accent-roles.js.map +1 -0
  34. package/dist/lib/appearance-font-settings.d.ts +1 -1
  35. package/dist/lib/appearance-font-settings.d.ts.map +1 -1
  36. package/dist/lib/appearance-font-settings.js.map +1 -1
  37. package/dist/lib/themes.d.ts +71 -1
  38. package/dist/lib/themes.d.ts.map +1 -1
  39. package/dist/lib/themes.js +172 -48
  40. package/dist/lib/themes.js.map +1 -1
  41. package/dist/r/lib.json +5 -0
  42. package/dist/r/registry.json +25 -0
  43. package/dist/r/src/components/theme/index.ts +3 -0
  44. package/dist/r/src/components/theme/theme-context.ts +43 -0
  45. package/dist/r/src/components/theme/theme-document.ts +184 -0
  46. package/dist/r/src/components/theme/theme-persistence.ts +143 -0
  47. package/dist/r/src/components/theme/theme-provider.tsx +147 -253
  48. package/dist/r/src/components/theme/theme-state.ts +274 -0
  49. package/dist/r/src/lib/accent-roles.ts +338 -0
  50. package/dist/r/src/lib/appearance-font-settings.ts +1 -1
  51. package/dist/r/src/lib/themes.ts +225 -59
  52. package/dist/r/theming.json +20 -0
  53. package/package.json +1 -1
  54. package/registry.json +25 -0
  55. package/src/components/theme/index.ts +3 -0
  56. package/src/components/theme/theme-context.ts +43 -0
  57. package/src/components/theme/theme-document.ts +184 -0
  58. package/src/components/theme/theme-persistence.ts +143 -0
  59. package/src/components/theme/theme-provider.tsx +147 -253
  60. package/src/components/theme/theme-state.ts +274 -0
  61. package/src/index.ts +11 -0
  62. package/src/lib/accent-roles.ts +338 -0
  63. package/src/lib/appearance-font-settings.ts +1 -1
  64. package/src/lib/themes.ts +225 -59
@@ -1,35 +1,40 @@
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
+ accentRolesForState,
21
+ initialThemeSelection,
22
+ mergeRegistry,
23
+ mergeThemeSelection,
24
+ resolveThemeState,
25
+ type ThemeContextValue,
26
+ type ThemeSelection,
27
+ } from './theme-state'
28
+
29
+ export { useOptionalTheme, useTheme } from './theme-context'
30
+ export { localStoragePersistence, type ThemePersistence } from './theme-persistence'
31
+ export {
32
+ defaultThemeSelection,
33
+ resolvedDensity,
34
+ type ThemeContextValue,
35
+ type ThemeDensity,
36
+ type ThemeSelection,
37
+ } from './theme-state'
33
38
 
34
39
  /**
35
40
  * ThemeProvider.
@@ -39,19 +44,17 @@ import {
39
44
  *
40
45
  * ## What a preference is
41
46
  *
42
- * Four independent choices, and keeping them independent is the point:
47
+ * Four independent choices (`ThemeSelection`), and keeping them independent is the
48
+ * point: appearance, a theme per appearance, an accent and fonts. They compose: a
49
+ * user who wants Nord with a violet accent and the system font should not need
50
+ * someone to author that combination.
43
51
  *
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.
52
+ * ## Where the rules live
51
53
  *
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.
54
+ * This component is wiring. What a selection merges to and resolves to is
55
+ * `theme-state.ts`; what lands on `<html>` is `theme-document.ts`; where the
56
+ * preference is kept is `theme-persistence.ts`. Each is plain code a test can call,
57
+ * which is the reason none of it is inlined here.
55
58
  *
56
59
  * ## Why the variants are applied as inline properties
57
60
  *
@@ -62,167 +65,6 @@ import {
62
65
  * stylesheet. `theme.css` keeps the default theme's values so the first paint is
63
66
  * correct before any script runs.
64
67
  */
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
68
  export type ThemeProviderProps = {
227
69
  children?: JSX.Element
228
70
  /** Where the preference is persisted. Distinct per application. */
@@ -242,14 +84,70 @@ export type ThemeProviderProps = {
242
84
  * seeds from `initial`, and persists every `setSelection`.
243
85
  */
244
86
  selection?: Partial<ThemeSelection>
87
+ /**
88
+ * Where the preference is kept, in place of `localStorage` under `storageKey`.
89
+ *
90
+ * Read once when the provider is created, so changing the prop later does not
91
+ * move the store. `storageKey` is ignored when this is given.
92
+ */
93
+ persistence?: ThemePersistence
94
+ /**
95
+ * Variants the host adds to the built-in catalogue — themes a user imported.
96
+ *
97
+ * A function is read inside the provider's reactive scope, so one that reads a
98
+ * signal keeps the picker and the document current when the set changes. A
99
+ * stored id that names no variant, built in or added, paints the default of its
100
+ * appearance and stays in the selection untouched.
101
+ */
102
+ themes?: readonly ThemeVariant[] | (() => readonly ThemeVariant[])
103
+ /**
104
+ * Rewrites the custom properties of each pass before they are written to
105
+ * `<html>`: the variant's roles with the accent over them. `tokensAsHex` from
106
+ * `lib/themes` is the one the system ships.
107
+ */
108
+ transformTokens?: (tokens: Record<string, string>) => Record<string, string>
109
+ /**
110
+ * Switch CSS transitions off for the frame that carries a change, so a theme
111
+ * switch cannot animate the whole page. Off by default: it adds a `<style>`
112
+ * element to `<head>` for two frames on every change.
113
+ */
114
+ disableTransitionOnChange?: boolean
115
+ /**
116
+ * The contrast ratio an accent is held to against the variant's background,
117
+ * repaired to the nearest passing colour when it falls short. Absent, presets
118
+ * are painted verbatim — the catalogue measured them for its own themes — and
119
+ * only a custom `#rrggbb` is held to 3:1. A host that adds themes the catalogue
120
+ * never measured passes 3 to hold the presets to it as well; for the catalogue's
121
+ * own themes the floor changes no preset.
122
+ */
123
+ accentContrastFloor?: number
245
124
  }
246
125
 
247
126
  export function ThemeProvider(props: ThemeProviderProps) {
248
- const [local] = splitProps(props, ['children', 'storageKey', 'initial', 'selection'])
127
+ const [local] = splitProps(props, [
128
+ 'children',
129
+ 'storageKey',
130
+ 'initial',
131
+ 'selection',
132
+ 'persistence',
133
+ 'themes',
134
+ 'transformTokens',
135
+ 'disableTransitionOnChange',
136
+ 'accentContrastFloor',
137
+ ])
249
138
  const storageKey = () => local.storageKey ?? 'adea-appearance'
139
+ const persistence = local.persistence ?? localStoragePersistence(storageKey)
140
+
141
+ // Read before the first resolution and before the store is, so a persistence whose
142
+ // answer depends on the extras (an id it only accepts if the theme exists) sees
143
+ // them.
144
+ const extraThemes = createMemo(() =>
145
+ typeof local.themes === 'function' ? local.themes() : (local.themes ?? [])
146
+ )
147
+ const registry = createMemo(() => mergeRegistry(builtinThemes, extraThemes()))
250
148
 
251
149
  const [selection, setSelectionState] = createSignal<ThemeSelection>(
252
- mergeThemeSelection(readSelection(storageKey()), local.initial ?? {})
150
+ initialThemeSelection(readPersisted(persistence), local.initial)
253
151
  )
254
152
 
255
153
  // The controlled half of the contract. A patch from the owner replaces the
@@ -272,60 +170,46 @@ export function ThemeProvider(props: ThemeProviderProps) {
272
170
  onCleanup(() => query.removeEventListener('change', onChange))
273
171
  }
274
172
 
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]!
173
+ // What the stored preference resolves to. `selection()`, `variant()` and
174
+ // `resolvedAppearance()` answer from this and never from a preview, so a picker
175
+ // that previews a draft still reads back what is committed.
176
+ const committed = createMemo(() => resolveThemeState(selection(), systemAppearance(), registry()))
177
+ const resolvedAppearance = createMemo<ThemeAppearance>(() => committed().appearance)
178
+ const variant = createMemo<ThemeVariant>(() => committed().variant)
179
+
180
+ const [draft, setDraft] = createSignal<Partial<ThemeSelection> | undefined>()
181
+ const shownSelection = createMemo(() => {
182
+ const preview = draft()
183
+ return preview ? mergeThemeSelection(selection(), preview) : selection()
284
184
  })
185
+ const shown = createMemo(() =>
186
+ draft() ? resolveThemeState(shownSelection(), systemAppearance(), registry()) : committed()
187
+ )
285
188
 
189
+ let written: ReadonlySet<string> | undefined
190
+ let restoreTransitions: (() => void) | undefined
286
191
  createEffect(() => {
287
192
  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)
193
+ const state = {
194
+ selection: shownSelection(),
195
+ appearance: shown().appearance,
196
+ variant: shown().variant,
197
+ }
198
+ const options = {
199
+ transformTokens: local.transformTokens,
200
+ accentContrastFloor: local.accentContrastFloor,
201
+ }
202
+ const apply = () => {
203
+ written = applyThemeToRoot(document.documentElement, state, options, written)
204
+ }
205
+ if (local.disableTransitionOnChange) {
206
+ restoreTransitions?.()
207
+ restoreTransitions = applyWithoutTransitions(document, apply)
208
+ } else {
209
+ apply()
307
210
  }
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
211
  })
212
+ onCleanup(() => restoreTransitions?.())
329
213
 
330
214
  const themeAccents = createMemo(() =>
331
215
  themeAccentsFor(selection().lightThemeId, selection().darkThemeId)
@@ -337,19 +221,29 @@ export function ThemeProvider(props: ThemeProviderProps) {
337
221
  variant,
338
222
  themes: builtinThemes,
339
223
  themeAccents,
224
+ allThemes: registry,
225
+ preview(next) {
226
+ setDraft(next ? { ...next } : undefined)
227
+ },
228
+ previewing: () => draft() !== undefined,
229
+ accentRolesFor: (accent, variantId) =>
230
+ accentRolesForState(accent, variantId, {
231
+ selection: selection(),
232
+ variant: variant(),
233
+ registry: registry(),
234
+ accentContrastFloor: local.accentContrastFloor,
235
+ }),
340
236
  setSelection(patch) {
341
237
  const next = mergeThemeSelection(selection(), patch)
342
238
  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
- }
239
+ // The committed selection is now what should be showing.
240
+ setDraft(undefined)
241
+ writePersisted(persistence, next)
349
242
  },
350
243
  }
351
244
 
352
- return <ThemeContext.Provider value={value}>{local.children}</ThemeContext.Provider>
245
+ const Context = themeContext()
246
+ return <Context.Provider value={value}>{local.children}</Context.Provider>
353
247
  }
354
248
 
355
249
  /**