@adea-ai/ui 0.123.0 → 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 (90) hide show
  1. package/dist/NOTICE +18 -0
  2. package/dist/components/layout/split-layout/resize-controller-core-retry.d.ts +13 -0
  3. package/dist/components/layout/split-layout/resize-controller-core-retry.d.ts.map +1 -0
  4. package/dist/components/layout/split-layout/resize-controller-core-retry.js +63 -0
  5. package/dist/components/layout/split-layout/resize-controller-core-retry.js.map +1 -0
  6. package/dist/components/layout/split-layout/resize-controller-core.d.ts +13 -0
  7. package/dist/components/layout/split-layout/resize-controller-core.d.ts.map +1 -0
  8. package/dist/components/layout/split-layout/resize-controller-core.js +63 -0
  9. package/dist/components/layout/split-layout/resize-controller-core.js.map +1 -0
  10. package/dist/components/layout/split-layout/resize-controller-retry-2.d.ts +3 -0
  11. package/dist/components/layout/split-layout/resize-controller-retry-2.d.ts.map +1 -0
  12. package/dist/components/layout/split-layout/resize-controller-retry-2.js +2 -0
  13. package/dist/components/layout/split-layout/resize-controller-retry.d.ts +2 -0
  14. package/dist/components/layout/split-layout/resize-controller-retry.d.ts.map +1 -0
  15. package/dist/components/layout/split-layout/resize-controller-retry.js +2 -0
  16. package/dist/components/layout/split-layout/resize-controller.d.ts +2 -0
  17. package/dist/components/layout/split-layout/resize-controller.d.ts.map +1 -0
  18. package/dist/components/layout/split-layout/resize-controller.js +2 -0
  19. package/dist/components/layout/split-layout/split-layout.d.ts.map +1 -1
  20. package/dist/components/layout/split-layout/split-layout.js +171 -60
  21. package/dist/components/layout/split-layout/split-layout.js.map +1 -1
  22. package/dist/components/theme/appearance-panel.js +1 -1
  23. package/dist/components/theme/index.d.ts +1 -1
  24. package/dist/components/theme/index.d.ts.map +1 -1
  25. package/dist/components/theme/index.js +5 -2
  26. package/dist/components/theme/theme-context.d.ts +13 -0
  27. package/dist/components/theme/theme-context.d.ts.map +1 -0
  28. package/dist/components/theme/theme-context.js +41 -0
  29. package/dist/components/theme/theme-context.js.map +1 -0
  30. package/dist/components/theme/theme-document.d.ts +76 -0
  31. package/dist/components/theme/theme-document.d.ts.map +1 -0
  32. package/dist/components/theme/theme-document.js +93 -0
  33. package/dist/components/theme/theme-document.js.map +1 -0
  34. package/dist/components/theme/theme-persistence.d.ts +50 -0
  35. package/dist/components/theme/theme-persistence.d.ts.map +1 -0
  36. package/dist/components/theme/theme-persistence.js +86 -0
  37. package/dist/components/theme/theme-persistence.js.map +1 -0
  38. package/dist/components/theme/theme-provider.d.ts +43 -75
  39. package/dist/components/theme/theme-provider.d.ts.map +1 -1
  40. package/dist/components/theme/theme-provider.js +50 -116
  41. package/dist/components/theme/theme-provider.js.map +1 -1
  42. package/dist/components/theme/theme-state.d.ts +142 -0
  43. package/dist/components/theme/theme-state.d.ts.map +1 -0
  44. package/dist/components/theme/theme-state.js +104 -0
  45. package/dist/components/theme/theme-state.js.map +1 -0
  46. package/dist/components/theme/theme-toggle.js +1 -1
  47. package/dist/index.d.ts +1 -1
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +6 -3
  50. package/dist/lib/appearance-font-settings.d.ts +1 -1
  51. package/dist/lib/appearance-font-settings.d.ts.map +1 -1
  52. package/dist/lib/appearance-font-settings.js.map +1 -1
  53. package/dist/lib/themes.d.ts +48 -0
  54. package/dist/lib/themes.d.ts.map +1 -1
  55. package/dist/lib/themes.js +150 -36
  56. package/dist/lib/themes.js.map +1 -1
  57. package/dist/r/registry.json +46 -0
  58. package/dist/r/split-layout.json +26 -0
  59. package/dist/r/src/components/layout/split-layout/resize-controller-core-retry.tsx +66 -0
  60. package/dist/r/src/components/layout/split-layout/resize-controller-core.tsx +66 -0
  61. package/dist/r/src/components/layout/split-layout/resize-controller-retry-2.tsx +2 -0
  62. package/dist/r/src/components/layout/split-layout/resize-controller-retry.tsx +1 -0
  63. package/dist/r/src/components/layout/split-layout/resize-controller.tsx +1 -0
  64. package/dist/r/src/components/layout/split-layout/split-layout.tsx +197 -49
  65. package/dist/r/src/components/theme/index.ts +3 -0
  66. package/dist/r/src/components/theme/theme-context.ts +43 -0
  67. package/dist/r/src/components/theme/theme-document.ts +168 -0
  68. package/dist/r/src/components/theme/theme-persistence.ts +140 -0
  69. package/dist/r/src/components/theme/theme-provider.tsx +126 -253
  70. package/dist/r/src/components/theme/theme-state.ts +225 -0
  71. package/dist/r/src/lib/appearance-font-settings.ts +1 -1
  72. package/dist/r/src/lib/themes.ts +181 -40
  73. package/dist/r/theming.json +20 -0
  74. package/package.json +1 -1
  75. package/registry.json +46 -0
  76. package/src/components/layout/split-layout/resize-controller-core-retry.tsx +66 -0
  77. package/src/components/layout/split-layout/resize-controller-core.tsx +66 -0
  78. package/src/components/layout/split-layout/resize-controller-retry-2.tsx +2 -0
  79. package/src/components/layout/split-layout/resize-controller-retry.tsx +1 -0
  80. package/src/components/layout/split-layout/resize-controller.tsx +1 -0
  81. package/src/components/layout/split-layout/split-layout.tsx +197 -49
  82. package/src/components/theme/index.ts +3 -0
  83. package/src/components/theme/theme-context.ts +43 -0
  84. package/src/components/theme/theme-document.ts +168 -0
  85. package/src/components/theme/theme-persistence.ts +140 -0
  86. package/src/components/theme/theme-provider.tsx +126 -253
  87. package/src/components/theme/theme-state.ts +225 -0
  88. package/src/index.ts +3 -0
  89. package/src/lib/appearance-font-settings.ts +1 -1
  90. package/src/lib/themes.ts +181 -40
@@ -0,0 +1,225 @@
1
+ import {
2
+ DEFAULT_APPEARANCE_EDITOR_FONT_SETTINGS,
3
+ normalizeAppearanceEditorFontSettings,
4
+ type AppearanceEditorFontSettings,
5
+ } from '../../lib/appearance-font-settings'
6
+ import type { AccentPreset } from '@adea-ai/themes'
7
+ import {
8
+ defaultDarkThemeId,
9
+ defaultLightThemeId,
10
+ resolveVariant,
11
+ type ThemeAppearance,
12
+ type ThemeVariant,
13
+ } from '../../lib/themes'
14
+
15
+ /**
16
+ * The pure half of the theme provider: what a selection is, how two selections
17
+ * merge, and what a selection resolves to.
18
+ *
19
+ * Nothing here touches the document, storage or a signal, so each rule can be
20
+ * tested by calling it. `ThemeProvider` owns the reactivity and wires these to it;
21
+ * `theme-document.ts` owns the writes; `theme-persistence.ts` owns the store.
22
+ */
23
+
24
+ /**
25
+ * Four independent choices, and keeping them independent is the point:
26
+ *
27
+ * - **appearance** — light, dark, or follow the system.
28
+ * - **a theme per appearance** — so a user can run Catppuccin Mocha by night and
29
+ * Solarized Light by day without re-choosing when the system flips.
30
+ * - **an accent** — adea's six presets, or one of the accents the selected theme
31
+ * pair carries itself (`ansi-blue`…), which re-colour the interactive roles on
32
+ * top of whichever theme is active.
33
+ * - **fonts** — separate family and size choices for UI, content and code.
34
+ */
35
+ export type ThemeSelection = {
36
+ /** `system` follows `prefers-color-scheme` and updates when it changes. */
37
+ appearance: ThemeAppearance | 'system'
38
+ /** The variant to use when the resolved appearance is light. */
39
+ lightThemeId: string
40
+ /** The variant to use when the resolved appearance is dark. */
41
+ darkThemeId: string
42
+ /**
43
+ * An `accentPresets` id, a theme accent id from `themeAccents()` (`ansi-blue`…),
44
+ * or `theme` for the variant's own primary. A theme accent the current pair does
45
+ * not offer falls back to the variant's own primary.
46
+ */
47
+ accent: string
48
+ /** UI-family alias for consumers of the original single-font preference. */
49
+ font: string
50
+ /** UI, content and code font settings. Omitted legacy records are migrated from `font`. */
51
+ fonts?: AppearanceEditorFontSettings
52
+ /**
53
+ * `comfortable` is the system's default geometry; `compact` moves the control
54
+ * ladder down one rung.
55
+ *
56
+ * Optional, and defaulted on read, so a preference stored before density existed
57
+ * keeps working and `ThemeSelection` stays assignable without it.
58
+ */
59
+ density?: ThemeDensity
60
+ }
61
+
62
+ /**
63
+ * The two density rungs.
64
+ *
65
+ * `comfortable` is the geometry in `theme.css`, so it is the absence of an
66
+ * attribute rather than a value: a document that ships no `data-density` at all
67
+ * renders correctly, which is what keeps this additive.
68
+ */
69
+ export type ThemeDensity = 'comfortable' | 'compact'
70
+
71
+ /**
72
+ * Deliberately lists only the fields every consumer has always had. A field added
73
+ * to {@link ThemeSelection} later gets no default here: `JSON.stringify` drops an
74
+ * `undefined`, so a store written by a consumer that never sets the field stays
75
+ * byte-identical to what it was before the field existed.
76
+ */
77
+ export const defaultThemeSelection: ThemeSelection = {
78
+ appearance: 'system',
79
+ lightThemeId: defaultLightThemeId,
80
+ darkThemeId: defaultDarkThemeId,
81
+ accent: 'theme',
82
+ font: 'system',
83
+ fonts: DEFAULT_APPEARANCE_EDITOR_FONT_SETTINGS,
84
+ density: 'comfortable',
85
+ }
86
+
87
+ /**
88
+ * A stored selection's density, defaulted.
89
+ *
90
+ * The stored value is read through here rather than trusted, because a preference
91
+ * written before density existed has no `density` key and a hand-edited store may
92
+ * hold anything.
93
+ */
94
+ export function resolvedDensity(selection: ThemeSelection): ThemeDensity {
95
+ return selection.density === 'compact' ? 'compact' : 'comfortable'
96
+ }
97
+
98
+ export type ThemeContextValue = {
99
+ /** The preference as stored. A {@link ThemeContextValue.preview} does not change it. */
100
+ selection: () => ThemeSelection
101
+ /** What the preference resolves to right now, after `system`. */
102
+ resolvedAppearance: () => ThemeAppearance
103
+ /** The variant in use. */
104
+ variant: () => ThemeVariant
105
+ /** Update part of the preference. */
106
+ setSelection: (patch: Partial<ThemeSelection>) => void
107
+ /**
108
+ * The built-in catalogue, so a picker does not import it separately. A plain
109
+ * array: {@link ThemeContextValue.allThemes} is the one that follows a host's
110
+ * extra themes.
111
+ */
112
+ themes: readonly ThemeVariant[]
113
+ /**
114
+ * The accents the selected light/dark pair carries, offered beside the presets.
115
+ * Follows the selection: a different theme pair may offer different slots.
116
+ */
117
+ themeAccents: () => readonly AccentPreset[]
118
+ /**
119
+ * The built-in catalogue followed by the provider's `themes` prop. Reactive when
120
+ * that prop is a function that reads signals.
121
+ */
122
+ allThemes: () => readonly ThemeVariant[]
123
+ /**
124
+ * Paint the document from `draft` laid over the stored selection, without
125
+ * persisting it or changing what {@link ThemeContextValue.selection} returns.
126
+ * `undefined` ends the preview. A {@link ThemeContextValue.setSelection} ends it
127
+ * too, because the committed selection is then what should be showing.
128
+ */
129
+ preview: (draft: Partial<ThemeSelection> | undefined) => void
130
+ /** True while a {@link ThemeContextValue.preview} draft is on the document. */
131
+ previewing: () => boolean
132
+ }
133
+
134
+ /** A selection with the `undefined` keys of a patch left out. */
135
+ function definedOnly<T extends object>(value: T): Partial<T> {
136
+ return Object.fromEntries(
137
+ Object.entries(value).filter(([, entry]) => entry !== undefined)
138
+ ) as Partial<T>
139
+ }
140
+
141
+ /** Keep the deprecated single-font view synchronized with the UI axis. */
142
+ export function mergeThemeSelection(
143
+ base: ThemeSelection,
144
+ patch: Partial<ThemeSelection>
145
+ ): ThemeSelection {
146
+ const merged = { ...base, ...patch }
147
+ let fonts: AppearanceEditorFontSettings
148
+
149
+ if (patch.fonts !== undefined) {
150
+ fonts = normalizeAppearanceEditorFontSettings(patch.fonts).settings
151
+ } else if (patch.font !== undefined) {
152
+ const currentFonts = normalizeAppearanceEditorFontSettings(base.fonts, base.font).settings
153
+ fonts = normalizeAppearanceEditorFontSettings({
154
+ ...currentFonts,
155
+ ui: { ...currentFonts.ui, family: patch.font },
156
+ }).settings
157
+ } else {
158
+ fonts = normalizeAppearanceEditorFontSettings(merged.fonts, merged.font).settings
159
+ }
160
+
161
+ return { ...merged, fonts, font: fonts.ui.family }
162
+ }
163
+
164
+ /**
165
+ * The selection a provider starts from: the defaults, then whatever the store
166
+ * returned, then `initial`.
167
+ *
168
+ * `initial` wins over the store on every mount. That precedence is part of the
169
+ * contract Cortana relies on (its `initial` pins `appearance` and `font`), and it
170
+ * is why the store's answer is only the middle layer. A store that returned
171
+ * nothing, or threw, or answered with explicit `undefined`s, leaves the defaults
172
+ * standing for those fields.
173
+ */
174
+ export function initialThemeSelection(
175
+ stored: Partial<ThemeSelection> | undefined,
176
+ initial: Partial<ThemeSelection> | undefined
177
+ ): ThemeSelection {
178
+ const base = mergeThemeSelection(defaultThemeSelection, definedOnly(stored ?? {}))
179
+ return mergeThemeSelection(base, initial ?? {})
180
+ }
181
+
182
+ /** `system` resolved against what the operating system reports. */
183
+ export function resolveAppearance(
184
+ preference: ThemeSelection['appearance'],
185
+ system: ThemeAppearance
186
+ ): ThemeAppearance {
187
+ return preference === 'system' ? system : preference
188
+ }
189
+
190
+ /** The theme id the selection names for a resolved appearance. */
191
+ export function selectedThemeId(selection: ThemeSelection, appearance: ThemeAppearance): string {
192
+ return appearance === 'dark' ? selection.darkThemeId : selection.lightThemeId
193
+ }
194
+
195
+ /**
196
+ * What a selection paints: the resolved appearance and the variant for it.
197
+ *
198
+ * An id the registry does not hold resolves to the appearance's default rather than
199
+ * being rewritten (see `resolveVariant`), so the selection a host stores is never
200
+ * changed by being unresolvable.
201
+ */
202
+ export function resolveThemeState(
203
+ selection: ThemeSelection,
204
+ system: ThemeAppearance,
205
+ registry: readonly ThemeVariant[]
206
+ ): { appearance: ThemeAppearance; variant: ThemeVariant } {
207
+ const appearance = resolveAppearance(selection.appearance, system)
208
+ return {
209
+ appearance,
210
+ variant: resolveVariant(registry, selectedThemeId(selection, appearance), appearance),
211
+ }
212
+ }
213
+
214
+ /**
215
+ * The registry a provider resolves against: the catalogue, then the host's extras.
216
+ *
217
+ * The catalogue wins an id collision, because it is listed first and `resolveVariant`
218
+ * takes the first match: a host's variant can add a theme but cannot shadow one.
219
+ */
220
+ export function mergeRegistry(
221
+ builtin: readonly ThemeVariant[],
222
+ extra: readonly ThemeVariant[] | undefined
223
+ ): readonly ThemeVariant[] {
224
+ return extra && extra.length > 0 ? [...builtin, ...extra] : builtin
225
+ }
@@ -181,7 +181,7 @@ export function normalizeAppearanceEditorFontSettings(
181
181
  * stylesheet and survives CSS import reordering.
182
182
  */
183
183
  export function applyAppearanceFontSettings(
184
- root: HTMLElement,
184
+ root: Pick<HTMLElement, 'removeAttribute' | 'setAttribute' | 'style'>,
185
185
  value: unknown
186
186
  ): NormalizedAppearanceEditorFontSettings {
187
187
  const normalized = normalizeAppearanceEditorFontSettings(value)
@@ -57,6 +57,7 @@ import {
57
57
  type ContrastFinding,
58
58
  } from '@adea-ai/themes'
59
59
  import { shadcnVariables } from '@adea-ai/themes/adapters/shadcn'
60
+ import { oklchToHex, parseColor } from '@adea-ai/themes/oklch'
60
61
  import { isThemeAccentId } from './theme-accents'
61
62
 
62
63
  export { isThemeAccentId } from './theme-accents'
@@ -179,10 +180,24 @@ export type ThemeVariant = {
179
180
  /** The categorical series, in order. */
180
181
  chart: readonly string[]
181
182
  provenance: ThemeProvenance
183
+ /**
184
+ * Custom properties written verbatim over {@link themeCssVariables}, last.
185
+ *
186
+ * For a variant a host built from data it does not control the derivation of —
187
+ * an imported theme whose painted colours must stay exactly what the user
188
+ * imported. The catalogue's own variants never carry it, so the derived roles
189
+ * stay the only source of truth for them.
190
+ */
191
+ tokens?: Readonly<Record<string, string>>
182
192
  }
183
193
 
184
- /** Builds the design system's view of one catalogue entry. */
185
- function toVariant(theme: AdeaThemeRecord): ThemeVariant {
194
+ /**
195
+ * The shadcn-vocabulary roles of one catalogue entry.
196
+ *
197
+ * The expensive half of a variant: `shadcnVariables` projects the whole palette
198
+ * (about 4 ms a theme), which is why {@link lazyVariant} defers it.
199
+ */
200
+ function colorsOf(theme: AdeaThemeRecord): ThemeColors {
186
201
  const projected = shadcnVariables(theme)
187
202
  const token = (name: string): string => {
188
203
  const value = projected[`--${name}`]
@@ -190,7 +205,7 @@ function toVariant(theme: AdeaThemeRecord): ThemeVariant {
190
205
  return value
191
206
  }
192
207
  const destructiveAction = shadcnDestructiveProjection(theme)
193
- const colors: ThemeColors = {
208
+ return {
194
209
  background: token('background'),
195
210
  foreground: token('foreground'),
196
211
  card: token('card'),
@@ -231,8 +246,62 @@ function toVariant(theme: AdeaThemeRecord): ThemeVariant {
231
246
  sidebarBorder: token('sidebar-border'),
232
247
  sidebarMutedForeground: token('sidebar-muted-foreground'),
233
248
  }
249
+ }
250
+
251
+ // The embedded terminal uses the theme's own canvas rather than a palette of its
252
+ // own. The ANSI colours came from the palette that produced the canvas, so they
253
+ // were chosen against it; a terminal on a different background would be the one
254
+ // surface in the application not lit by the active theme.
255
+ function terminalOf(theme: AdeaThemeRecord): ThemeTerminalPalette {
256
+ return {
257
+ background: theme.colors.background,
258
+ foreground: theme.colors.foreground,
259
+ cursor: theme.cursor,
260
+ selection: theme.selection,
261
+ ...theme.ansi,
262
+ }
263
+ }
264
+
265
+ function editorOf(theme: AdeaThemeRecord): ThemeEditorPalette {
234
266
  const syntax = syntaxRoles(theme)
267
+ return {
268
+ keyword: syntax.keyword,
269
+ string: syntax.string,
270
+ number: syntax.number,
271
+ comment: syntax.comment,
272
+ function: syntax.function,
273
+ variable: syntax.variable,
274
+ type: syntax.type,
275
+ tag: syntax.tag,
276
+ attribute: syntax.attribute,
277
+ operator: syntax.operator,
278
+ heading: syntax.heading,
279
+ link: syntax.link,
280
+ diffAdd: syntax.diffAdd,
281
+ diffDelete: syntax.diffDelete,
282
+ diffHunk: syntax.diffHunk,
283
+ searchMatch: syntax.searchMatch,
284
+ }
285
+ }
235
286
 
287
+ function provenanceOf(theme: AdeaThemeRecord): ThemeProvenance {
288
+ return {
289
+ source: theme.provenance.project,
290
+ url: theme.provenance.url,
291
+ license: theme.provenance.license,
292
+ }
293
+ }
294
+
295
+ /**
296
+ * The design system's view of one catalogue record, built in full and at once.
297
+ *
298
+ * For a host that holds a record the catalogue does not — an imported theme, a
299
+ * reconstruction from stored data — and wants a variant it can hand to
300
+ * `ThemeProvider`'s `themes`. It throws for a record the shadcn projection cannot
301
+ * map, here and now, rather than on first paint. The catalogue itself goes through
302
+ * {@link lazyVariant} instead.
303
+ */
304
+ export function variantFromRecord(theme: AdeaThemeRecord): ThemeVariant {
236
305
  return {
237
306
  id: theme.id,
238
307
  family: theme.family,
@@ -240,45 +309,62 @@ function toVariant(theme: AdeaThemeRecord): ThemeVariant {
240
309
  label: theme.label,
241
310
  appearance: theme.appearance,
242
311
  description: theme.description,
243
- colors,
244
- terminal: {
245
- // The embedded terminal uses the theme's own canvas rather than a palette of its
246
- // own. The ANSI colours came from the palette that produced the canvas, so they
247
- // were chosen against it; a terminal on a different background would be the one
248
- // surface in the application not lit by the active theme.
249
- background: theme.colors.background,
250
- foreground: theme.colors.foreground,
251
- cursor: theme.cursor,
252
- selection: theme.selection,
253
- ...theme.ansi,
254
- },
255
- editor: {
256
- keyword: syntax.keyword,
257
- string: syntax.string,
258
- number: syntax.number,
259
- comment: syntax.comment,
260
- function: syntax.function,
261
- variable: syntax.variable,
262
- type: syntax.type,
263
- tag: syntax.tag,
264
- attribute: syntax.attribute,
265
- operator: syntax.operator,
266
- heading: syntax.heading,
267
- link: syntax.link,
268
- diffAdd: syntax.diffAdd,
269
- diffDelete: syntax.diffDelete,
270
- diffHunk: syntax.diffHunk,
271
- searchMatch: syntax.searchMatch,
272
- },
312
+ colors: colorsOf(theme),
313
+ terminal: terminalOf(theme),
314
+ editor: editorOf(theme),
273
315
  chart: chartSeries(theme),
274
- provenance: {
275
- source: theme.provenance.project,
276
- url: theme.provenance.url,
277
- license: theme.provenance.license,
278
- },
316
+ provenance: provenanceOf(theme),
317
+ }
318
+ }
319
+
320
+ /** Runs `build` on first call and returns that value from then on. A throw is not cached. */
321
+ function once<T>(build: () => T): () => T {
322
+ let built = false
323
+ let value: T
324
+ return () => {
325
+ if (!built) {
326
+ value = build()
327
+ built = true
328
+ }
329
+ return value
279
330
  }
280
331
  }
281
332
 
333
+ const lazy = <T>(build: () => T): PropertyDescriptor => ({
334
+ get: once(build),
335
+ enumerable: true,
336
+ })
337
+
338
+ /**
339
+ * {@link variantFromRecord} with the four derived palettes built on first read.
340
+ *
341
+ * Evaluating this module used to project all 34 catalogue themes up front, about
342
+ * 155 ms before a single one was needed, on the hydration path of every consumer.
343
+ * A page paints one variant, and a picker's labels and appearance are plain
344
+ * fields, so only a variant whose `colors`, `terminal`, `editor` or `chart` is read
345
+ * pays for them, once. The properties stay enumerable data-shaped accessors, so
346
+ * spreading, `Object.entries` and JSON see the same object they always did.
347
+ */
348
+ function lazyVariant(theme: AdeaThemeRecord): ThemeVariant {
349
+ return Object.defineProperties(
350
+ {
351
+ id: theme.id,
352
+ family: theme.family,
353
+ familyLabel: theme.familyLabel,
354
+ label: theme.label,
355
+ appearance: theme.appearance,
356
+ description: theme.description,
357
+ provenance: provenanceOf(theme),
358
+ },
359
+ {
360
+ colors: lazy(() => colorsOf(theme)),
361
+ terminal: lazy(() => terminalOf(theme)),
362
+ editor: lazy(() => editorOf(theme)),
363
+ chart: lazy(() => chartSeries(theme)),
364
+ }
365
+ ) as ThemeVariant
366
+ }
367
+
282
368
  /**
283
369
  * A variant's `--primary-subtle`: the tint over `var(--primary)` at the strength its
284
370
  * catalogue record measures (`primarySubtleAlpha`). A variant that is not in the
@@ -290,13 +376,45 @@ export function primarySubtleFor(theme: Pick<ThemeVariant, 'id' | 'appearance'>)
290
376
  }
291
377
 
292
378
  /** The catalogue, in catalogue order. */
293
- export const builtinThemes: readonly ThemeVariant[] = Object.freeze(catalogue.map(toVariant))
379
+ export const builtinThemes: readonly ThemeVariant[] = Object.freeze(catalogue.map(lazyVariant))
294
380
 
295
381
  export function themeById(id: string): ThemeVariant | undefined {
296
382
  if (!hasTheme(id)) return undefined
297
383
  return builtinThemes.find((theme) => theme.id === id)
298
384
  }
299
385
 
386
+ /**
387
+ * The variant a stored id means, or the default of the same appearance.
388
+ *
389
+ * An id the registry does not hold — an imported theme that was deleted, a
390
+ * catalogue entry that was renamed — must still paint *something of the appearance
391
+ * being shown*. Falling back to whichever variant happens to be first in the
392
+ * registry painted a dark palette under a light `color-scheme` for a light id, so
393
+ * the fallback is the appearance's own default, then any variant of that
394
+ * appearance, then the registry's first entry only when it holds nothing else.
395
+ *
396
+ * `registry` is the builtin catalogue plus whatever extras the host supplies; the
397
+ * stored id is never rewritten, only resolved, so a host that keeps an unknown id
398
+ * keeps resolving it if the theme comes back.
399
+ *
400
+ * The fallback order is translated from Zeron's `ThemeRegistry::resolve` (MIT,
401
+ * Copyright (c) 2026 Wing); see NOTICE.
402
+ */
403
+ export function resolveVariant(
404
+ registry: readonly ThemeVariant[],
405
+ id: string,
406
+ appearance: ThemeAppearance
407
+ ): ThemeVariant {
408
+ const found = registry.find((variant) => variant.id === id)
409
+ if (found) return found
410
+ const defaultId = appearance === 'dark' ? defaultDarkThemeId : defaultLightThemeId
411
+ return (
412
+ registry.find((variant) => variant.id === defaultId) ??
413
+ registry.find((variant) => variant.appearance === appearance) ??
414
+ registry[0]!
415
+ )
416
+ }
417
+
300
418
  /** The variants of one appearance, which is what a picker for that mode shows. */
301
419
  export function themesForAppearance(appearance: ThemeAppearance): readonly ThemeVariant[] {
302
420
  const ids = new Set(themesByAppearance(appearance).map((theme) => theme.id))
@@ -482,7 +600,7 @@ export function themeCssVariables(theme: ThemeVariant): Record<string, string> {
482
600
  const c = theme.colors
483
601
  const t = theme.terminal
484
602
  const e = theme.editor
485
- return {
603
+ const derived = {
486
604
  '--background': c.background,
487
605
  '--foreground': c.foreground,
488
606
  '--card': c.card,
@@ -587,6 +705,29 @@ export function themeCssVariables(theme: ThemeVariant): Record<string, string> {
587
705
  '--chart-5': theme.chart[4] ?? c.destructive,
588
706
  '--chart-6': theme.chart[5] ?? c.mutedForeground,
589
707
  }
708
+ return theme.tokens ? { ...derived, ...theme.tokens } : derived
709
+ }
710
+
711
+ /**
712
+ * Token values with every `oklch()` colour written as `#rrggbb`.
713
+ *
714
+ * For a host whose stylesheet, tests or native shell speak hex: the catalogue's
715
+ * roles are `oklch()` strings, and this is the one conversion, gamut-mapped by the
716
+ * catalogue's own `oklchToHex`. A value that is already hex, carries alpha
717
+ * (`rgba()`), or is not a plain colour (`color-mix()`, `var()`) is left byte for
718
+ * byte, because converting would either lose the alpha or be a guess. Suitable as
719
+ * `ThemeProvider`'s `transformTokens`.
720
+ */
721
+ export function tokensAsHex(tokens: Readonly<Record<string, string>>): Record<string, string> {
722
+ const converted: Record<string, string> = {}
723
+ for (const [name, value] of Object.entries(tokens)) {
724
+ const trimmed = value.trim()
725
+ // `parseOklch` discards alpha, so a translucent oklch() stays as written.
726
+ const opaque = trimmed.startsWith('oklch(') && !trimmed.includes('/')
727
+ const parsed = opaque ? parseColor(trimmed) : undefined
728
+ converted[name] = parsed ? oklchToHex(parsed) : value
729
+ }
730
+ return converted
590
731
  }
591
732
 
592
733
  /* ---------------------------------------------------------------------------
@@ -28,11 +28,26 @@
28
28
  "type": "registry:ui",
29
29
  "target": "components/theming/index.ts"
30
30
  },
31
+ {
32
+ "path": "src/components/theme/theme-context.ts",
33
+ "type": "registry:ui",
34
+ "target": "components/theming/theme-context.ts"
35
+ },
36
+ {
37
+ "path": "src/components/theme/theme-document.ts",
38
+ "type": "registry:ui",
39
+ "target": "components/theming/theme-document.ts"
40
+ },
31
41
  {
32
42
  "path": "src/components/theme/theme-mode-toggle.tsx",
33
43
  "type": "registry:ui",
34
44
  "target": "components/theming/theme-mode-toggle.tsx"
35
45
  },
46
+ {
47
+ "path": "src/components/theme/theme-persistence.ts",
48
+ "type": "registry:ui",
49
+ "target": "components/theming/theme-persistence.ts"
50
+ },
36
51
  {
37
52
  "path": "src/components/theme/theme-picker.tsx",
38
53
  "type": "registry:ui",
@@ -43,6 +58,11 @@
43
58
  "type": "registry:ui",
44
59
  "target": "components/theming/theme-provider.tsx"
45
60
  },
61
+ {
62
+ "path": "src/components/theme/theme-state.ts",
63
+ "type": "registry:ui",
64
+ "target": "components/theming/theme-state.ts"
65
+ },
46
66
  {
47
67
  "path": "src/components/theme/theme-toggle.tsx",
48
68
  "type": "registry:ui",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adea-ai/ui",
3
- "version": "0.123.0",
3
+ "version": "0.124.0",
4
4
  "description": "The Adea design system: Kobalte-backed Solid components, semantic OKLCH tokens, and the app-shell layout primitives shared by Adea and Cortana.",
5
5
  "keywords": [
6
6
  "adea",
package/registry.json CHANGED
@@ -1811,6 +1811,7 @@
1811
1811
  ],
1812
1812
  "registryDependencies": [
1813
1813
  "@adea-ai/ui/action-button",
1814
+ "@adea-ai/ui/button",
1814
1815
  "lib"
1815
1816
  ],
1816
1817
  "files": [
@@ -1834,6 +1835,31 @@
1834
1835
  "type": "registry:ui",
1835
1836
  "target": "components/layout/split-layout/model.ts"
1836
1837
  },
1838
+ {
1839
+ "path": "src/components/layout/split-layout/resize-controller-core-retry.tsx",
1840
+ "type": "registry:ui",
1841
+ "target": "components/layout/split-layout/resize-controller-core-retry.tsx"
1842
+ },
1843
+ {
1844
+ "path": "src/components/layout/split-layout/resize-controller-core.tsx",
1845
+ "type": "registry:ui",
1846
+ "target": "components/layout/split-layout/resize-controller-core.tsx"
1847
+ },
1848
+ {
1849
+ "path": "src/components/layout/split-layout/resize-controller-retry-2.tsx",
1850
+ "type": "registry:ui",
1851
+ "target": "components/layout/split-layout/resize-controller-retry-2.tsx"
1852
+ },
1853
+ {
1854
+ "path": "src/components/layout/split-layout/resize-controller-retry.tsx",
1855
+ "type": "registry:ui",
1856
+ "target": "components/layout/split-layout/resize-controller-retry.tsx"
1857
+ },
1858
+ {
1859
+ "path": "src/components/layout/split-layout/resize-controller.tsx",
1860
+ "type": "registry:ui",
1861
+ "target": "components/layout/split-layout/resize-controller.tsx"
1862
+ },
1837
1863
  {
1838
1864
  "path": "src/components/layout/split-layout/split-layout.tsx",
1839
1865
  "type": "registry:ui",
@@ -2501,11 +2527,26 @@
2501
2527
  "type": "registry:ui",
2502
2528
  "target": "components/theming/index.ts"
2503
2529
  },
2530
+ {
2531
+ "path": "src/components/theme/theme-context.ts",
2532
+ "type": "registry:ui",
2533
+ "target": "components/theming/theme-context.ts"
2534
+ },
2535
+ {
2536
+ "path": "src/components/theme/theme-document.ts",
2537
+ "type": "registry:ui",
2538
+ "target": "components/theming/theme-document.ts"
2539
+ },
2504
2540
  {
2505
2541
  "path": "src/components/theme/theme-mode-toggle.tsx",
2506
2542
  "type": "registry:ui",
2507
2543
  "target": "components/theming/theme-mode-toggle.tsx"
2508
2544
  },
2545
+ {
2546
+ "path": "src/components/theme/theme-persistence.ts",
2547
+ "type": "registry:ui",
2548
+ "target": "components/theming/theme-persistence.ts"
2549
+ },
2509
2550
  {
2510
2551
  "path": "src/components/theme/theme-picker.tsx",
2511
2552
  "type": "registry:ui",
@@ -2516,6 +2557,11 @@
2516
2557
  "type": "registry:ui",
2517
2558
  "target": "components/theming/theme-provider.tsx"
2518
2559
  },
2560
+ {
2561
+ "path": "src/components/theme/theme-state.ts",
2562
+ "type": "registry:ui",
2563
+ "target": "components/theming/theme-state.ts"
2564
+ },
2519
2565
  {
2520
2566
  "path": "src/components/theme/theme-toggle.tsx",
2521
2567
  "type": "registry:ui",