@adea-ai/ui 0.124.0 → 0.127.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 (82) hide show
  1. package/dist/NOTICE +58 -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 +31 -1
  6. package/dist/components/theme/theme-document.d.ts.map +1 -1
  7. package/dist/components/theme/theme-document.js +25 -5
  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 +65 -3
  12. package/dist/components/theme/theme-persistence.js.map +1 -1
  13. package/dist/components/theme/theme-provider.d.ts +49 -40
  14. package/dist/components/theme/theme-provider.d.ts.map +1 -1
  15. package/dist/components/theme/theme-provider.js +67 -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 +85 -2
  22. package/dist/components/theme/theme-state.d.ts.map +1 -1
  23. package/dist/components/theme/theme-state.js +41 -2
  24. package/dist/components/theme/theme-state.js.map +1 -1
  25. package/dist/index.d.ts +5 -1
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +9 -4
  28. package/dist/lib/accent-roles.d.ts +124 -0
  29. package/dist/lib/accent-roles.d.ts.map +1 -0
  30. package/dist/lib/accent-roles.js +163 -0
  31. package/dist/lib/accent-roles.js.map +1 -0
  32. package/dist/lib/font-zoom.d.ts +25 -0
  33. package/dist/lib/font-zoom.d.ts.map +1 -0
  34. package/dist/lib/font-zoom.js +45 -0
  35. package/dist/lib/font-zoom.js.map +1 -0
  36. package/dist/lib/preference-store.d.ts +126 -0
  37. package/dist/lib/preference-store.d.ts.map +1 -0
  38. package/dist/lib/preference-store.js +126 -0
  39. package/dist/lib/preference-store.js.map +1 -0
  40. package/dist/lib/theme-surface.d.ts +71 -0
  41. package/dist/lib/theme-surface.d.ts.map +1 -0
  42. package/dist/lib/theme-surface.js +62 -0
  43. package/dist/lib/theme-surface.js.map +1 -0
  44. package/dist/lib/themes.d.ts +32 -1
  45. package/dist/lib/themes.d.ts.map +1 -1
  46. package/dist/lib/themes.js +34 -13
  47. package/dist/lib/themes.js.map +1 -1
  48. package/dist/lib/tokens.d.ts.map +1 -1
  49. package/dist/lib/tokens.js +15 -0
  50. package/dist/lib/tokens.js.map +1 -1
  51. package/dist/r/lib.json +20 -0
  52. package/dist/r/registry.json +25 -0
  53. package/dist/r/src/components/theme/index.ts +6 -0
  54. package/dist/r/src/components/theme/theme-document.ts +69 -4
  55. package/dist/r/src/components/theme/theme-persistence.ts +172 -2
  56. package/dist/r/src/components/theme/theme-provider.tsx +137 -51
  57. package/dist/r/src/components/theme/theme-script.ts +201 -0
  58. package/dist/r/src/components/theme/theme-state.ts +124 -3
  59. package/dist/r/src/lib/accent-roles.ts +338 -0
  60. package/dist/r/src/lib/font-zoom.ts +74 -0
  61. package/dist/r/src/lib/preference-store.ts +216 -0
  62. package/dist/r/src/lib/theme-surface.ts +110 -0
  63. package/dist/r/src/lib/themes.ts +58 -19
  64. package/dist/r/src/lib/tokens.ts +17 -0
  65. package/dist/r/src/styles/theme.css +38 -0
  66. package/dist/r/theming.json +5 -0
  67. package/package.json +1 -1
  68. package/registry.json +25 -0
  69. package/src/components/theme/index.ts +6 -0
  70. package/src/components/theme/theme-document.ts +69 -4
  71. package/src/components/theme/theme-persistence.ts +172 -2
  72. package/src/components/theme/theme-provider.tsx +137 -51
  73. package/src/components/theme/theme-script.ts +201 -0
  74. package/src/components/theme/theme-state.ts +124 -3
  75. package/src/index.ts +34 -0
  76. package/src/lib/accent-roles.ts +338 -0
  77. package/src/lib/font-zoom.ts +74 -0
  78. package/src/lib/preference-store.ts +216 -0
  79. package/src/lib/theme-surface.ts +110 -0
  80. package/src/lib/themes.ts +58 -19
  81. package/src/lib/tokens.ts +17 -0
  82. package/src/styles/theme.css +38 -0
@@ -4,10 +4,14 @@ import {
4
4
  } from '../../lib/appearance-font-settings'
5
5
  import {
6
6
  accentVariables,
7
+ terminalCssVariables,
8
+ themeById,
7
9
  themeCssVariables,
8
10
  type ThemeAppearance,
9
11
  type ThemeVariant,
10
12
  } from '../../lib/themes'
13
+ import { isCustomAccent } from '../../lib/accent-roles'
14
+ import { surfaceAlpha, type SurfaceState } from '../../lib/theme-surface'
11
15
  import { resolvedDensity, type ThemeSelection } from './theme-state'
12
16
 
13
17
  /**
@@ -26,6 +30,16 @@ export type ThemeDocumentState = {
26
30
  selection: ThemeSelection
27
31
  appearance: ThemeAppearance
28
32
  variant: ThemeVariant
33
+ /**
34
+ * The variant whose terminal palette is pinned over the interface variant's.
35
+ * Absent, the terminal follows the interface theme.
36
+ */
37
+ terminalVariant?: ThemeVariant
38
+ /**
39
+ * The resolved surface axis. Absent, the axis is off: no attribute and no
40
+ * `--surface-alpha` is written, and any this writer wrote before is removed.
41
+ */
42
+ surface?: SurfaceState
29
43
  }
30
44
 
31
45
  export type ThemeDocumentOptions = {
@@ -35,11 +49,35 @@ export type ThemeDocumentOptions = {
35
49
  * must return the full map; a name it drops is not written.
36
50
  */
37
51
  transformTokens?: (tokens: Record<string, string>) => Record<string, string>
52
+ /**
53
+ * The contrast an accent is held to against the variant's background. Absent, a
54
+ * preset is painted verbatim (the catalogue measured it for its own themes) and
55
+ * only a custom `#rrggbb` is held to the default floor of 3:1.
56
+ */
57
+ accentContrastFloor?: number
58
+ /**
59
+ * Where a catalogue variant's palette comes from. `inline` (the default) writes
60
+ * every role onto the root. `stylesheet` writes none for a catalogue variant,
61
+ * because the host loads a stylesheet that carries a `[data-theme]` block for it;
62
+ * a variant the catalogue does not hold (a host's imported theme) is still written
63
+ * inline, since no stylesheet can know it. The accent, the terminal pin, the
64
+ * surface alpha and the fonts are written the same in both.
65
+ *
66
+ * Switching from an imported variant back to a catalogue one removes what the
67
+ * imported one wrote, so nothing inline outlives it to beat the stylesheet.
68
+ */
69
+ palette?: 'inline' | 'stylesheet'
70
+ }
71
+
72
+ /** True for the catalogue's own variant, not a host's copy that merely shares its id. */
73
+ function isCatalogueVariant(variant: ThemeVariant): boolean {
74
+ return themeById(variant.id) === variant
38
75
  }
39
76
 
40
77
  /**
41
78
  * The custom properties a state paints: the variant's roles, with the accent
42
- * selection laid over them, then `transformTokens`.
79
+ * selection laid over them, the pinned terminal palette over that, the surface
80
+ * alpha, then `transformTokens`.
43
81
  *
44
82
  * The accent is applied *here* rather than left to the `[data-accent]` blocks in
45
83
  * `theme.css`, because the properties are written inline and an inline declaration
@@ -52,9 +90,20 @@ export function themeDocumentTokens(
52
90
  state: ThemeDocumentState,
53
91
  options: ThemeDocumentOptions = {}
54
92
  ): { tokens: Record<string, string>; accented: boolean } {
55
- const properties = { ...themeCssVariables(state.variant) }
56
- const accent = accentVariables(state.selection.accent, state.appearance, state.selection)
93
+ const fromStylesheet = options.palette === 'stylesheet' && isCatalogueVariant(state.variant)
94
+ const properties = fromStylesheet ? {} : { ...themeCssVariables(state.variant) }
95
+ const accent = accentVariables(state.selection.accent, state.appearance, state.selection, {
96
+ background: state.variant.colors.background,
97
+ floor: options.accentContrastFloor,
98
+ variantId: state.variant.id,
99
+ })
57
100
  if (accent) Object.assign(properties, accent)
101
+ // Written after the variant and the accent, so it wins over the interface
102
+ // variant's own terminal ramp (and over the stylesheet's, which it outranks as an
103
+ // inline declaration). Dropping the pin needs no removal of its own: the ramp is
104
+ // then rewritten by the variant, or swept back to the stylesheet.
105
+ if (state.terminalVariant) Object.assign(properties, terminalCssVariables(state.terminalVariant))
106
+ if (state.surface) properties['--surface-alpha'] = surfaceAlpha[state.surface.surface]
58
107
  return {
59
108
  tokens: options.transformTokens ? options.transformTokens(properties) : properties,
60
109
  accented: accent !== undefined,
@@ -92,8 +141,24 @@ export function applyThemeToRoot(
92
141
  root.dataset['appearance'] = state.appearance
93
142
  root.style.colorScheme = state.appearance
94
143
 
144
+ // A custom colour has no stylesheet block to select, and its value is the
145
+ // attribute's worst possible content, so it is reported as `custom`.
95
146
  if (!accented) root.removeAttribute('data-accent')
96
- else root.dataset['accent'] = state.selection.accent
147
+ else
148
+ root.dataset['accent'] = isCustomAccent(state.selection.accent)
149
+ ? 'custom'
150
+ : state.selection.accent
151
+
152
+ // The surface axis is opt-in, so its absence removes rather than defaults: a host
153
+ // that never enables it keeps an `<html>` with neither attribute, and one that
154
+ // turns it off leaves nothing behind. (`--surface-alpha` is swept with the tokens.)
155
+ if (state.surface) {
156
+ root.dataset['surface'] = state.surface.surface
157
+ root.dataset['reduceTransparency'] = state.surface.reduceTransparencyActive ? 'true' : 'false'
158
+ } else {
159
+ root.removeAttribute('data-surface')
160
+ root.removeAttribute('data-reduce-transparency')
161
+ }
97
162
 
98
163
  applyAppearanceFontSettings(
99
164
  root,
@@ -1,6 +1,16 @@
1
+ import { normalizeCustomAccent } from '../../lib/accent-roles'
1
2
  import { accentPresets } from '../../lib/tokens'
2
3
  import { normalizeAppearanceEditorFontSettings } from '../../lib/appearance-font-settings'
3
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'
4
14
  import { defaultThemeSelection, resolvedDensity, type ThemeSelection } from './theme-state'
5
15
 
6
16
  /**
@@ -67,19 +77,32 @@ function readSelection(storageKey: string): ThemeSelection {
67
77
  : defaultDarkThemeId,
68
78
  // A theme accent is kept whenever it names a slot, even one the stored pair
69
79
  // cannot offer: the id is role-shaped so it survives a theme switch, and an
70
- // unoffered slot already resolves to the theme's own primary when applied.
80
+ // unoffered slot already resolves to the theme's own primary when applied. A
81
+ // custom `#rrggbb` is kept in its one stored spelling; its readability is
82
+ // settled when it is painted, not when it is read.
71
83
  accent:
72
84
  typeof parsed.accent === 'string' &&
73
85
  (accentPresets.some((preset) => preset.id === parsed.accent) ||
74
86
  isThemeAccentId(parsed.accent))
75
87
  ? parsed.accent
76
- : defaultThemeSelection.accent,
88
+ : (normalizeCustomAccent(parsed.accent) ?? defaultThemeSelection.accent),
77
89
  font: fonts.ui.family,
78
90
  fonts,
79
91
  // Every axis has to be read back here, or it is lost twice: the provider's
80
92
  // document effect removes the `data-density` that `themeScript` set for the first paint, and
81
93
  // the next `setSelection` rewrites the store without it.
82
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,
83
106
  }
84
107
  } catch {
85
108
  return defaultThemeSelection
@@ -138,3 +161,150 @@ export function writePersisted(persistence: ThemePersistence, next: ThemeSelecti
138
161
  // session and does not survive the reload.
139
162
  }
140
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
+ }
@@ -1,7 +1,7 @@
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,
@@ -17,16 +17,28 @@ import {
17
17
  type ThemePersistence,
18
18
  } from './theme-persistence'
19
19
  import {
20
+ accentRolesForState,
20
21
  initialThemeSelection,
21
22
  mergeRegistry,
22
23
  mergeThemeSelection,
24
+ resolveTerminalVariant,
23
25
  resolveThemeState,
26
+ zoomedFontSettings,
24
27
  type ThemeContextValue,
25
28
  type ThemeSelection,
26
29
  } from './theme-state'
27
30
 
28
31
  export { useOptionalTheme, useTheme } from './theme-context'
29
- export { localStoragePersistence, type ThemePersistence } from './theme-persistence'
32
+ export {
33
+ createVersionedPersistence,
34
+ localStoragePersistence,
35
+ type ThemePersistence,
36
+ type ThemeStorageSchema,
37
+ type VersionedCodec,
38
+ type VersionedPersistenceOptions,
39
+ } from './theme-persistence'
40
+ export type { ThemeSurface } from '../../lib/theme-surface'
41
+ export { inlineScriptLiteral, themeScript, type ThemeScriptOptions } from './theme-script'
30
42
  export {
31
43
  defaultThemeSelection,
32
44
  resolvedDensity,
@@ -111,6 +123,57 @@ export type ThemeProviderProps = {
111
123
  * element to `<head>` for two frames on every change.
112
124
  */
113
125
  disableTransitionOnChange?: boolean
126
+ /**
127
+ * The contrast ratio an accent is held to against the variant's background,
128
+ * repaired to the nearest passing colour when it falls short. Absent, presets
129
+ * are painted verbatim — the catalogue measured them for its own themes — and
130
+ * only a custom `#rrggbb` is held to 3:1. A host that adds themes the catalogue
131
+ * never measured passes 3 to hold the presets to it as well; for the catalogue's
132
+ * own themes the floor changes no preset.
133
+ */
134
+ accentContrastFloor?: number
135
+ /**
136
+ * Turns the surface axis on: `selection.surface` and `selection.reduceTransparency`
137
+ * are resolved against the operating system's Reduce Transparency setting and the
138
+ * host's capability, and `data-surface`, `data-reduce-transparency` and
139
+ * `--surface-alpha` are written to `<html>` (the frost rule in `theme.css` reads
140
+ * them). Off by default: a provider that never opts in writes none of them, adds no
141
+ * media-query listener, and stores nothing new.
142
+ *
143
+ * An object also states the host's capability. `nativeTranslucency` is whether the
144
+ * window can render the OS's own translucency; without it a `translucent`
145
+ * preference paints the tokenized `frosted` surface instead of pretending to
146
+ * vibrancy. A function is read inside the provider's reactive scope, for a
147
+ * capability that is learned after mount.
148
+ *
149
+ * A selection that already carries a `surface` turns the axis on for itself, so a
150
+ * host that stores the field gets the same document without the prop.
151
+ */
152
+ surface?: boolean | { nativeTranslucency?: boolean | (() => boolean) }
153
+ /**
154
+ * Where a catalogue variant's palette comes from. `inline` (the default) writes
155
+ * every role onto `<html>`. `stylesheet` leaves a catalogue variant to the host's
156
+ * `[data-theme]` stylesheet and writes inline only what no stylesheet can know: a
157
+ * host's own `themes`, the accent, the pinned terminal palette, the surface alpha
158
+ * and the fonts. Switching from a host variant back to a catalogue one removes the
159
+ * properties the host variant wrote.
160
+ */
161
+ palette?: 'inline' | 'stylesheet'
162
+ /**
163
+ * Bind the platform zoom chords (Cmd or Ctrl with `=`, `+`, `-`, `0`) to the three
164
+ * text sizes instead of the browser's page zoom: stepping them together is zoom
165
+ * inside the persisted type system. Off by default: it adds a capturing `keydown`
166
+ * listener to `window`, and an event already handled elsewhere is left alone.
167
+ * `zoomFonts` on the context does the same from a menu or a host's own binding.
168
+ */
169
+ fontZoom?: boolean
170
+ }
171
+
172
+ /** The operating system's Reduce Transparency query, where there is a window to ask. */
173
+ function reducedTransparencyMedia(): MediaQueryList | undefined {
174
+ return typeof window !== 'undefined' && typeof window.matchMedia === 'function'
175
+ ? window.matchMedia(REDUCED_TRANSPARENCY_QUERY)
176
+ : undefined
114
177
  }
115
178
 
116
179
  export function ThemeProvider(props: ThemeProviderProps) {
@@ -123,6 +186,10 @@ export function ThemeProvider(props: ThemeProviderProps) {
123
186
  'themes',
124
187
  'transformTokens',
125
188
  'disableTransitionOnChange',
189
+ 'accentContrastFloor',
190
+ 'surface',
191
+ 'palette',
192
+ 'fontZoom',
126
193
  ])
127
194
  const storageKey = () => local.storageKey ?? 'adea-appearance'
128
195
  const persistence = local.persistence ?? localStoragePersistence(storageKey)
@@ -175,6 +242,37 @@ export function ThemeProvider(props: ThemeProviderProps) {
175
242
  draft() ? resolveThemeState(shownSelection(), systemAppearance(), registry()) : committed()
176
243
  )
177
244
 
245
+ // The surface axis. Off, none of this reads the operating system: the media query
246
+ // is subscribed only while the axis is on, so a provider that never opts in adds no
247
+ // listener and no repaint.
248
+ const surfaceOn = (candidate: ThemeSelection) =>
249
+ local.surface === true || typeof local.surface === 'object' || candidate.surface !== undefined
250
+ const nativeTranslucency = () => {
251
+ const capability = typeof local.surface === 'object' ? local.surface.nativeTranslucency : false
252
+ return (typeof capability === 'function' ? capability() : capability) === true
253
+ }
254
+ const [osReducedTransparency, setOsReducedTransparency] = createSignal(
255
+ surfaceOn(untrack(shownSelection)) ? (reducedTransparencyMedia()?.matches ?? false) : false
256
+ )
257
+ const axisOn = createMemo(() => surfaceOn(shownSelection()))
258
+ createEffect(() => {
259
+ const query = axisOn() ? reducedTransparencyMedia() : undefined
260
+ setOsReducedTransparency(query?.matches ?? false)
261
+ if (!query) return
262
+ const onChange = (event: MediaQueryListEvent) => setOsReducedTransparency(event.matches)
263
+ query.addEventListener('change', onChange)
264
+ onCleanup(() => query.removeEventListener('change', onChange))
265
+ })
266
+ const surfaceStateOf = (candidate: ThemeSelection) =>
267
+ resolveSurfaceState(candidate.surface, {
268
+ osReducedTransparency: osReducedTransparency(),
269
+ userReducedTransparency: candidate.reduceTransparency === true,
270
+ nativeTranslucency: nativeTranslucency(),
271
+ })
272
+ const committedSurface = createMemo(() =>
273
+ surfaceOn(selection()) ? surfaceStateOf(selection()) : undefined
274
+ )
275
+
178
276
  let written: ReadonlySet<string> | undefined
179
277
  let restoreTransitions: (() => void) | undefined
180
278
  createEffect(() => {
@@ -183,8 +281,14 @@ export function ThemeProvider(props: ThemeProviderProps) {
183
281
  selection: shownSelection(),
184
282
  appearance: shown().appearance,
185
283
  variant: shown().variant,
284
+ terminalVariant: resolveTerminalVariant(shownSelection().terminalThemeId, registry()),
285
+ surface: axisOn() ? surfaceStateOf(shownSelection()) : undefined,
286
+ }
287
+ const options = {
288
+ transformTokens: local.transformTokens,
289
+ accentContrastFloor: local.accentContrastFloor,
290
+ palette: local.palette,
186
291
  }
187
- const options = { transformTokens: local.transformTokens }
188
292
  const apply = () => {
189
293
  written = applyThemeToRoot(document.documentElement, state, options, written)
190
294
  }
@@ -201,6 +305,24 @@ export function ThemeProvider(props: ThemeProviderProps) {
201
305
  themeAccentsFor(selection().lightThemeId, selection().darkThemeId)
202
306
  )
203
307
 
308
+ const zoomFonts = (intent: FontZoomIntent) =>
309
+ value.setSelection({ fonts: zoomedFontSettings(selection(), intent) })
310
+
311
+ // The platform zoom chords drive the three text sizes, captured like the other
312
+ // global chords; an event something else already handled never double-steps.
313
+ const onKeyDown = (event: KeyboardEvent) => {
314
+ if (event.defaultPrevented) return
315
+ const intent = fontZoomShortcut(event)
316
+ if (!intent) return
317
+ event.preventDefault()
318
+ zoomFonts(intent)
319
+ }
320
+ createEffect(() => {
321
+ if (!local.fontZoom || typeof window === 'undefined') return
322
+ window.addEventListener('keydown', onKeyDown, { capture: true })
323
+ onCleanup(() => window.removeEventListener('keydown', onKeyDown, { capture: true }))
324
+ })
325
+
204
326
  const value: ThemeContextValue = {
205
327
  selection,
206
328
  resolvedAppearance,
@@ -212,6 +334,16 @@ export function ThemeProvider(props: ThemeProviderProps) {
212
334
  setDraft(next ? { ...next } : undefined)
213
335
  },
214
336
  previewing: () => draft() !== undefined,
337
+ accentRolesFor: (accent, variantId) =>
338
+ accentRolesForState(accent, variantId, {
339
+ selection: selection(),
340
+ variant: variant(),
341
+ registry: registry(),
342
+ accentContrastFloor: local.accentContrastFloor,
343
+ }),
344
+ effectiveSurface: () => committedSurface()?.surface ?? 'opaque',
345
+ reduceTransparencyActive: () => committedSurface()?.reduceTransparencyActive ?? false,
346
+ zoomFonts,
215
347
  setSelection(patch) {
216
348
  const next = mergeThemeSelection(selection(), patch)
217
349
  setSelectionState(next)
@@ -224,49 +356,3 @@ export function ThemeProvider(props: ThemeProviderProps) {
224
356
  const Context = themeContext()
225
357
  return <Context.Provider value={value}>{local.children}</Context.Provider>
226
358
  }
227
-
228
- /**
229
- * A string as a JavaScript literal that is also safe inside an inline `<script>`.
230
- *
231
- * `JSON.stringify` produces a correct JavaScript literal, and it is **not** safe
232
- * here: it leaves `<` and `>` alone, so a value containing `</script>` ends the tag
233
- * early and everything after it is parsed as HTML. That is the classic inline-script
234
- * injection, and it is a real one even though the value is a developer-supplied prop
235
- * rather than user input — a prop can come from configuration, and a key that happens
236
- * to contain a closing tag should not be able to execute.
237
- *
238
- * The escape is also correct JavaScript: `\u003c` is `<` to the parser, so the value
239
- * is byte-identical at runtime and inert to the HTML tokenizer. `\u2028` and `\u2029`
240
- * are escaped for the neighbouring reason — they are legal inside a JSON string and
241
- * were, before ES2019, line terminators to a JavaScript parser.
242
- *
243
- * CodeQL flags the unescaped form as `js/bad-code-sanitization`, which is how this
244
- * was found; `tests/theme-script.test.ts` pins it so it cannot come back.
245
- */
246
- export function inlineScriptLiteral(value: string): string {
247
- return safeScriptStringLiteral(value)
248
- }
249
-
250
- /**
251
- * The no-flash script.
252
- *
253
- * Rendered inline in `<head>` before any stylesheet that matters. Without it a dark
254
- * preference paints the light theme first and then swaps, which is the flash every
255
- * themed application has to solve and the one thing a provider cannot do from
256
- * inside the body.
257
- *
258
- * Deliberately tiny and dependency-free: it reads the stored preference, resolves
259
- * `system`, and sets the class. It does not apply the variant's roles — the default
260
- * theme in `theme.css` covers the first paint, and the provider writes the rest on
261
- * mount.
262
- *
263
- * The script reads two values out of `localStorage` and writes them to `dataset`,
264
- * which is a *property* assignment rather than code: a stored accent or font id can
265
- * set an attribute and cannot execute. The one value that reaches the source as text
266
- * is `storageKey`, and it goes through {@link inlineScriptLiteral} for the reason
267
- * documented there.
268
- */
269
- export function themeScript(storageKey = 'adea-appearance'): string {
270
- 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){}})();`
271
- return `${script}${fontSettingsBootstrapScript(storageKey)}`
272
- }