@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
@@ -1,10 +1,11 @@
1
1
  import type { JSX } from 'solid-js'
2
- import { createEffect, createMemo, createSignal, onCleanup, splitProps } from 'solid-js'
3
- import { safeScriptStringLiteral } from '#lib/safe-script'
4
- import { fontSettingsBootstrapScript } from '#lib/appearance-font-settings'
2
+ import { createEffect, createMemo, createSignal, onCleanup, splitProps, untrack } from 'solid-js'
3
+ import { fontZoomShortcut, type FontZoomIntent } from '#lib/font-zoom'
4
+ import { REDUCED_TRANSPARENCY_QUERY, resolveSurfaceState } from '#lib/theme-surface'
5
5
  import {
6
6
  builtinThemes,
7
7
  themeAccentsFor,
8
+ type EditorRolesMode,
8
9
  type ThemeAppearance,
9
10
  type ThemeVariant,
10
11
  } from '#lib/themes'
@@ -21,13 +22,25 @@ import {
21
22
  initialThemeSelection,
22
23
  mergeRegistry,
23
24
  mergeThemeSelection,
25
+ resolveTerminalVariant,
24
26
  resolveThemeState,
27
+ zoomedFontSettings,
25
28
  type ThemeContextValue,
26
29
  type ThemeSelection,
27
30
  } from './theme-state'
28
31
 
29
32
  export { useOptionalTheme, useTheme } from './theme-context'
30
- export { localStoragePersistence, type ThemePersistence } from './theme-persistence'
33
+ export {
34
+ createVersionedPersistence,
35
+ localStoragePersistence,
36
+ type ThemePersistence,
37
+ type ThemeStorageSchema,
38
+ type VersionedCodec,
39
+ type VersionedPersistenceOptions,
40
+ } from './theme-persistence'
41
+ export type { ThemeSurface } from '#lib/theme-surface'
42
+ export type { EditorRolesMode } from '#lib/themes'
43
+ export { inlineScriptLiteral, themeScript, type ThemeScriptOptions } from './theme-script'
31
44
  export {
32
45
  defaultThemeSelection,
33
46
  resolvedDensity,
@@ -121,6 +134,61 @@ export type ThemeProviderProps = {
121
134
  * own themes the floor changes no preset.
122
135
  */
123
136
  accentContrastFloor?: number
137
+ /**
138
+ * Turns the surface axis on: `selection.surface` and `selection.reduceTransparency`
139
+ * are resolved against the operating system's Reduce Transparency setting and the
140
+ * host's capability, and `data-surface`, `data-reduce-transparency` and
141
+ * `--surface-alpha` are written to `<html>` (the frost rule in `theme.css` reads
142
+ * them). Off by default: a provider that never opts in writes none of them, adds no
143
+ * media-query listener, and stores nothing new.
144
+ *
145
+ * An object also states the host's capability. `nativeTranslucency` is whether the
146
+ * window can render the OS's own translucency; without it a `translucent`
147
+ * preference paints the tokenized `frosted` surface instead of pretending to
148
+ * vibrancy. A function is read inside the provider's reactive scope, for a
149
+ * capability that is learned after mount.
150
+ *
151
+ * A selection that already carries a `surface` turns the axis on for itself, so a
152
+ * host that stores the field gets the same document without the prop.
153
+ */
154
+ surface?: boolean | { nativeTranslucency?: boolean | (() => boolean) }
155
+ /**
156
+ * Where a catalogue variant's palette comes from. `inline` (the default) writes
157
+ * every role onto `<html>`. `stylesheet` leaves a catalogue variant to the host's
158
+ * `[data-theme]` stylesheet and writes inline only what no stylesheet can know: a
159
+ * host's own `themes`, the accent, the pinned terminal palette, the surface alpha
160
+ * and the fonts. Switching from a host variant back to a catalogue one removes the
161
+ * properties the host variant wrote.
162
+ */
163
+ palette?: 'inline' | 'stylesheet'
164
+ /**
165
+ * The projection behind the `--editor-*` ramp. `syntax` (the default) is the
166
+ * catalogue's quiet syntax roles, a comment among them deliberately below
167
+ * body-text contrast. `floor` holds all sixteen roles to 4.5:1 on the canvas, as
168
+ * hex, for a host that renders code as content (a diff, a file view) rather than
169
+ * as decoration.
170
+ *
171
+ * Applies to catalogue variants; a host's own `themes` keep their `editor` and
172
+ * their `tokens`. With `palette="stylesheet"` it is the one palette property a
173
+ * catalogue variant still writes inline, because `theme-variants.css` is generated
174
+ * for `syntax`. Absent, nothing changes.
175
+ */
176
+ editorRoles?: EditorRolesMode
177
+ /**
178
+ * Bind the platform zoom chords (Cmd or Ctrl with `=`, `+`, `-`, `0`) to the three
179
+ * text sizes instead of the browser's page zoom: stepping them together is zoom
180
+ * inside the persisted type system. Off by default: it adds a capturing `keydown`
181
+ * listener to `window`, and an event already handled elsewhere is left alone.
182
+ * `zoomFonts` on the context does the same from a menu or a host's own binding.
183
+ */
184
+ fontZoom?: boolean
185
+ }
186
+
187
+ /** The operating system's Reduce Transparency query, where there is a window to ask. */
188
+ function reducedTransparencyMedia(): MediaQueryList | undefined {
189
+ return typeof window !== 'undefined' && typeof window.matchMedia === 'function'
190
+ ? window.matchMedia(REDUCED_TRANSPARENCY_QUERY)
191
+ : undefined
124
192
  }
125
193
 
126
194
  export function ThemeProvider(props: ThemeProviderProps) {
@@ -134,6 +202,10 @@ export function ThemeProvider(props: ThemeProviderProps) {
134
202
  'transformTokens',
135
203
  'disableTransitionOnChange',
136
204
  'accentContrastFloor',
205
+ 'surface',
206
+ 'palette',
207
+ 'editorRoles',
208
+ 'fontZoom',
137
209
  ])
138
210
  const storageKey = () => local.storageKey ?? 'adea-appearance'
139
211
  const persistence = local.persistence ?? localStoragePersistence(storageKey)
@@ -186,6 +258,37 @@ export function ThemeProvider(props: ThemeProviderProps) {
186
258
  draft() ? resolveThemeState(shownSelection(), systemAppearance(), registry()) : committed()
187
259
  )
188
260
 
261
+ // The surface axis. Off, none of this reads the operating system: the media query
262
+ // is subscribed only while the axis is on, so a provider that never opts in adds no
263
+ // listener and no repaint.
264
+ const surfaceOn = (candidate: ThemeSelection) =>
265
+ local.surface === true || typeof local.surface === 'object' || candidate.surface !== undefined
266
+ const nativeTranslucency = () => {
267
+ const capability = typeof local.surface === 'object' ? local.surface.nativeTranslucency : false
268
+ return (typeof capability === 'function' ? capability() : capability) === true
269
+ }
270
+ const [osReducedTransparency, setOsReducedTransparency] = createSignal(
271
+ surfaceOn(untrack(shownSelection)) ? (reducedTransparencyMedia()?.matches ?? false) : false
272
+ )
273
+ const axisOn = createMemo(() => surfaceOn(shownSelection()))
274
+ createEffect(() => {
275
+ const query = axisOn() ? reducedTransparencyMedia() : undefined
276
+ setOsReducedTransparency(query?.matches ?? false)
277
+ if (!query) return
278
+ const onChange = (event: MediaQueryListEvent) => setOsReducedTransparency(event.matches)
279
+ query.addEventListener('change', onChange)
280
+ onCleanup(() => query.removeEventListener('change', onChange))
281
+ })
282
+ const surfaceStateOf = (candidate: ThemeSelection) =>
283
+ resolveSurfaceState(candidate.surface, {
284
+ osReducedTransparency: osReducedTransparency(),
285
+ userReducedTransparency: candidate.reduceTransparency === true,
286
+ nativeTranslucency: nativeTranslucency(),
287
+ })
288
+ const committedSurface = createMemo(() =>
289
+ surfaceOn(selection()) ? surfaceStateOf(selection()) : undefined
290
+ )
291
+
189
292
  let written: ReadonlySet<string> | undefined
190
293
  let restoreTransitions: (() => void) | undefined
191
294
  createEffect(() => {
@@ -194,10 +297,14 @@ export function ThemeProvider(props: ThemeProviderProps) {
194
297
  selection: shownSelection(),
195
298
  appearance: shown().appearance,
196
299
  variant: shown().variant,
300
+ terminalVariant: resolveTerminalVariant(shownSelection().terminalThemeId, registry()),
301
+ surface: axisOn() ? surfaceStateOf(shownSelection()) : undefined,
197
302
  }
198
303
  const options = {
199
304
  transformTokens: local.transformTokens,
200
305
  accentContrastFloor: local.accentContrastFloor,
306
+ palette: local.palette,
307
+ editorRoles: local.editorRoles,
201
308
  }
202
309
  const apply = () => {
203
310
  written = applyThemeToRoot(document.documentElement, state, options, written)
@@ -215,6 +322,24 @@ export function ThemeProvider(props: ThemeProviderProps) {
215
322
  themeAccentsFor(selection().lightThemeId, selection().darkThemeId)
216
323
  )
217
324
 
325
+ const zoomFonts = (intent: FontZoomIntent) =>
326
+ value.setSelection({ fonts: zoomedFontSettings(selection(), intent) })
327
+
328
+ // The platform zoom chords drive the three text sizes, captured like the other
329
+ // global chords; an event something else already handled never double-steps.
330
+ const onKeyDown = (event: KeyboardEvent) => {
331
+ if (event.defaultPrevented) return
332
+ const intent = fontZoomShortcut(event)
333
+ if (!intent) return
334
+ event.preventDefault()
335
+ zoomFonts(intent)
336
+ }
337
+ createEffect(() => {
338
+ if (!local.fontZoom || typeof window === 'undefined') return
339
+ window.addEventListener('keydown', onKeyDown, { capture: true })
340
+ onCleanup(() => window.removeEventListener('keydown', onKeyDown, { capture: true }))
341
+ })
342
+
218
343
  const value: ThemeContextValue = {
219
344
  selection,
220
345
  resolvedAppearance,
@@ -233,6 +358,9 @@ export function ThemeProvider(props: ThemeProviderProps) {
233
358
  registry: registry(),
234
359
  accentContrastFloor: local.accentContrastFloor,
235
360
  }),
361
+ effectiveSurface: () => committedSurface()?.surface ?? 'opaque',
362
+ reduceTransparencyActive: () => committedSurface()?.reduceTransparencyActive ?? false,
363
+ zoomFonts,
236
364
  setSelection(patch) {
237
365
  const next = mergeThemeSelection(selection(), patch)
238
366
  setSelectionState(next)
@@ -245,49 +373,3 @@ export function ThemeProvider(props: ThemeProviderProps) {
245
373
  const Context = themeContext()
246
374
  return <Context.Provider value={value}>{local.children}</Context.Provider>
247
375
  }
248
-
249
- /**
250
- * A string as a JavaScript literal that is also safe inside an inline `<script>`.
251
- *
252
- * `JSON.stringify` produces a correct JavaScript literal, and it is **not** safe
253
- * here: it leaves `<` and `>` alone, so a value containing `</script>` ends the tag
254
- * early and everything after it is parsed as HTML. That is the classic inline-script
255
- * injection, and it is a real one even though the value is a developer-supplied prop
256
- * rather than user input — a prop can come from configuration, and a key that happens
257
- * to contain a closing tag should not be able to execute.
258
- *
259
- * The escape is also correct JavaScript: `\u003c` is `<` to the parser, so the value
260
- * is byte-identical at runtime and inert to the HTML tokenizer. `\u2028` and `\u2029`
261
- * are escaped for the neighbouring reason — they are legal inside a JSON string and
262
- * were, before ES2019, line terminators to a JavaScript parser.
263
- *
264
- * CodeQL flags the unescaped form as `js/bad-code-sanitization`, which is how this
265
- * was found; `tests/theme-script.test.ts` pins it so it cannot come back.
266
- */
267
- export function inlineScriptLiteral(value: string): string {
268
- return safeScriptStringLiteral(value)
269
- }
270
-
271
- /**
272
- * The no-flash script.
273
- *
274
- * Rendered inline in `<head>` before any stylesheet that matters. Without it a dark
275
- * preference paints the light theme first and then swaps, which is the flash every
276
- * themed application has to solve and the one thing a provider cannot do from
277
- * inside the body.
278
- *
279
- * Deliberately tiny and dependency-free: it reads the stored preference, resolves
280
- * `system`, and sets the class. It does not apply the variant's roles — the default
281
- * theme in `theme.css` covers the first paint, and the provider writes the rest on
282
- * mount.
283
- *
284
- * The script reads two values out of `localStorage` and writes them to `dataset`,
285
- * which is a *property* assignment rather than code: a stored accent or font id can
286
- * set an attribute and cannot execute. The one value that reaches the source as text
287
- * is `storageKey`, and it goes through {@link inlineScriptLiteral} for the reason
288
- * documented there.
289
- */
290
- export function themeScript(storageKey = 'adea-appearance'): string {
291
- const script = `(function(){try{var s=localStorage.getItem(${inlineScriptLiteral(storageKey)});var p=s?JSON.parse(s):{};var a=p.appearance||'system';var d=a==='dark'||(a==='system'&&window.matchMedia('(prefers-color-scheme: dark)').matches);var r=document.documentElement;if(d)r.classList.add('dark');r.style.colorScheme=d?'dark':'light';if(p.accent&&p.accent!=='theme')r.dataset.accent=p.accent;if(p.density==='compact')r.dataset.density='compact';}catch(e){}})();`
292
- return `${script}${fontSettingsBootstrapScript(storageKey)}`
293
- }
@@ -0,0 +1,201 @@
1
+ import { fontSettingsBootstrapScript } from '#lib/appearance-font-settings'
2
+ import { isCustomAccent } from '#lib/accent-roles'
3
+ import { safeScriptStringLiteral } from '#lib/safe-script'
4
+ import { REDUCED_TRANSPARENCY_QUERY, surfaceAlpha } from '#lib/theme-surface'
5
+ import { accentPresets } from '#lib/tokens'
6
+ import { builtinThemes, defaultDarkThemeId, defaultLightThemeId } from '#lib/themes'
7
+ import type { ThemeStorageSchema } from './theme-persistence'
8
+
9
+ /**
10
+ * A string as a JavaScript literal that is also safe inside an inline `<script>`.
11
+ *
12
+ * `JSON.stringify` produces a correct JavaScript literal, and it is **not** safe
13
+ * here: it leaves `<` and `>` alone, so a value containing `</script>` ends the tag
14
+ * early and everything after it is parsed as HTML. That is the classic inline-script
15
+ * injection, and it is a real one even though the value is a developer-supplied prop
16
+ * rather than user input — a prop can come from configuration, and a key that happens
17
+ * to contain a closing tag should not be able to execute.
18
+ *
19
+ * The escape is also correct JavaScript: `\u003c` is `<` to the parser, so the value
20
+ * is byte-identical at runtime and inert to the HTML tokenizer. `\u2028` and `\u2029`
21
+ * are escaped for the neighbouring reason — they are legal inside a JSON string and
22
+ * were, before ES2019, line terminators to a JavaScript parser.
23
+ *
24
+ * CodeQL flags the unescaped form as `js/bad-code-sanitization`, which is how this
25
+ * was found; `tests/theme-script.test.ts` pins it so it cannot come back.
26
+ */
27
+ export function inlineScriptLiteral(value: string): string {
28
+ return safeScriptStringLiteral(value)
29
+ }
30
+
31
+ /**
32
+ * Options for the host-shaped no-flash script: the document's {@link ThemeStorageSchema}
33
+ * plus what the script paints from it.
34
+ */
35
+ export type ThemeScriptOptions = ThemeStorageSchema & {
36
+ /**
37
+ * The accent painted when the document has none, an unrecognised one, or the
38
+ * legacy `theme` spelling — the host's default (adea: `violet`). Without it those
39
+ * cases paint no `data-accent`, which is what the provider writes for `theme`.
40
+ * Setting it means the `[data-accent]` block of the default preset paints on the
41
+ * first frame instead of after hydration.
42
+ */
43
+ defaultAccent?: string
44
+ /**
45
+ * Also resolve the surface axis (`data-surface`, `data-reduce-transparency`,
46
+ * `--surface-alpha`) from the document and the operating system's Reduce
47
+ * Transparency setting. A `translucent` preference resolves to `frosted`: the
48
+ * host's translucency capability is not known until the provider mounts, and the
49
+ * provider re-resolves it then. Match the provider's `surface` prop.
50
+ */
51
+ surface?: boolean
52
+ /**
53
+ * Match the provider's `palette` prop. The attributes written are the same either
54
+ * way: a catalogue variant's palette is the host stylesheet's job (`stylesheet`) or
55
+ * the provider's, at mount (`inline`), and a stored theme's own tokens are written
56
+ * inline in both.
57
+ */
58
+ palette?: 'inline' | 'stylesheet'
59
+ /** The only `version` of the library document read. Default `1`. */
60
+ libraryVersion?: number
61
+ }
62
+
63
+ /**
64
+ * The no-flash script.
65
+ *
66
+ * Rendered inline in `<head>` before any stylesheet that matters. Without it a dark
67
+ * preference paints the light theme first and then swaps, which is the flash every
68
+ * themed application has to solve and the one thing a provider cannot do from
69
+ * inside the body.
70
+ *
71
+ * ## The string form
72
+ *
73
+ * `themeScript('key')` serves a provider that stores an unversioned
74
+ * `localStorage[key]` document. Deliberately tiny and dependency-free: it reads the
75
+ * stored preference, resolves `system`, and sets the class. It does not apply the
76
+ * variant's roles — the default theme in `theme.css` covers the first paint, and the
77
+ * provider writes the rest on mount. Its output is a compatibility surface (a
78
+ * consumer may have it in a snapshot), so it does not change.
79
+ *
80
+ * ## The options form
81
+ *
82
+ * `themeScript({ key, ... })` serves a host with a versioned document (see
83
+ * `createVersionedPersistence`) and writes what the provider will write, so mounting
84
+ * repaints nothing:
85
+ *
86
+ * - `class="dark"`, `color-scheme`, `data-theme`, `data-appearance` and, for a
87
+ * compact document, `data-density`;
88
+ * - `data-accent`: a preset id as stored, `custom` for a `#rrggbb` (never the hex
89
+ * itself, which is the worst possible attribute content), `defaultAccent` when the
90
+ * document has none, nothing for a theme-derived `ansi-*` accent (it has no
91
+ * stylesheet block; the provider paints it inline);
92
+ * - the theme: the stored id when the catalogue or the stored library holds it, else
93
+ * the default of the resolved appearance, exactly as the provider resolves it. A
94
+ * library theme's own tokens are written inline, so an imported theme paints on the
95
+ * first frame;
96
+ * - the surface axis, when `surface` is set;
97
+ * - the font settings, guarded to the document's `version`.
98
+ *
99
+ * The document's `version`, when the schema names one, must match exactly. The legacy
100
+ * key is read only while the main key is absent: an unreadable main document is not
101
+ * "no preference", so it does not resurrect an older value.
102
+ *
103
+ * The script reads values out of `localStorage` and writes them to `dataset` and
104
+ * `style`, which are *property* assignments rather than code: a stored id, accent or
105
+ * token can set an attribute and cannot execute. The values that reach the source as
106
+ * text (keys, ids, the default accent) go through {@link inlineScriptLiteral} for the
107
+ * reason documented there.
108
+ */
109
+ export function themeScript(storageKey?: string): string
110
+ export function themeScript(options: ThemeScriptOptions): string
111
+ export function themeScript(input: string | ThemeScriptOptions = 'adea-appearance'): string {
112
+ if (typeof input === 'string') return stringFormScript(input)
113
+ return optionsFormScript(input)
114
+ }
115
+
116
+ function stringFormScript(storageKey: string): string {
117
+ const script = `(function(){try{var s=localStorage.getItem(${inlineScriptLiteral(storageKey)});var p=s?JSON.parse(s):{};var a=p.appearance||'system';var d=a==='dark'||(a==='system'&&window.matchMedia('(prefers-color-scheme: dark)').matches);var r=document.documentElement;if(d)r.classList.add('dark');r.style.colorScheme=d?'dark':'light';if(p.accent&&p.accent!=='theme')r.dataset.accent=p.accent;if(p.density==='compact')r.dataset.density='compact';}catch(e){}})();`
118
+ return `${script}${fontSettingsBootstrapScript(storageKey)}`
119
+ }
120
+
121
+ const literal = safeScriptStringLiteral
122
+
123
+ function optionsFormScript(options: ThemeScriptOptions): string {
124
+ const field = options.appearanceField ?? 'appearance'
125
+ const versionGuard =
126
+ options.version === undefined ? '' : `&&x.version===${Number(options.version)}`
127
+ const presets = accentPresets
128
+ .filter((preset) => preset.id !== 'theme')
129
+ .map((preset) => literal(preset.id))
130
+ const defaultAccent =
131
+ options.defaultAccent === undefined
132
+ ? ''
133
+ : isCustomAccent(options.defaultAccent)
134
+ ? 'custom'
135
+ : options.defaultAccent
136
+
137
+ const parts: string[] = []
138
+ parts.push(
139
+ `(function(){try{`,
140
+ `var r=document.documentElement,s=r.style;`,
141
+ `var q=function(m){try{return window.matchMedia(m).matches}catch(e){return false}};`,
142
+ `var get=function(k){try{return localStorage.getItem(k)}catch(e){return null}};`,
143
+ // The main document. `raw !== null` rather than truthiness: an empty string is a
144
+ // stored (corrupt) value, so it does not fall through to the legacy key.
145
+ `var raw=get(${literal(options.key)}),doc=null,legacy=null;`,
146
+ `if(raw!==null){try{var x=JSON.parse(raw);if(x&&typeof x==='object'&&!Array.isArray(x)${versionGuard})doc=x}catch(e){}}`
147
+ )
148
+ if (options.legacyAppearanceKey !== undefined) {
149
+ parts.push(`else legacy=get(${literal(options.legacyAppearanceKey)});`)
150
+ }
151
+ parts.push(
152
+ `var m=doc?doc[${literal(field)}]:legacy;`,
153
+ `var mode=m==='light'||m==='dark'?m:'system';`,
154
+ `var dark=mode==='dark'||(mode==='system'&&q('(prefers-color-scheme: dark)'));`,
155
+ `var fallback=dark?${literal(defaultDarkThemeId)}:${literal(defaultLightThemeId)};`,
156
+ `var want=doc?(dark?doc.darkThemeId:doc.lightThemeId):null;`,
157
+ `if(typeof want!=='string')want=fallback;`,
158
+ `var ids=[${builtinThemes.map((theme) => literal(theme.id)).join(',')}];`,
159
+ `var id=ids.indexOf(want)!==-1?want:null,tokens=null;`
160
+ )
161
+ if (options.libraryKey !== undefined) {
162
+ parts.push(
163
+ `if(id===null){try{var lib=JSON.parse(get(${literal(options.libraryKey)})||'null');`,
164
+ `if(lib&&typeof lib==='object'&&lib.version===${Number(options.libraryVersion ?? 1)}&&Array.isArray(lib.themes)){`,
165
+ `for(var i=0;i!==lib.themes.length;i++){var t=lib.themes[i];`,
166
+ `if(t&&t.id===want&&t.flatTokens&&typeof t.flatTokens==='object'){id=want;tokens=t.flatTokens;break}}}}catch(e){}}`
167
+ )
168
+ }
169
+ parts.push(
170
+ `if(id===null)id=fallback;`,
171
+ `r.classList.toggle('dark',dark);`,
172
+ `s.colorScheme=dark?'dark':'light';`,
173
+ `r.dataset.theme=id;`,
174
+ `r.dataset.appearance=dark?'dark':'light';`
175
+ )
176
+ if (options.libraryKey !== undefined) {
177
+ parts.push(
178
+ `if(tokens){for(var n in tokens){if(Object.prototype.hasOwnProperty.call(tokens,n)&&n.slice(0,2)==='--'&&typeof tokens[n]==='string')s.setProperty(n,tokens[n])}}`
179
+ )
180
+ }
181
+ parts.push(
182
+ `var a=doc&&typeof doc.accent==='string'?doc.accent:'';`,
183
+ `var accent=[${presets.join(',')}].indexOf(a)!==-1?a:/^#[0-9a-fA-F]{6}$/.test(a)?'custom':a.slice(0,5)==='ansi-'?'':${literal(defaultAccent)};`,
184
+ `if(accent)r.dataset.accent=accent;`,
185
+ `if(doc&&doc.density==='compact')r.dataset.density='compact';`
186
+ )
187
+ if (options.surface) {
188
+ parts.push(
189
+ // Reduce Transparency forces the opaque surface; a translucent request resolves
190
+ // to frosted because the host's capability is unknown before the provider mounts.
191
+ `var reduce=q(${literal(REDUCED_TRANSPARENCY_QUERY)})||!!(doc&&doc.reduceTransparency===true);`,
192
+ `var surface=reduce?'opaque':doc&&(doc.surface==='frosted'||doc.surface==='translucent')?'frosted':'opaque';`,
193
+ `r.dataset.surface=surface;`,
194
+ `r.dataset.reduceTransparency=reduce?'true':'false';`,
195
+ `s.setProperty('--surface-alpha',${JSON.stringify(surfaceAlpha)}[surface]);`
196
+ )
197
+ }
198
+ parts.push(`}catch(e){}})();`)
199
+
200
+ return `${parts.join('')}${fontSettingsBootstrapScript(options.key, options.version)}`
201
+ }
@@ -5,6 +5,8 @@ import {
5
5
  } from '#lib/appearance-font-settings'
6
6
  import type { AccentPreset } from '@adea-ai/themes'
7
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'
8
10
  import {
9
11
  accentRolesForSelection,
10
12
  defaultDarkThemeId,
@@ -63,6 +65,30 @@ export type ThemeSelection = {
63
65
  * keeps working and `ThemeSelection` stays assignable without it.
64
66
  */
65
67
  density?: ThemeDensity
68
+ /**
69
+ * The terminal either follows the interface theme (`theme`, or absent) or is pinned
70
+ * to another theme's terminal palette by that theme's id. The pin is written over
71
+ * the interface variant's own `--terminal-*` properties, so a light interface can
72
+ * carry a dark terminal.
73
+ *
74
+ * An id the registry does not hold paints the interface palette and stays in the
75
+ * selection, the same rule as an unknown theme id.
76
+ *
77
+ * Optional and not defaulted: see {@link defaultThemeSelection}.
78
+ */
79
+ terminalThemeId?: string
80
+ /**
81
+ * How opaque elevated panels are. Written to `<html>` only when the provider's
82
+ * `surface` prop is on or this is set; absent, a host paints no surface axis at
83
+ * all. Resolved against the OS and the host's capability before it is painted —
84
+ * see `resolveSurface` — so the value stored is a preference, never the outcome.
85
+ */
86
+ surface?: ThemeSurface
87
+ /**
88
+ * The user's own Reduce Transparency switch, which forces the `opaque` surface the
89
+ * same way the operating system's setting does.
90
+ */
91
+ reduceTransparency?: boolean
66
92
  }
67
93
 
68
94
  /**
@@ -143,6 +169,24 @@ export type ThemeContextValue = {
143
169
  * registry does not hold: each means "keep the inherited accent".
144
170
  */
145
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
146
190
  }
147
191
 
148
192
  /** A selection with the `undefined` keys of a patch left out. */
@@ -231,6 +275,34 @@ export function resolveThemeState(
231
275
  }
232
276
  }
233
277
 
278
+ /**
279
+ * The variant whose terminal palette is pinned, or `undefined` when the terminal
280
+ * follows the interface theme.
281
+ *
282
+ * `theme`, an absent id and an id the registry does not hold all mean the interface
283
+ * palette: a terminal must never resolve to a blank or half-painted palette because a
284
+ * theme was deleted after it was pinned.
285
+ */
286
+ export function resolveTerminalVariant(
287
+ terminalThemeId: string | undefined,
288
+ registry: readonly ThemeVariant[]
289
+ ): ThemeVariant | undefined {
290
+ if (terminalThemeId === undefined || terminalThemeId === 'theme') return undefined
291
+ return registry.find((candidate) => candidate.id === terminalThemeId)
292
+ }
293
+
294
+ /**
295
+ * The font settings a zoom chord asks for: one pixel up or down on every axis, or
296
+ * the defaults for `reset`.
297
+ */
298
+ export function zoomedFontSettings(
299
+ selection: ThemeSelection,
300
+ intent: FontZoomIntent
301
+ ): AppearanceEditorFontSettings {
302
+ if (intent === 'reset') return { ...DEFAULT_APPEARANCE_EDITOR_FONT_SETTINGS }
303
+ return stepAppearanceFontSizes(selection.fonts, intent === 'in' ? 1 : -1)
304
+ }
305
+
234
306
  /**
235
307
  * The registry a provider resolves against: the catalogue, then the host's extras.
236
308
  *
package/src/index.ts CHANGED
@@ -141,9 +141,11 @@ export {
141
141
  contrastRatio,
142
142
  defaultDarkThemeId,
143
143
  defaultLightThemeId,
144
+ editorPaletteFor,
144
145
  isThemeAccentId,
145
146
  resolveAccentPreset,
146
147
  resolveVariant,
148
+ terminalCssVariables,
147
149
  themeAccentsFor,
148
150
  themeById,
149
151
  themeCssVariables,
@@ -156,14 +158,41 @@ export {
156
158
  validateThemeRegistry,
157
159
  variantFromRecord,
158
160
  withAccent,
161
+ type EditorRolesMode,
159
162
  type ThemeAppearance,
160
163
  type ThemeColors,
164
+ type ThemeCssVariableOptions,
161
165
  type ThemeEditorPalette,
162
166
  type ThemeFinding,
163
167
  type ThemeProvenance,
164
168
  type ThemeTerminalPalette,
165
169
  type ThemeVariant,
166
170
  } from './lib/themes'
171
+ export { fontZoomShortcut, stepAppearanceFontSizes, type FontZoomIntent } from './lib/font-zoom'
172
+ export {
173
+ REDUCED_TRANSPARENCY_QUERY,
174
+ normalizeSurface,
175
+ resolveSurface,
176
+ resolveSurfaceState,
177
+ surfaceAlpha,
178
+ themeSurfaces,
179
+ type SurfaceEnvironment,
180
+ type SurfaceState,
181
+ } from './lib/theme-surface'
182
+ export {
183
+ defaultPreferenceStorage,
184
+ quarantineRecord,
185
+ readLegacyValue,
186
+ readVersionedRecord,
187
+ recoveryEnvelope,
188
+ recoveryKeyFor,
189
+ writeVersionedRecord,
190
+ type PreferenceStorage,
191
+ type RecoveryEnvelope,
192
+ type RecoveryReason,
193
+ type VersionedRead,
194
+ type VersionedRecordSchema,
195
+ } from './lib/preference-store'
167
196
  export { keyedRows, type KeyedRow } from './lib/keyed-rows'
168
197
  export { writeClipboardText } from './lib/clipboard'
169
198
  export { formatBytes, formatReleaseDate, plainTextFromMarkdown } from './lib/version-notes'
@@ -0,0 +1,74 @@
1
+ /*
2
+ * Copyright (c) 2026 Wing
3
+ * Licensed under the MIT License.
4
+ *
5
+ * The platform zoom chords, mapped onto Adea's three text roles.
6
+ *
7
+ * macOS (and Windows/Linux) zoom shortcuts — Cmd/Ctrl with `=`, `+`, `-`, or
8
+ * `0` — normally scale the browser's page zoom. In Adea every text element is
9
+ * already tied to one of the three appearance font tiers (the UI tier drives
10
+ * the whole `text-*` scale through `--font-ui-scale`; content and code roles
11
+ * scale through their own tokens), so stepping those tiers reproduces zoom
12
+ * while keeping the app inside its tokenized, persisted type system: the page
13
+ * layout, menus, sheets, and terminals reflow live from the same projection
14
+ * the appearance editor writes.
15
+ *
16
+ * Translated from Adea's accepted port (`packages/ui/src/components/font-zoom.ts`) so
17
+ * the shared provider and Adea run one implementation. See NOTICE.
18
+ */
19
+ import {
20
+ APPEARANCE_EDITOR_FONT_AXES,
21
+ APPEARANCE_EDITOR_FONT_SIZE_MAX,
22
+ APPEARANCE_EDITOR_FONT_SIZE_MIN,
23
+ normalizeAppearanceEditorFontSettings,
24
+ type AppearanceEditorFontSettings,
25
+ } from '#lib/appearance-font-settings'
26
+
27
+ /** What the user's zoom chord asked for. */
28
+ export type FontZoomIntent = 'in' | 'out' | 'reset'
29
+
30
+ /**
31
+ * Classify the platform zoom chord: Cmd (macOS) or Ctrl (other platforms)
32
+ * with `=`/`+` zooming in, `-` zooming out, and `0` resetting to the System
33
+ * defaults. Unmodified keys, Alt-modified chords, and IME composition never
34
+ * count, so typing and text editing are untouched.
35
+ */
36
+ export function fontZoomShortcut(event: {
37
+ key: string
38
+ metaKey: boolean
39
+ ctrlKey: boolean
40
+ altKey: boolean
41
+ isComposing?: boolean
42
+ }): FontZoomIntent | undefined {
43
+ if (event.isComposing || event.altKey) return undefined
44
+ if (!(event.metaKey || event.ctrlKey)) return undefined
45
+ if (event.key === '=' || event.key === '+') return 'in'
46
+ if (event.key === '-') return 'out'
47
+ if (event.key === '0') return 'reset'
48
+ return undefined
49
+ }
50
+
51
+ /** Clamp a pixel size to the shared range the appearance editor enforces. */
52
+ function clampFontSize(size: number): number {
53
+ return Math.min(APPEARANCE_EDITOR_FONT_SIZE_MAX, Math.max(APPEARANCE_EDITOR_FONT_SIZE_MIN, size))
54
+ }
55
+
56
+ /**
57
+ * Step every text tier by `delta` pixels, clamped to that range. The axes move
58
+ * together — that is what makes the chord read as zoom — and each keeps its
59
+ * own family choice.
60
+ */
61
+ export function stepAppearanceFontSizes(
62
+ fonts: unknown,
63
+ delta: 1 | -1
64
+ ): AppearanceEditorFontSettings {
65
+ const current = normalizeAppearanceEditorFontSettings(fonts).settings
66
+ return {
67
+ ui: { ...current.ui, size: clampFontSize(current.ui.size + delta) },
68
+ content: { ...current.content, size: clampFontSize(current.content.size + delta) },
69
+ code: { ...current.code, size: clampFontSize(current.code.size + delta) },
70
+ }
71
+ }
72
+
73
+ /** Every axis name, for callers that want to report or test the stepping. */
74
+ export const FONT_ZOOM_AXES = APPEARANCE_EDITOR_FONT_AXES