@adea-ai/ui 0.125.0 → 0.128.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 (79) hide show
  1. package/dist/NOTICE +40 -0
  2. package/dist/components/theme/index.d.ts +1 -1
  3. package/dist/components/theme/index.d.ts.map +1 -1
  4. package/dist/components/theme/index.js +4 -3
  5. package/dist/components/theme/theme-document.d.ts +37 -2
  6. package/dist/components/theme/theme-document.d.ts.map +1 -1
  7. package/dist/components/theme/theme-document.js +23 -3
  8. package/dist/components/theme/theme-document.js.map +1 -1
  9. package/dist/components/theme/theme-persistence.d.ts +87 -0
  10. package/dist/components/theme/theme-persistence.d.ts.map +1 -1
  11. package/dist/components/theme/theme-persistence.js +63 -2
  12. package/dist/components/theme/theme-persistence.js.map +1 -1
  13. package/dist/components/theme/theme-provider.d.ts +55 -41
  14. package/dist/components/theme/theme-provider.d.ts.map +1 -1
  15. package/dist/components/theme/theme-provider.js +59 -52
  16. package/dist/components/theme/theme-provider.js.map +1 -1
  17. package/dist/components/theme/theme-script.d.ts +100 -0
  18. package/dist/components/theme/theme-script.d.ts.map +1 -0
  19. package/dist/components/theme/theme-script.js +57 -0
  20. package/dist/components/theme/theme-script.js.map +1 -0
  21. package/dist/components/theme/theme-state.d.ts +58 -0
  22. package/dist/components/theme/theme-state.d.ts.map +1 -1
  23. package/dist/components/theme/theme-state.js +22 -1
  24. package/dist/components/theme/theme-state.js.map +1 -1
  25. package/dist/index.d.ts +4 -1
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +8 -4
  28. package/dist/lib/font-zoom.d.ts +25 -0
  29. package/dist/lib/font-zoom.d.ts.map +1 -0
  30. package/dist/lib/font-zoom.js +45 -0
  31. package/dist/lib/font-zoom.js.map +1 -0
  32. package/dist/lib/preference-store.d.ts +126 -0
  33. package/dist/lib/preference-store.d.ts.map +1 -0
  34. package/dist/lib/preference-store.js +126 -0
  35. package/dist/lib/preference-store.js.map +1 -0
  36. package/dist/lib/theme-surface.d.ts +71 -0
  37. package/dist/lib/theme-surface.d.ts.map +1 -0
  38. package/dist/lib/theme-surface.js +62 -0
  39. package/dist/lib/theme-surface.js.map +1 -0
  40. package/dist/lib/themes.d.ts +57 -1
  41. package/dist/lib/themes.d.ts.map +1 -1
  42. package/dist/lib/themes.js +81 -4
  43. package/dist/lib/themes.js.map +1 -1
  44. package/dist/lib/tokens.d.ts.map +1 -1
  45. package/dist/lib/tokens.js +15 -0
  46. package/dist/lib/tokens.js.map +1 -1
  47. package/dist/r/lib.json +15 -0
  48. package/dist/r/registry.json +26 -1
  49. package/dist/r/src/components/theme/index.ts +7 -0
  50. package/dist/r/src/components/theme/theme-document.ts +77 -2
  51. package/dist/r/src/components/theme/theme-persistence.ts +167 -0
  52. package/dist/r/src/components/theme/theme-provider.tsx +132 -50
  53. package/dist/r/src/components/theme/theme-script.ts +201 -0
  54. package/dist/r/src/components/theme/theme-state.ts +72 -0
  55. package/dist/r/src/lib/font-zoom.ts +74 -0
  56. package/dist/r/src/lib/preference-store.ts +216 -0
  57. package/dist/r/src/lib/theme-surface.ts +110 -0
  58. package/dist/r/src/lib/themes.ts +115 -2
  59. package/dist/r/src/lib/tokens.ts +17 -0
  60. package/dist/r/src/styles/theme-variants.css +3010 -0
  61. package/dist/r/src/styles/theme.css +38 -0
  62. package/dist/r/theme.json +6 -1
  63. package/dist/r/theming.json +5 -0
  64. package/package.json +2 -1
  65. package/registry.json +26 -1
  66. package/src/components/theme/index.ts +7 -0
  67. package/src/components/theme/theme-document.ts +77 -2
  68. package/src/components/theme/theme-persistence.ts +167 -0
  69. package/src/components/theme/theme-provider.tsx +132 -50
  70. package/src/components/theme/theme-script.ts +201 -0
  71. package/src/components/theme/theme-state.ts +72 -0
  72. package/src/index.ts +29 -0
  73. package/src/lib/font-zoom.ts +74 -0
  74. package/src/lib/preference-store.ts +216 -0
  75. package/src/lib/theme-surface.ts +110 -0
  76. package/src/lib/themes.ts +115 -2
  77. package/src/lib/tokens.ts +17 -0
  78. package/src/styles/theme-variants.css +3010 -0
  79. package/src/styles/theme.css +38 -0
@@ -637,6 +637,15 @@
637
637
  --z-menu: 140;
638
638
  --z-tooltip: 160;
639
639
  --z-toast: 180;
640
+
641
+ /* Surface glass. `--surface-alpha` is what the provider's surface axis writes: the
642
+ opacity a frosted dialog's fill keeps (1 = opaque, so the default never matches
643
+ the frost rule's blur). `--surface-frost` and `--surface-translucent` are the two
644
+ amounts it chooses between; `lib/theme-surface.ts` writes the same numbers, and
645
+ `tests/theme-surface.test.ts` fails when they disagree. */
646
+ --surface-alpha: 1;
647
+ --surface-frost: 0.92;
648
+ --surface-translucent: 0.8;
640
649
  }
641
650
 
642
651
  /* Compact density: the standard control ladder one rung tighter.
@@ -687,6 +696,35 @@
687
696
  --shadow-xl: 0 20px 44px oklch(0 0 0 / 0.45);
688
697
  }
689
698
 
699
+ /* The OS-level backstop for the surface axis. The provider resolves the effective
700
+ surface and the reduced-transparency policy and writes `--surface-alpha` inline,
701
+ which outranks this rule; this is what keeps a frost amount from outliving the
702
+ system's Reduce Transparency setting for a document the provider has not
703
+ reached yet (a server-rendered one, before hydration). */
704
+ @media (prefers-reduced-transparency: reduce) {
705
+ :root {
706
+ --surface-alpha: 1;
707
+ }
708
+ }
709
+
710
+ /* Tokenized frost: while the resolved surface asks for glass, a modal dialog goes
711
+ translucent and blurs the content behind it. The opaque default and both
712
+ reduced-transparency paths resolve `--surface-alpha` to 1 and never write
713
+ `data-surface='frosted'`, so frost only ever applies when the user chose it and
714
+ the platform can honour it. Native window vibrancy (a desktop shell's translucent
715
+ surface) remains the shell's work; this is the frost the surface preference
716
+ promises everywhere else.
717
+
718
+ Modal dialogs only: a sheet carries `data-variant` and keeps its own bands. */
719
+ [data-surface='frosted'] [role='dialog']:not([data-variant]) {
720
+ background-color: color-mix(
721
+ in srgb,
722
+ var(--background) calc(var(--surface-alpha) * 100%),
723
+ transparent
724
+ );
725
+ backdrop-filter: blur(1.25rem) saturate(140%);
726
+ }
727
+
690
728
  /* ---------------------------------------------------------------------------
691
729
  * 4b. Named CSS hooks
692
730
  *
package/dist/r/theme.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "theme",
3
3
  "type": "registry:style",
4
4
  "title": "Adea theme",
5
- "description": "The design tokens: the semantic palette in OKLCH, the type and control scales, shells geometry, shadows, motion and the z-index stack.",
5
+ "description": "The design tokens: the semantic palette in OKLCH, the type and control scales, shells geometry, shadows, motion and the z-index stack, plus the pre-paint stylesheet that carries every catalogue theme.",
6
6
  "dependencies": [
7
7
  "@fontsource-variable/geist@^5.3.0",
8
8
  "@fontsource-variable/geist-mono@^5.3.0",
@@ -22,6 +22,11 @@
22
22
  "type": "registry:style",
23
23
  "target": "styles/theme.css"
24
24
  },
25
+ {
26
+ "path": "src/styles/theme-variants.css",
27
+ "type": "registry:style",
28
+ "target": "styles/theme-variants.css"
29
+ },
25
30
  {
26
31
  "path": "src/styles/base.css",
27
32
  "type": "registry:style",
@@ -58,6 +58,11 @@
58
58
  "type": "registry:ui",
59
59
  "target": "components/theming/theme-provider.tsx"
60
60
  },
61
+ {
62
+ "path": "src/components/theme/theme-script.ts",
63
+ "type": "registry:ui",
64
+ "target": "components/theming/theme-script.ts"
65
+ },
61
66
  {
62
67
  "path": "src/components/theme/theme-state.ts",
63
68
  "type": "registry:ui",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adea-ai/ui",
3
- "version": "0.125.0",
3
+ "version": "0.128.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",
@@ -68,6 +68,7 @@
68
68
  },
69
69
  "./globals.css": "./src/styles/globals.css",
70
70
  "./theme.css": "./src/styles/theme.css",
71
+ "./theme-variants.css": "./src/styles/theme-variants.css",
71
72
  "./base.css": "./src/styles/base.css",
72
73
  "./appearance-font-settings.css": "./src/styles/appearance-font-settings.css",
73
74
  "./fonts.css": "./src/styles/fonts.css",
package/registry.json CHANGED
@@ -2557,6 +2557,11 @@
2557
2557
  "type": "registry:ui",
2558
2558
  "target": "components/theming/theme-provider.tsx"
2559
2559
  },
2560
+ {
2561
+ "path": "src/components/theme/theme-script.ts",
2562
+ "type": "registry:ui",
2563
+ "target": "components/theming/theme-script.ts"
2564
+ },
2560
2565
  {
2561
2566
  "path": "src/components/theme/theme-state.ts",
2562
2567
  "type": "registry:ui",
@@ -2595,7 +2600,7 @@
2595
2600
  "name": "theme",
2596
2601
  "type": "registry:style",
2597
2602
  "title": "Adea theme",
2598
- "description": "The design tokens: the semantic palette in OKLCH, the type and control scales, shells geometry, shadows, motion and the z-index stack.",
2603
+ "description": "The design tokens: the semantic palette in OKLCH, the type and control scales, shells geometry, shadows, motion and the z-index stack, plus the pre-paint stylesheet that carries every catalogue theme.",
2599
2604
  "dependencies": [
2600
2605
  "@fontsource-variable/geist@^5.3.0",
2601
2606
  "@fontsource-variable/geist-mono@^5.3.0",
@@ -2615,6 +2620,11 @@
2615
2620
  "type": "registry:style",
2616
2621
  "target": "styles/theme.css"
2617
2622
  },
2623
+ {
2624
+ "path": "src/styles/theme-variants.css",
2625
+ "type": "registry:style",
2626
+ "target": "styles/theme-variants.css"
2627
+ },
2618
2628
  {
2619
2629
  "path": "src/styles/base.css",
2620
2630
  "type": "registry:style",
@@ -2674,6 +2684,11 @@
2674
2684
  "type": "registry:lib",
2675
2685
  "target": "lib/font-catalog.ts"
2676
2686
  },
2687
+ {
2688
+ "path": "src/lib/font-zoom.ts",
2689
+ "type": "registry:lib",
2690
+ "target": "lib/font-zoom.ts"
2691
+ },
2677
2692
  {
2678
2693
  "path": "src/lib/form-field.ts",
2679
2694
  "type": "registry:lib",
@@ -2689,6 +2704,11 @@
2689
2704
  "type": "registry:lib",
2690
2705
  "target": "lib/overlay.ts"
2691
2706
  },
2707
+ {
2708
+ "path": "src/lib/preference-store.ts",
2709
+ "type": "registry:lib",
2710
+ "target": "lib/preference-store.ts"
2711
+ },
2692
2712
  {
2693
2713
  "path": "src/lib/safe-script.ts",
2694
2714
  "type": "registry:lib",
@@ -2699,6 +2719,11 @@
2699
2719
  "type": "registry:lib",
2700
2720
  "target": "lib/theme-accents.ts"
2701
2721
  },
2722
+ {
2723
+ "path": "src/lib/theme-surface.ts",
2724
+ "type": "registry:lib",
2725
+ "target": "lib/theme-surface.ts"
2726
+ },
2702
2727
  {
2703
2728
  "path": "src/lib/themes.ts",
2704
2729
  "type": "registry:lib",
@@ -9,6 +9,7 @@ export {
9
9
  } from './theme-picker'
10
10
  export {
11
11
  ThemeProvider,
12
+ createVersionedPersistence,
12
13
  defaultThemeSelection,
13
14
  inlineScriptLiteral,
14
15
  localStoragePersistence,
@@ -16,11 +17,17 @@ export {
16
17
  themeScript,
17
18
  useOptionalTheme,
18
19
  useTheme,
20
+ type EditorRolesMode,
19
21
  type ThemeContextValue,
20
22
  type ThemeDensity,
21
23
  type ThemePersistence,
22
24
  type ThemeProviderProps,
25
+ type ThemeScriptOptions,
23
26
  type ThemeSelection,
27
+ type ThemeStorageSchema,
28
+ type ThemeSurface,
29
+ type VersionedCodec,
30
+ type VersionedPersistenceOptions,
24
31
  } from './theme-provider'
25
32
  export { ThemeToggle, type ThemeToggleProps } from './theme-toggle'
26
33
  export { ThemeModeToggle, type ThemeModeToggleProps } from './theme-mode-toggle'
@@ -4,11 +4,15 @@ import {
4
4
  } from '#lib/appearance-font-settings'
5
5
  import {
6
6
  accentVariables,
7
+ isCatalogueVariant,
8
+ terminalCssVariables,
7
9
  themeCssVariables,
10
+ type EditorRolesMode,
8
11
  type ThemeAppearance,
9
12
  type ThemeVariant,
10
13
  } from '#lib/themes'
11
14
  import { isCustomAccent } from '#lib/accent-roles'
15
+ import { surfaceAlpha, type SurfaceState } from '#lib/theme-surface'
12
16
  import { resolvedDensity, type ThemeSelection } from './theme-state'
13
17
 
14
18
  /**
@@ -27,6 +31,16 @@ export type ThemeDocumentState = {
27
31
  selection: ThemeSelection
28
32
  appearance: ThemeAppearance
29
33
  variant: ThemeVariant
34
+ /**
35
+ * The variant whose terminal palette is pinned over the interface variant's.
36
+ * Absent, the terminal follows the interface theme.
37
+ */
38
+ terminalVariant?: ThemeVariant
39
+ /**
40
+ * The resolved surface axis. Absent, the axis is off: no attribute and no
41
+ * `--surface-alpha` is written, and any this writer wrote before is removed.
42
+ */
43
+ surface?: SurfaceState
30
44
  }
31
45
 
32
46
  export type ThemeDocumentOptions = {
@@ -42,11 +56,52 @@ export type ThemeDocumentOptions = {
42
56
  * only a custom `#rrggbb` is held to the default floor of 3:1.
43
57
  */
44
58
  accentContrastFloor?: number
59
+ /**
60
+ * Where a catalogue variant's palette comes from. `inline` (the default) writes
61
+ * every role onto the root. `stylesheet` writes none for a catalogue variant,
62
+ * because the host loads a stylesheet that carries a `[data-theme]` block for it;
63
+ * a variant the catalogue does not hold (a host's imported theme) is still written
64
+ * inline, since no stylesheet can know it. The accent, the terminal pin, the
65
+ * surface alpha and the fonts are written the same in both.
66
+ *
67
+ * Switching from an imported variant back to a catalogue one removes what the
68
+ * imported one wrote, so nothing inline outlives it to beat the stylesheet.
69
+ */
70
+ palette?: 'inline' | 'stylesheet'
71
+ /**
72
+ * The `--editor-*` projection. `syntax` (the default) is the catalogue's quiet
73
+ * syntax roles, which is what `theme-variants.css` carries; `floor` holds every
74
+ * editor role to 4.5:1 on the canvas, as hex.
75
+ *
76
+ * With `palette: 'stylesheet'`, `floor` is the one palette write a catalogue
77
+ * variant still gets: the stylesheet is generated for the default projection, so
78
+ * the sixteen `--editor-*` properties are written inline over it. The rest of the
79
+ * palette stays in the stylesheet.
80
+ */
81
+ editorRoles?: EditorRolesMode
82
+ }
83
+
84
+ /**
85
+ * What a catalogue variant still writes inline when its palette is the stylesheet's:
86
+ * nothing, unless the editor projection is not the one the stylesheet was generated
87
+ * for.
88
+ */
89
+ function stylesheetOverrides(
90
+ variant: ThemeVariant,
91
+ editorRoles: EditorRolesMode | undefined
92
+ ): Record<string, string> {
93
+ if (editorRoles !== 'floor') return {}
94
+ return Object.fromEntries(
95
+ Object.entries(themeCssVariables(variant, { editor: 'floor' })).filter(([name]) =>
96
+ name.startsWith('--editor-')
97
+ )
98
+ )
45
99
  }
46
100
 
47
101
  /**
48
102
  * The custom properties a state paints: the variant's roles, with the accent
49
- * selection laid over them, then `transformTokens`.
103
+ * selection laid over them, the pinned terminal palette over that, the surface
104
+ * alpha, then `transformTokens`.
50
105
  *
51
106
  * The accent is applied *here* rather than left to the `[data-accent]` blocks in
52
107
  * `theme.css`, because the properties are written inline and an inline declaration
@@ -59,13 +114,22 @@ export function themeDocumentTokens(
59
114
  state: ThemeDocumentState,
60
115
  options: ThemeDocumentOptions = {}
61
116
  ): { tokens: Record<string, string>; accented: boolean } {
62
- const properties = { ...themeCssVariables(state.variant) }
117
+ const fromStylesheet = options.palette === 'stylesheet' && isCatalogueVariant(state.variant)
118
+ const properties = fromStylesheet
119
+ ? stylesheetOverrides(state.variant, options.editorRoles)
120
+ : { ...themeCssVariables(state.variant, { editor: options.editorRoles }) }
63
121
  const accent = accentVariables(state.selection.accent, state.appearance, state.selection, {
64
122
  background: state.variant.colors.background,
65
123
  floor: options.accentContrastFloor,
66
124
  variantId: state.variant.id,
67
125
  })
68
126
  if (accent) Object.assign(properties, accent)
127
+ // Written after the variant and the accent, so it wins over the interface
128
+ // variant's own terminal ramp (and over the stylesheet's, which it outranks as an
129
+ // inline declaration). Dropping the pin needs no removal of its own: the ramp is
130
+ // then rewritten by the variant, or swept back to the stylesheet.
131
+ if (state.terminalVariant) Object.assign(properties, terminalCssVariables(state.terminalVariant))
132
+ if (state.surface) properties['--surface-alpha'] = surfaceAlpha[state.surface.surface]
69
133
  return {
70
134
  tokens: options.transformTokens ? options.transformTokens(properties) : properties,
71
135
  accented: accent !== undefined,
@@ -111,6 +175,17 @@ export function applyThemeToRoot(
111
175
  ? 'custom'
112
176
  : state.selection.accent
113
177
 
178
+ // The surface axis is opt-in, so its absence removes rather than defaults: a host
179
+ // that never enables it keeps an `<html>` with neither attribute, and one that
180
+ // turns it off leaves nothing behind. (`--surface-alpha` is swept with the tokens.)
181
+ if (state.surface) {
182
+ root.dataset['surface'] = state.surface.surface
183
+ root.dataset['reduceTransparency'] = state.surface.reduceTransparencyActive ? 'true' : 'false'
184
+ } else {
185
+ root.removeAttribute('data-surface')
186
+ root.removeAttribute('data-reduce-transparency')
187
+ }
188
+
114
189
  applyAppearanceFontSettings(
115
190
  root,
116
191
  normalizeAppearanceEditorFontSettings(state.selection.fonts, state.selection.font).settings
@@ -2,6 +2,15 @@ import { normalizeCustomAccent } from '#lib/accent-roles'
2
2
  import { accentPresets } from '#lib/tokens'
3
3
  import { normalizeAppearanceEditorFontSettings } from '#lib/appearance-font-settings'
4
4
  import { defaultDarkThemeId, defaultLightThemeId, isThemeAccentId, themeById } from '#lib/themes'
5
+ import { normalizeSurface } from '#lib/theme-surface'
6
+ import {
7
+ defaultPreferenceStorage,
8
+ readLegacyValue,
9
+ readVersionedRecord,
10
+ recoveryKeyFor,
11
+ writeVersionedRecord,
12
+ type PreferenceStorage,
13
+ } from '#lib/preference-store'
5
14
  import { defaultThemeSelection, resolvedDensity, type ThemeSelection } from './theme-state'
6
15
 
7
16
  /**
@@ -83,6 +92,17 @@ function readSelection(storageKey: string): ThemeSelection {
83
92
  // document effect removes the `data-density` that `themeScript` set for the first paint, and
84
93
  // the next `setSelection` rewrites the store without it.
85
94
  density: resolvedDensity(parsed as ThemeSelection),
95
+ // The newer axes are read back only when the store holds a usable value and are
96
+ // otherwise left `undefined`, never defaulted: `JSON.stringify` drops them, so a
97
+ // consumer that never sets one writes the bytes it always did. An id is kept as
98
+ // stored, known or not, for the same reason a theme id the registry lacks is.
99
+ terminalThemeId:
100
+ typeof parsed.terminalThemeId === 'string' && parsed.terminalThemeId.trim() !== ''
101
+ ? parsed.terminalThemeId
102
+ : undefined,
103
+ surface: normalizeSurface(parsed.surface),
104
+ reduceTransparency:
105
+ typeof parsed.reduceTransparency === 'boolean' ? parsed.reduceTransparency : undefined,
86
106
  }
87
107
  } catch {
88
108
  return defaultThemeSelection
@@ -141,3 +161,150 @@ export function writePersisted(persistence: ThemePersistence, next: ThemeSelecti
141
161
  // session and does not survive the reload.
142
162
  }
143
163
  }
164
+
165
+ /**
166
+ * Where a host's versioned preference document lives. One schema describes the
167
+ * document to both halves of the system: {@link createVersionedPersistence} reads and
168
+ * writes it, and `themeScript(options)` reads it before first paint, so the two
169
+ * cannot disagree about a key or a version.
170
+ */
171
+ export type ThemeStorageSchema = {
172
+ /** The storage key of the document. */
173
+ key: string
174
+ /**
175
+ * The only top-level `version` read. Absent, the document is unversioned. A record
176
+ * of any other version is quarantined (see `recoveryKey`), not interpreted.
177
+ */
178
+ version?: number
179
+ /**
180
+ * The document field that holds the appearance preference (`system`, `light`,
181
+ * `dark`). Read by `themeScript`; the persistence leaves field names to the codec.
182
+ * Default `appearance`.
183
+ */
184
+ appearanceField?: 'appearance' | 'mode'
185
+ /**
186
+ * An older single-value key (`light` | `dark` | `system`) read only while `key` is
187
+ * absent, and never deleted. The persistence hands it to the codec's `fromLegacy`.
188
+ */
189
+ legacyAppearanceKey?: string
190
+ /**
191
+ * The stored library of imported themes, for `themeScript` only: an imported
192
+ * theme's id and verbatim tokens, so the first paint can use the right variant. The
193
+ * persistence neither reads nor writes it.
194
+ */
195
+ libraryKey?: string
196
+ /** Where an unreadable document is kept. Default `${key}.recovery`. */
197
+ recoveryKey?: string
198
+ }
199
+
200
+ /**
201
+ * How a host's document maps to and from the shared {@link ThemeSelection}.
202
+ *
203
+ * The codec is the product schema: it decides which fields a document has, how an
204
+ * unknown value is corrected, and what a fresh install looks like. The engine decides
205
+ * everything about *storage*: what is quarantined, when the legacy key is read, and
206
+ * that nothing is written until `setSelection` asks.
207
+ */
208
+ export type VersionedCodec<Doc> = {
209
+ /**
210
+ * A readable record (the right JSON shape and version) as a selection patch. Fields
211
+ * it leaves `undefined` fall back to the provider's defaults. It may keep an id the
212
+ * catalogue lacks; the provider resolves it, it does not rewrite it.
213
+ */
214
+ decode(record: Readonly<Record<string, unknown>>): Partial<ThemeSelection>
215
+ /**
216
+ * The legacy key's value (never `null`) as a selection patch, or `undefined` for a
217
+ * value it does not recognise. Called only when the main key is absent.
218
+ */
219
+ fromLegacy?(value: string): Partial<ThemeSelection> | undefined
220
+ /**
221
+ * What a store with nothing readable answers: a fresh install, an unreadable
222
+ * document, a blocked store. Absent, the provider's own defaults apply.
223
+ */
224
+ defaults?(): Partial<ThemeSelection> | undefined
225
+ /**
226
+ * The document to store for a selection. `previous` is the record last read or
227
+ * written, so a codec can carry a field the selection does not model.
228
+ */
229
+ encode(next: ThemeSelection, previous?: Readonly<Record<string, unknown>>): Doc
230
+ }
231
+
232
+ /** Seams for the storage and the clock; both default to the real ones. */
233
+ export type VersionedPersistenceOptions = {
234
+ /** The store, or nothing. Called per access; default is `localStorage` (blocked-safe). */
235
+ storage?: () => PreferenceStorage | undefined
236
+ /** The clock for the recovery envelope's `capturedAt`. */
237
+ now?: () => Date
238
+ }
239
+
240
+ /**
241
+ * A {@link ThemePersistence} over a versioned document and a host codec.
242
+ *
243
+ * `read` follows the engine's rules (`lib/preference-store.ts`): the main key first;
244
+ * an unreadable or unsupported document is quarantined byte for byte into the
245
+ * recovery key and the codec's `defaults` answer; the legacy key only when the main
246
+ * key is absent. It never writes the main key, so the first write is a user's
247
+ * decision. `write` stores `codec.encode(next, previous)` as JSON and swallows a
248
+ * failure.
249
+ *
250
+ * Neither method throws. The stored bytes are exactly what the codec encodes, so a
251
+ * host keeps its keys, field names and bytes by keeping its codec.
252
+ */
253
+ export function createVersionedPersistence<Doc>(
254
+ schema: ThemeStorageSchema,
255
+ codec: VersionedCodec<Doc>,
256
+ options: VersionedPersistenceOptions = {}
257
+ ): ThemePersistence {
258
+ const storageOf = options.storage ?? defaultPreferenceStorage
259
+ const recordSchema = {
260
+ key: schema.key,
261
+ version: schema.version,
262
+ recoveryKey: recoveryKeyFor(schema),
263
+ }
264
+ let previous: Readonly<Record<string, unknown>> | undefined
265
+
266
+ const fallback = () => {
267
+ try {
268
+ return codec.defaults?.()
269
+ } catch {
270
+ return undefined
271
+ }
272
+ }
273
+
274
+ return {
275
+ read() {
276
+ const storage = storageOf()
277
+ if (!storage) return fallback()
278
+ try {
279
+ const found = readVersionedRecord(storage, recordSchema, options.now)
280
+ if (found.status === 'record') {
281
+ previous = found.record
282
+ return codec.decode(found.record)
283
+ }
284
+ if (found.status === 'absent' && schema.legacyAppearanceKey && codec.fromLegacy) {
285
+ const legacy = readLegacyValue(storage, schema.legacyAppearanceKey)
286
+ const migrated = legacy === null ? undefined : codec.fromLegacy(legacy)
287
+ if (migrated) return migrated
288
+ }
289
+ } catch {
290
+ // A codec that throws on a record it cannot read is an unreadable store.
291
+ }
292
+ return fallback()
293
+ },
294
+ write(next) {
295
+ const storage = storageOf()
296
+ if (!storage) return
297
+ try {
298
+ const document = codec.encode(next, previous)
299
+ if (writeVersionedRecord(storage, schema.key, document)) {
300
+ previous =
301
+ typeof document === 'object' && document !== null
302
+ ? (document as Readonly<Record<string, unknown>>)
303
+ : undefined
304
+ }
305
+ } catch {
306
+ // An encoder that throws loses the preference, not the switch.
307
+ }
308
+ },
309
+ }
310
+ }