@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
@@ -36,7 +36,6 @@
36
36
  import {
37
37
  ACCENTS,
38
38
  accentForeground,
39
- accentRoles,
40
39
  chartSeries,
41
40
  getAccent,
42
41
  getTheme,
@@ -57,6 +56,8 @@ import {
57
56
  type ContrastFinding,
58
57
  } from '@adea-ai/themes'
59
58
  import { shadcnVariables } from '@adea-ai/themes/adapters/shadcn'
59
+ import { oklchToHex, parseColor } from '@adea-ai/themes/oklch'
60
+ import { accentOverrideVariables, resolveAccentRoles, type AccentRoles } from './accent-roles'
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))
@@ -448,26 +566,51 @@ export function accentVariables(
448
566
  pair: { lightThemeId: string; darkThemeId: string } = {
449
567
  lightThemeId: defaultLightThemeId,
450
568
  darkThemeId: defaultDarkThemeId,
451
- }
569
+ },
570
+ options: AccentVariableOptions = {}
452
571
  ): Record<string, string> | undefined {
572
+ const roles = accentRolesForSelection(accentId, appearance, pair, options)
573
+ return roles ? accentOverrideVariables(roles) : undefined
574
+ }
575
+
576
+ /** What an accent is held to when it is resolved against a surface. */
577
+ export type AccentVariableOptions = {
578
+ /** The surface the accent must read on: the variant's `colors.background`. */
579
+ background?: string
580
+ /**
581
+ * The contrast floor against `background`. Absent, a preset is painted verbatim
582
+ * and only a custom `#rrggbb` is held to the default floor.
583
+ */
584
+ floor?: number
585
+ /** The variant the accent is applied to, so the tint takes its measured strength. */
586
+ variantId?: string
587
+ }
588
+
589
+ /**
590
+ * An accent selection resolved to the full {@link AccentRoles}, against a theme
591
+ * pair. {@link accentVariables} is the document half of this; an element-scoped
592
+ * paint (`paintAccentRoles`) wants all of it, including the tint.
593
+ */
594
+ export function accentRolesForSelection(
595
+ accentId: string,
596
+ appearance: ThemeAppearance,
597
+ pair: { lightThemeId: string; darkThemeId: string } = {
598
+ lightThemeId: defaultLightThemeId,
599
+ darkThemeId: defaultDarkThemeId,
600
+ },
601
+ options: AccentVariableOptions = {}
602
+ ): AccentRoles | undefined {
453
603
  if (accentId === 'theme') return undefined
454
- const { light, dark } = recordPair(pair.lightThemeId, pair.darkThemeId)
455
- const preset = resolveAccentPreset(accentId, light, dark)
456
- if (!preset) return undefined
457
-
458
- // `--primary-subtle` is deliberately not here. It is a `color-mix()` over
459
- // `var(--primary)`, so the theme's own declaration (`themeCssVariables`, or the
460
- // default block in `theme.css`) already follows the accent; and its strength is the
461
- // *theme's*, measured by `primarySubtleAlpha` to hold body text at 4.5:1 for every
462
- // accent the theme offers. Writing one here would replace that measured strength
463
- // with whatever this function was given, which is how an accent used to undo it.
464
- const roles = accentRoles(preset, appearance)
465
- return {
466
- '--primary': roles.primary,
467
- '--primary-foreground': roles.primaryForeground,
468
- '--primary-hover': roles.primaryHover,
469
- '--ring': roles.ring,
470
- }
604
+ const record = options.variantId ? getTheme(options.variantId) : undefined
605
+ return resolveAccentRoles(accentId, {
606
+ target: record ?? appearance,
607
+ background: options.background,
608
+ floor: options.floor,
609
+ // Only an `ansi-*` id needs the pair's offering, and measuring it is not free.
610
+ themeAccents: isThemeAccentId(accentId)
611
+ ? themeAccentsFor(pair.lightThemeId, pair.darkThemeId)
612
+ : undefined,
613
+ })
471
614
  }
472
615
 
473
616
  /**
@@ -482,7 +625,7 @@ export function themeCssVariables(theme: ThemeVariant): Record<string, string> {
482
625
  const c = theme.colors
483
626
  const t = theme.terminal
484
627
  const e = theme.editor
485
- return {
628
+ const derived = {
486
629
  '--background': c.background,
487
630
  '--foreground': c.foreground,
488
631
  '--card': c.card,
@@ -587,6 +730,29 @@ export function themeCssVariables(theme: ThemeVariant): Record<string, string> {
587
730
  '--chart-5': theme.chart[4] ?? c.destructive,
588
731
  '--chart-6': theme.chart[5] ?? c.mutedForeground,
589
732
  }
733
+ return theme.tokens ? { ...derived, ...theme.tokens } : derived
734
+ }
735
+
736
+ /**
737
+ * Token values with every `oklch()` colour written as `#rrggbb`.
738
+ *
739
+ * For a host whose stylesheet, tests or native shell speak hex: the catalogue's
740
+ * roles are `oklch()` strings, and this is the one conversion, gamut-mapped by the
741
+ * catalogue's own `oklchToHex`. A value that is already hex, carries alpha
742
+ * (`rgba()`), or is not a plain colour (`color-mix()`, `var()`) is left byte for
743
+ * byte, because converting would either lose the alpha or be a guess. Suitable as
744
+ * `ThemeProvider`'s `transformTokens`.
745
+ */
746
+ export function tokensAsHex(tokens: Readonly<Record<string, string>>): Record<string, string> {
747
+ const converted: Record<string, string> = {}
748
+ for (const [name, value] of Object.entries(tokens)) {
749
+ const trimmed = value.trim()
750
+ // `parseOklch` discards alpha, so a translucent oklch() stays as written.
751
+ const opaque = trimmed.startsWith('oklch(') && !trimmed.includes('/')
752
+ const parsed = opaque ? parseColor(trimmed) : undefined
753
+ converted[name] = parsed ? oklchToHex(parsed) : value
754
+ }
755
+ return converted
590
756
  }
591
757
 
592
758
  /* ---------------------------------------------------------------------------
@@ -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.1",
3
+ "version": "0.125.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
@@ -2527,11 +2527,26 @@
2527
2527
  "type": "registry:ui",
2528
2528
  "target": "components/theming/index.ts"
2529
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
+ },
2530
2540
  {
2531
2541
  "path": "src/components/theme/theme-mode-toggle.tsx",
2532
2542
  "type": "registry:ui",
2533
2543
  "target": "components/theming/theme-mode-toggle.tsx"
2534
2544
  },
2545
+ {
2546
+ "path": "src/components/theme/theme-persistence.ts",
2547
+ "type": "registry:ui",
2548
+ "target": "components/theming/theme-persistence.ts"
2549
+ },
2535
2550
  {
2536
2551
  "path": "src/components/theme/theme-picker.tsx",
2537
2552
  "type": "registry:ui",
@@ -2542,6 +2557,11 @@
2542
2557
  "type": "registry:ui",
2543
2558
  "target": "components/theming/theme-provider.tsx"
2544
2559
  },
2560
+ {
2561
+ "path": "src/components/theme/theme-state.ts",
2562
+ "type": "registry:ui",
2563
+ "target": "components/theming/theme-state.ts"
2564
+ },
2545
2565
  {
2546
2566
  "path": "src/components/theme/theme-toggle.tsx",
2547
2567
  "type": "registry:ui",
@@ -2624,6 +2644,11 @@
2624
2644
  ],
2625
2645
  "registryDependencies": [],
2626
2646
  "files": [
2647
+ {
2648
+ "path": "src/lib/accent-roles.ts",
2649
+ "type": "registry:lib",
2650
+ "target": "lib/accent-roles.ts"
2651
+ },
2627
2652
  {
2628
2653
  "path": "src/lib/appearance-font-settings.ts",
2629
2654
  "type": "registry:lib",
@@ -11,11 +11,14 @@ export {
11
11
  ThemeProvider,
12
12
  defaultThemeSelection,
13
13
  inlineScriptLiteral,
14
+ localStoragePersistence,
14
15
  resolvedDensity,
15
16
  themeScript,
17
+ useOptionalTheme,
16
18
  useTheme,
17
19
  type ThemeContextValue,
18
20
  type ThemeDensity,
21
+ type ThemePersistence,
19
22
  type ThemeProviderProps,
20
23
  type ThemeSelection,
21
24
  } from './theme-provider'
@@ -0,0 +1,43 @@
1
+ import { createContext, useContext, type Context } from 'solid-js'
2
+ import type { ThemeContextValue } from './theme-state'
3
+
4
+ /**
5
+ * Where the context object lives: on the global, under a registered symbol.
6
+ *
7
+ * A context is an object identity, and a bundle can legitimately load this module
8
+ * twice — a dev graph where a relative import and a package subpath are distinct
9
+ * module records, or two copies of the package at different versions. Each copy
10
+ * would then call `createContext` and own its own object; a provider from one and a
11
+ * `useOptionalTheme()` from the other would never meet, and the second would return
12
+ * `undefined` as though no provider were mounted. `Symbol.for` is shared across
13
+ * every copy in a realm, so they all find the one context the first created.
14
+ *
15
+ * Both copies must agree on its shape. The context value only ever grows, which is
16
+ * why a newer provider under an older reader is safe and the reverse is a missing
17
+ * member, not a mismatch.
18
+ */
19
+ const CONTEXT_KEY = Symbol.for('adea.ui.theme')
20
+
21
+ type ContextHost = { [CONTEXT_KEY]?: Context<ThemeContextValue | undefined> }
22
+
23
+ export function themeContext(): Context<ThemeContextValue | undefined> {
24
+ const host = globalThis as ContextHost
25
+ return (host[CONTEXT_KEY] ??= createContext<ThemeContextValue>())
26
+ }
27
+
28
+ export function useTheme(): ThemeContextValue {
29
+ const value = useContext(themeContext())
30
+ if (!value) throw new Error('useTheme must be called inside a <ThemeProvider>.')
31
+ return value
32
+ }
33
+
34
+ /**
35
+ * The theme context, or `undefined` outside a `ThemeProvider`.
36
+ *
37
+ * For a component that can run both ways — a shared control rendered in a harness,
38
+ * a story, or a host that has not mounted a provider — and would rather degrade
39
+ * than throw.
40
+ */
41
+ export function useOptionalTheme(): ThemeContextValue | undefined {
42
+ return useContext(themeContext())
43
+ }