@adea-ai/ui 0.128.1 → 0.128.3

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 (141) hide show
  1. package/dist/components/theme/appearance-panel.js +1 -1
  2. package/dist/components/theme/index.js +1 -1
  3. package/dist/components/theme/lazy.d.ts +15 -0
  4. package/dist/components/theme/lazy.d.ts.map +1 -0
  5. package/dist/components/theme/lazy.js +9 -0
  6. package/dist/components/theme/theme-catalogue-source.d.ts +15 -0
  7. package/dist/components/theme/theme-catalogue-source.d.ts.map +1 -0
  8. package/dist/components/theme/theme-catalogue-source.js +51 -0
  9. package/dist/components/theme/theme-catalogue-source.js.map +1 -0
  10. package/dist/components/theme/theme-document-core.d.ts +123 -0
  11. package/dist/components/theme/theme-document-core.d.ts.map +1 -0
  12. package/dist/components/theme/{theme-document.js → theme-document-core.js} +12 -12
  13. package/dist/components/theme/theme-document-core.js.map +1 -0
  14. package/dist/components/theme/theme-document.d.ts +8 -107
  15. package/dist/components/theme/theme-document.d.ts.map +1 -1
  16. package/dist/components/theme/theme-persistence.d.ts +1 -1
  17. package/dist/components/theme/theme-persistence.d.ts.map +1 -1
  18. package/dist/components/theme/theme-persistence.js +9 -6
  19. package/dist/components/theme/theme-persistence.js.map +1 -1
  20. package/dist/components/theme/theme-provider-core.d.ts +19 -0
  21. package/dist/components/theme/theme-provider-core.d.ts.map +1 -0
  22. package/dist/components/theme/theme-provider-core.js +28 -0
  23. package/dist/components/theme/theme-provider-core.js.map +1 -0
  24. package/dist/components/theme/theme-provider-lazy.d.ts +28 -0
  25. package/dist/components/theme/theme-provider-lazy.d.ts.map +1 -0
  26. package/dist/components/theme/theme-provider-lazy.js +36 -0
  27. package/dist/components/theme/theme-provider-lazy.js.map +1 -0
  28. package/dist/components/theme/theme-provider-value.d.ts +175 -0
  29. package/dist/components/theme/theme-provider-value.d.ts.map +1 -0
  30. package/dist/components/theme/theme-provider-value.js +200 -0
  31. package/dist/components/theme/theme-provider-value.js.map +1 -0
  32. package/dist/components/theme/theme-provider.d.ts +11 -136
  33. package/dist/components/theme/theme-provider.d.ts.map +1 -1
  34. package/dist/components/theme/theme-provider.js +27 -149
  35. package/dist/components/theme/theme-provider.js.map +1 -1
  36. package/dist/components/theme/theme-script.js +4 -3
  37. package/dist/components/theme/theme-script.js.map +1 -1
  38. package/dist/components/theme/theme-state-core.d.ts +248 -0
  39. package/dist/components/theme/theme-state-core.d.ts.map +1 -0
  40. package/dist/components/theme/{theme-state.js → theme-state-core.js} +8 -7
  41. package/dist/components/theme/theme-state-core.js.map +1 -0
  42. package/dist/components/theme/theme-state.d.ts +8 -214
  43. package/dist/components/theme/theme-state.d.ts.map +1 -1
  44. package/dist/components/theme/theme-toggle.js.map +1 -1
  45. package/dist/index.js +7 -4
  46. package/dist/lib/accent-presets.d.ts +26 -0
  47. package/dist/lib/accent-presets.d.ts.map +1 -0
  48. package/dist/lib/accent-presets.js +11 -0
  49. package/dist/lib/accent-presets.js.map +1 -0
  50. package/dist/lib/accent-roles.js +94 -9
  51. package/dist/lib/accent-roles.js.map +1 -1
  52. package/dist/lib/font-zoom.js +4 -2
  53. package/dist/lib/font-zoom.js.map +1 -1
  54. package/dist/lib/overlay.js +3 -1
  55. package/dist/lib/overlay.js.map +1 -1
  56. package/dist/lib/theme-catalogue.d.ts +3 -0
  57. package/dist/lib/theme-catalogue.d.ts.map +1 -0
  58. package/dist/lib/theme-catalogue.js +22 -0
  59. package/dist/lib/theme-catalogue.js.map +1 -0
  60. package/dist/lib/theme-css.d.ts +43 -0
  61. package/dist/lib/theme-css.d.ts.map +1 -0
  62. package/dist/lib/theme-css.js +167 -0
  63. package/dist/lib/theme-css.js.map +1 -0
  64. package/dist/lib/theme-defaults.d.ts +46 -0
  65. package/dist/lib/theme-defaults.d.ts.map +1 -0
  66. package/dist/lib/theme-defaults.generated.d.ts +13 -0
  67. package/dist/lib/theme-defaults.generated.d.ts.map +1 -0
  68. package/dist/lib/theme-defaults.generated.js +318 -0
  69. package/dist/lib/theme-defaults.generated.js.map +1 -0
  70. package/dist/lib/theme-defaults.js +47 -0
  71. package/dist/lib/theme-defaults.js.map +1 -0
  72. package/dist/lib/theme-engine-light.d.ts +3 -0
  73. package/dist/lib/theme-engine-light.d.ts.map +1 -0
  74. package/dist/lib/theme-engine-light.js +75 -0
  75. package/dist/lib/theme-engine-light.js.map +1 -0
  76. package/dist/lib/theme-engine.d.ts +69 -0
  77. package/dist/lib/theme-engine.d.ts.map +1 -0
  78. package/dist/lib/theme-engine.js +33 -0
  79. package/dist/lib/theme-engine.js.map +1 -0
  80. package/dist/lib/themes.d.ts +14 -48
  81. package/dist/lib/themes.d.ts.map +1 -1
  82. package/dist/lib/themes.js +24 -164
  83. package/dist/lib/themes.js.map +1 -1
  84. package/dist/lib/tokens.d.ts +1 -25
  85. package/dist/lib/tokens.d.ts.map +1 -1
  86. package/dist/lib/tokens.js +1 -24
  87. package/dist/lib/tokens.js.map +1 -1
  88. package/dist/lib/tooltip-focus-gate.js +7 -1
  89. package/dist/lib/tooltip-focus-gate.js.map +1 -1
  90. package/dist/lib/variants.js +2 -2
  91. package/dist/r/lib.json +35 -0
  92. package/dist/r/registry.json +70 -0
  93. package/dist/r/src/components/theme/lazy.ts +35 -0
  94. package/dist/r/src/components/theme/theme-catalogue-source.ts +59 -0
  95. package/dist/r/src/components/theme/theme-document-core.ts +262 -0
  96. package/dist/r/src/components/theme/theme-document.ts +23 -240
  97. package/dist/r/src/components/theme/theme-persistence.ts +14 -6
  98. package/dist/r/src/components/theme/theme-provider-core.tsx +40 -0
  99. package/dist/r/src/components/theme/theme-provider-lazy.tsx +34 -0
  100. package/dist/r/src/components/theme/theme-provider-value.ts +445 -0
  101. package/dist/r/src/components/theme/theme-provider.tsx +23 -346
  102. package/dist/r/src/components/theme/theme-script.ts +3 -3
  103. package/dist/r/src/components/theme/theme-state-core.ts +364 -0
  104. package/dist/r/src/components/theme/theme-state.ts +10 -329
  105. package/dist/r/src/components/theme/theme-toggle.tsx +1 -1
  106. package/dist/r/src/lib/accent-presets.ts +43 -0
  107. package/dist/r/src/lib/theme-catalogue.ts +26 -0
  108. package/dist/r/src/lib/theme-css.ts +201 -0
  109. package/dist/r/src/lib/theme-defaults.generated.ts +319 -0
  110. package/dist/r/src/lib/theme-defaults.ts +79 -0
  111. package/dist/r/src/lib/theme-engine-light.ts +117 -0
  112. package/dist/r/src/lib/theme-engine.ts +111 -0
  113. package/dist/r/src/lib/themes.ts +27 -191
  114. package/dist/r/src/lib/tokens.ts +1 -35
  115. package/dist/r/theming.json +35 -0
  116. package/package.json +12 -3
  117. package/registry.json +70 -0
  118. package/src/components/theme/lazy.ts +35 -0
  119. package/src/components/theme/theme-catalogue-source.ts +59 -0
  120. package/src/components/theme/theme-document-core.ts +262 -0
  121. package/src/components/theme/theme-document.ts +23 -240
  122. package/src/components/theme/theme-persistence.ts +14 -6
  123. package/src/components/theme/theme-provider-core.tsx +40 -0
  124. package/src/components/theme/theme-provider-lazy.tsx +34 -0
  125. package/src/components/theme/theme-provider-value.ts +445 -0
  126. package/src/components/theme/theme-provider.tsx +23 -346
  127. package/src/components/theme/theme-script.ts +3 -3
  128. package/src/components/theme/theme-state-core.ts +364 -0
  129. package/src/components/theme/theme-state.ts +10 -329
  130. package/src/components/theme/theme-toggle.tsx +1 -1
  131. package/src/lib/accent-presets.ts +43 -0
  132. package/src/lib/theme-catalogue.ts +26 -0
  133. package/src/lib/theme-css.ts +201 -0
  134. package/src/lib/theme-defaults.generated.ts +319 -0
  135. package/src/lib/theme-defaults.ts +79 -0
  136. package/src/lib/theme-engine-light.ts +117 -0
  137. package/src/lib/theme-engine.ts +111 -0
  138. package/src/lib/themes.ts +27 -191
  139. package/src/lib/tokens.ts +1 -35
  140. package/dist/components/theme/theme-document.js.map +0 -1
  141. package/dist/components/theme/theme-state.js.map +0 -1
@@ -0,0 +1,364 @@
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 { normalizeCustomAccent, type AccentRoles } from '../../lib/accent-roles'
8
+ import { stepAppearanceFontSizes, type FontZoomIntent } from '../../lib/font-zoom'
9
+ import type { ThemeSurface } from '../../lib/theme-surface'
10
+ import { defaultDarkThemeId, defaultLightThemeId, resolveVariant } from '../../lib/theme-defaults'
11
+ import type { ThemeEngine } from '../../lib/theme-engine'
12
+ import type { ThemeAppearance, ThemeVariant } from '../../lib/themes'
13
+
14
+ /**
15
+ * The pure half of the theme provider: what a selection is, how two selections
16
+ * merge, and what a selection resolves to.
17
+ *
18
+ * Nothing here touches the document, storage or a signal, so each rule can be
19
+ * tested by calling it. `ThemeProvider` owns the reactivity and wires these to it;
20
+ * `theme-document.ts` owns the writes; `theme-persistence.ts` owns the store.
21
+ *
22
+ * This is the catalogue-free half: it imports no catalogue code or data, so the lazy
23
+ * provider can use it at start-up. `theme-state.ts` re-exports it and adds
24
+ * {@link accentRolesForState} bound to the full catalogue.
25
+ */
26
+
27
+ /**
28
+ * Four independent choices, and keeping them independent is the point:
29
+ *
30
+ * - **appearance** — light, dark, or follow the system.
31
+ * - **a theme per appearance** — so a user can run Catppuccin Mocha by night and
32
+ * Solarized Light by day without re-choosing when the system flips.
33
+ * - **an accent** — adea's six presets, or one of the accents the selected theme
34
+ * pair carries itself (`ansi-blue`…), which re-colour the interactive roles on
35
+ * top of whichever theme is active.
36
+ * - **fonts** — separate family and size choices for UI, content and code.
37
+ */
38
+ export type ThemeSelection = {
39
+ /** `system` follows `prefers-color-scheme` and updates when it changes. */
40
+ appearance: ThemeAppearance | 'system'
41
+ /** The variant to use when the resolved appearance is light. */
42
+ lightThemeId: string
43
+ /** The variant to use when the resolved appearance is dark. */
44
+ darkThemeId: string
45
+ /**
46
+ * An `accentPresets` id, a theme accent id from `themeAccents()` (`ansi-blue`…),
47
+ * `theme` for the variant's own primary, or a custom colour as `#rrggbb`. A theme
48
+ * accent the current pair does not offer falls back to the variant's own primary.
49
+ *
50
+ * A custom colour is stored lower case and is raised to the contrast floor when it
51
+ * is painted, never when it is stored: the selection keeps what the user chose.
52
+ * Anything else that is not an id is dropped at paint, not rejected here.
53
+ */
54
+ accent: string
55
+ /** UI-family alias for consumers of the original single-font preference. */
56
+ font: string
57
+ /** UI, content and code font settings. Omitted legacy records are migrated from `font`. */
58
+ fonts?: AppearanceEditorFontSettings
59
+ /**
60
+ * `comfortable` is the system's default geometry; `compact` moves the control
61
+ * ladder down one rung.
62
+ *
63
+ * Optional, and defaulted on read, so a preference stored before density existed
64
+ * keeps working and `ThemeSelection` stays assignable without it.
65
+ */
66
+ density?: ThemeDensity
67
+ /**
68
+ * The terminal either follows the interface theme (`theme`, or absent) or is pinned
69
+ * to another theme's terminal palette by that theme's id. The pin is written over
70
+ * the interface variant's own `--terminal-*` properties, so a light interface can
71
+ * carry a dark terminal.
72
+ *
73
+ * An id the registry does not hold paints the interface palette and stays in the
74
+ * selection, the same rule as an unknown theme id.
75
+ *
76
+ * Optional and not defaulted: see {@link defaultThemeSelection}.
77
+ */
78
+ terminalThemeId?: string
79
+ /**
80
+ * How opaque elevated panels are. Written to `<html>` only when the provider's
81
+ * `surface` prop is on or this is set; absent, a host paints no surface axis at
82
+ * all. Resolved against the OS and the host's capability before it is painted —
83
+ * see `resolveSurface` — so the value stored is a preference, never the outcome.
84
+ */
85
+ surface?: ThemeSurface
86
+ /**
87
+ * The user's own Reduce Transparency switch, which forces the `opaque` surface the
88
+ * same way the operating system's setting does.
89
+ */
90
+ reduceTransparency?: boolean
91
+ }
92
+
93
+ /**
94
+ * The two density rungs.
95
+ *
96
+ * `comfortable` is the geometry in `theme.css`, so it is the absence of an
97
+ * attribute rather than a value: a document that ships no `data-density` at all
98
+ * renders correctly, which is what keeps this additive.
99
+ */
100
+ export type ThemeDensity = 'comfortable' | 'compact'
101
+
102
+ /**
103
+ * Deliberately lists only the fields every consumer has always had. A field added
104
+ * to {@link ThemeSelection} later gets no default here: `JSON.stringify` drops an
105
+ * `undefined`, so a store written by a consumer that never sets the field stays
106
+ * byte-identical to what it was before the field existed.
107
+ */
108
+ export const defaultThemeSelection: ThemeSelection = {
109
+ appearance: 'system',
110
+ lightThemeId: defaultLightThemeId,
111
+ darkThemeId: defaultDarkThemeId,
112
+ accent: 'theme',
113
+ font: 'system',
114
+ fonts: DEFAULT_APPEARANCE_EDITOR_FONT_SETTINGS,
115
+ density: 'comfortable',
116
+ }
117
+
118
+ /**
119
+ * A stored selection's density, defaulted.
120
+ *
121
+ * The stored value is read through here rather than trusted, because a preference
122
+ * written before density existed has no `density` key and a hand-edited store may
123
+ * hold anything.
124
+ */
125
+ export function resolvedDensity(selection: ThemeSelection): ThemeDensity {
126
+ return selection.density === 'compact' ? 'compact' : 'comfortable'
127
+ }
128
+
129
+ export type ThemeContextValue = {
130
+ /** The preference as stored. A {@link ThemeContextValue.preview} does not change it. */
131
+ selection: () => ThemeSelection
132
+ /** What the preference resolves to right now, after `system`. */
133
+ resolvedAppearance: () => ThemeAppearance
134
+ /** The variant in use. */
135
+ variant: () => ThemeVariant
136
+ /** Update part of the preference. */
137
+ setSelection: (patch: Partial<ThemeSelection>) => void
138
+ /**
139
+ * The built-in catalogue, so a picker does not import it separately. A plain
140
+ * array: {@link ThemeContextValue.allThemes} is the one that follows a host's
141
+ * extra themes. Under the lazy provider it holds only the default pair until
142
+ * {@link ThemeContextValue.catalogueReady}; read it again afterwards.
143
+ */
144
+ themes: readonly ThemeVariant[]
145
+ /**
146
+ * The accents the selected light/dark pair carries, offered beside the presets.
147
+ * Follows the selection: a different theme pair may offer different slots.
148
+ */
149
+ themeAccents: () => readonly AccentPreset[]
150
+ /**
151
+ * The built-in catalogue followed by the provider's `themes` prop. Reactive when
152
+ * that prop is a function that reads signals.
153
+ */
154
+ allThemes: () => readonly ThemeVariant[]
155
+ /**
156
+ * Paint the document from `draft` laid over the stored selection, without
157
+ * persisting it or changing what {@link ThemeContextValue.selection} returns.
158
+ * `undefined` ends the preview. A {@link ThemeContextValue.setSelection} ends it
159
+ * too, because the committed selection is then what should be showing.
160
+ */
161
+ preview: (draft: Partial<ThemeSelection> | undefined) => void
162
+ /** True while a {@link ThemeContextValue.preview} draft is on the document. */
163
+ previewing: () => boolean
164
+ /**
165
+ * The roles `accent` paints over `variantId` (default: the variant in use), held
166
+ * to the provider's `accentContrastFloor`, for an element-scoped override with
167
+ * `paintAccentRoles` — a workspace that carries its own colour. `undefined` for
168
+ * `null`, for `theme`, for an id that resolves to nothing, and for a variant the
169
+ * registry does not hold: each means "keep the inherited accent".
170
+ */
171
+ accentRolesFor: (accent: string | null, variantId?: string) => AccentRoles | undefined
172
+ /**
173
+ * The surface the committed selection paints, after Reduce Transparency and the
174
+ * host's translucency capability. `opaque` when the axis is off. Follows the
175
+ * committed selection, not a preview, like {@link ThemeContextValue.variant}.
176
+ */
177
+ effectiveSurface: () => ThemeSurface
178
+ /**
179
+ * True while the operating system or the selection's `reduceTransparency` forces
180
+ * opaque surfaces. Always false when the surface axis is off, because nothing is
181
+ * then listening to the operating system.
182
+ */
183
+ reduceTransparencyActive: () => boolean
184
+ /**
185
+ * Step the three text sizes together, or put them back to the defaults: what the
186
+ * `fontZoom` prop's chords do, for a host that binds its own keys or a menu. Goes
187
+ * through {@link ThemeContextValue.setSelection}, so it persists and ends a preview.
188
+ */
189
+ zoomFonts: (intent: FontZoomIntent) => void
190
+ /**
191
+ * True when `themes`, `allThemes()` and the variants they resolve are backed by the
192
+ * whole catalogue. Always true under `ThemeProvider`. Under the lazy provider
193
+ * (`@adea-ai/ui/components/theme/lazy`) it turns true once the catalogue has loaded,
194
+ * and until then only the default light and dark themes are available: a stored
195
+ * theme beyond them resolves to its appearance's default (the document keeps painting
196
+ * what the stylesheet gave it) until `catalogueReady()` is true. Absent on a context
197
+ * a host builds itself, where it should be read as true.
198
+ */
199
+ catalogueReady?: () => boolean
200
+ /**
201
+ * Start loading the whole catalogue (idempotent) and resolve when it is available.
202
+ * A picker, a preview or a settings page calls it when it mounts. A no-op under
203
+ * `ThemeProvider`, whose catalogue is already loaded.
204
+ */
205
+ loadCatalogue?: () => Promise<void>
206
+ }
207
+
208
+ /** A selection with the `undefined` keys of a patch left out. */
209
+ function definedOnly<T extends object>(value: T): Partial<T> {
210
+ return Object.fromEntries(
211
+ Object.entries(value).filter(([, entry]) => entry !== undefined)
212
+ ) as Partial<T>
213
+ }
214
+
215
+ /** Keep the deprecated single-font view synchronized with the UI axis. */
216
+ export function mergeThemeSelection(
217
+ base: ThemeSelection,
218
+ patch: Partial<ThemeSelection>
219
+ ): ThemeSelection {
220
+ const merged = { ...base, ...patch }
221
+ let fonts: AppearanceEditorFontSettings
222
+
223
+ if (patch.fonts !== undefined) {
224
+ fonts = normalizeAppearanceEditorFontSettings(patch.fonts).settings
225
+ } else if (patch.font !== undefined) {
226
+ const currentFonts = normalizeAppearanceEditorFontSettings(base.fonts, base.font).settings
227
+ fonts = normalizeAppearanceEditorFontSettings({
228
+ ...currentFonts,
229
+ ui: { ...currentFonts.ui, family: patch.font },
230
+ }).settings
231
+ } else {
232
+ fonts = normalizeAppearanceEditorFontSettings(merged.fonts, merged.font).settings
233
+ }
234
+
235
+ return {
236
+ ...merged,
237
+ // One spelling for a custom colour, so equal choices compare and store equal.
238
+ accent: normalizeCustomAccent(merged.accent) ?? merged.accent,
239
+ fonts,
240
+ font: fonts.ui.family,
241
+ }
242
+ }
243
+
244
+ /**
245
+ * The selection a provider starts from: the defaults, then whatever the store
246
+ * returned, then `initial`.
247
+ *
248
+ * `initial` wins over the store on every mount. That precedence is part of the
249
+ * contract Cortana relies on (its `initial` pins `appearance` and `font`), and it
250
+ * is why the store's answer is only the middle layer. A store that returned
251
+ * nothing, or threw, or answered with explicit `undefined`s, leaves the defaults
252
+ * standing for those fields.
253
+ */
254
+ export function initialThemeSelection(
255
+ stored: Partial<ThemeSelection> | undefined,
256
+ initial: Partial<ThemeSelection> | undefined
257
+ ): ThemeSelection {
258
+ const base = mergeThemeSelection(defaultThemeSelection, definedOnly(stored ?? {}))
259
+ return mergeThemeSelection(base, initial ?? {})
260
+ }
261
+
262
+ /** `system` resolved against what the operating system reports. */
263
+ export function resolveAppearance(
264
+ preference: ThemeSelection['appearance'],
265
+ system: ThemeAppearance
266
+ ): ThemeAppearance {
267
+ return preference === 'system' ? system : preference
268
+ }
269
+
270
+ /** The theme id the selection names for a resolved appearance. */
271
+ export function selectedThemeId(selection: ThemeSelection, appearance: ThemeAppearance): string {
272
+ return appearance === 'dark' ? selection.darkThemeId : selection.lightThemeId
273
+ }
274
+
275
+ /**
276
+ * What a selection paints: the resolved appearance and the variant for it.
277
+ *
278
+ * An id the registry does not hold resolves to the appearance's default rather than
279
+ * being rewritten (see `resolveVariant`), so the selection a host stores is never
280
+ * changed by being unresolvable.
281
+ */
282
+ export function resolveThemeState(
283
+ selection: ThemeSelection,
284
+ system: ThemeAppearance,
285
+ registry: readonly ThemeVariant[]
286
+ ): { appearance: ThemeAppearance; variant: ThemeVariant } {
287
+ const appearance = resolveAppearance(selection.appearance, system)
288
+ return {
289
+ appearance,
290
+ variant: resolveVariant(registry, selectedThemeId(selection, appearance), appearance),
291
+ }
292
+ }
293
+
294
+ /**
295
+ * The variant whose terminal palette is pinned, or `undefined` when the terminal
296
+ * follows the interface theme.
297
+ *
298
+ * `theme`, an absent id and an id the registry does not hold all mean the interface
299
+ * palette: a terminal must never resolve to a blank or half-painted palette because a
300
+ * theme was deleted after it was pinned.
301
+ */
302
+ export function resolveTerminalVariant(
303
+ terminalThemeId: string | undefined,
304
+ registry: readonly ThemeVariant[]
305
+ ): ThemeVariant | undefined {
306
+ if (terminalThemeId === undefined || terminalThemeId === 'theme') return undefined
307
+ return registry.find((candidate) => candidate.id === terminalThemeId)
308
+ }
309
+
310
+ /**
311
+ * The font settings a zoom chord asks for: one pixel up or down on every axis, or
312
+ * the defaults for `reset`.
313
+ */
314
+ export function zoomedFontSettings(
315
+ selection: ThemeSelection,
316
+ intent: FontZoomIntent
317
+ ): AppearanceEditorFontSettings {
318
+ if (intent === 'reset') return { ...DEFAULT_APPEARANCE_EDITOR_FONT_SETTINGS }
319
+ return stepAppearanceFontSizes(selection.fonts, intent === 'in' ? 1 : -1)
320
+ }
321
+
322
+ /**
323
+ * The registry a provider resolves against: the catalogue, then the host's extras.
324
+ *
325
+ * The catalogue wins an id collision, because it is listed first and `resolveVariant`
326
+ * takes the first match: a host's variant can add a theme but cannot shadow one.
327
+ */
328
+ export function mergeRegistry(
329
+ builtin: readonly ThemeVariant[],
330
+ extra: readonly ThemeVariant[] | undefined
331
+ ): readonly ThemeVariant[] {
332
+ return extra && extra.length > 0 ? [...builtin, ...extra] : builtin
333
+ }
334
+
335
+ /**
336
+ * The roles an accent paints over a variant, for {@link ThemeContextValue.accentRolesFor}.
337
+ *
338
+ * Resolved against the committed selection's theme pair, so an `ansi-*` accent means
339
+ * the same colour here as it does on `<html>`. `engine` answers the catalogue
340
+ * questions; `theme-state.ts` binds the full catalogue for callers that have none.
341
+ */
342
+ export function accentRolesForStateWith(
343
+ engine: Pick<ThemeEngine, 'accentRolesForSelection'>,
344
+ accent: string | null,
345
+ variantId: string | undefined,
346
+ state: {
347
+ selection: ThemeSelection
348
+ variant: ThemeVariant
349
+ registry: readonly ThemeVariant[]
350
+ accentContrastFloor?: number
351
+ }
352
+ ): AccentRoles | undefined {
353
+ if (accent === null) return undefined
354
+ const variant =
355
+ variantId === undefined
356
+ ? state.variant
357
+ : state.registry.find((candidate) => candidate.id === variantId)
358
+ if (!variant) return undefined
359
+ return engine.accentRolesForSelection(accent, variant.appearance, state.selection, {
360
+ background: variant.colors.background,
361
+ floor: state.accentContrastFloor,
362
+ variantId: variant.id,
363
+ })
364
+ }