@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.
- package/dist/NOTICE +58 -0
- package/dist/components/theme/index.d.ts +1 -1
- package/dist/components/theme/index.d.ts.map +1 -1
- package/dist/components/theme/index.js +4 -3
- package/dist/components/theme/theme-document.d.ts +31 -1
- package/dist/components/theme/theme-document.d.ts.map +1 -1
- package/dist/components/theme/theme-document.js +25 -5
- package/dist/components/theme/theme-document.js.map +1 -1
- package/dist/components/theme/theme-persistence.d.ts +87 -0
- package/dist/components/theme/theme-persistence.d.ts.map +1 -1
- package/dist/components/theme/theme-persistence.js +65 -3
- package/dist/components/theme/theme-persistence.js.map +1 -1
- package/dist/components/theme/theme-provider.d.ts +49 -40
- package/dist/components/theme/theme-provider.d.ts.map +1 -1
- package/dist/components/theme/theme-provider.js +67 -52
- package/dist/components/theme/theme-provider.js.map +1 -1
- package/dist/components/theme/theme-script.d.ts +100 -0
- package/dist/components/theme/theme-script.d.ts.map +1 -0
- package/dist/components/theme/theme-script.js +57 -0
- package/dist/components/theme/theme-script.js.map +1 -0
- package/dist/components/theme/theme-state.d.ts +85 -2
- package/dist/components/theme/theme-state.d.ts.map +1 -1
- package/dist/components/theme/theme-state.js +41 -2
- package/dist/components/theme/theme-state.js.map +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -4
- package/dist/lib/accent-roles.d.ts +124 -0
- package/dist/lib/accent-roles.d.ts.map +1 -0
- package/dist/lib/accent-roles.js +163 -0
- package/dist/lib/accent-roles.js.map +1 -0
- package/dist/lib/font-zoom.d.ts +25 -0
- package/dist/lib/font-zoom.d.ts.map +1 -0
- package/dist/lib/font-zoom.js +45 -0
- package/dist/lib/font-zoom.js.map +1 -0
- package/dist/lib/preference-store.d.ts +126 -0
- package/dist/lib/preference-store.d.ts.map +1 -0
- package/dist/lib/preference-store.js +126 -0
- package/dist/lib/preference-store.js.map +1 -0
- package/dist/lib/theme-surface.d.ts +71 -0
- package/dist/lib/theme-surface.d.ts.map +1 -0
- package/dist/lib/theme-surface.js +62 -0
- package/dist/lib/theme-surface.js.map +1 -0
- package/dist/lib/themes.d.ts +32 -1
- package/dist/lib/themes.d.ts.map +1 -1
- package/dist/lib/themes.js +34 -13
- package/dist/lib/themes.js.map +1 -1
- package/dist/lib/tokens.d.ts.map +1 -1
- package/dist/lib/tokens.js +15 -0
- package/dist/lib/tokens.js.map +1 -1
- package/dist/r/lib.json +20 -0
- package/dist/r/registry.json +25 -0
- package/dist/r/src/components/theme/index.ts +6 -0
- package/dist/r/src/components/theme/theme-document.ts +69 -4
- package/dist/r/src/components/theme/theme-persistence.ts +172 -2
- package/dist/r/src/components/theme/theme-provider.tsx +137 -51
- package/dist/r/src/components/theme/theme-script.ts +201 -0
- package/dist/r/src/components/theme/theme-state.ts +124 -3
- package/dist/r/src/lib/accent-roles.ts +338 -0
- package/dist/r/src/lib/font-zoom.ts +74 -0
- package/dist/r/src/lib/preference-store.ts +216 -0
- package/dist/r/src/lib/theme-surface.ts +110 -0
- package/dist/r/src/lib/themes.ts +58 -19
- package/dist/r/src/lib/tokens.ts +17 -0
- package/dist/r/src/styles/theme.css +38 -0
- package/dist/r/theming.json +5 -0
- package/package.json +1 -1
- package/registry.json +25 -0
- package/src/components/theme/index.ts +6 -0
- package/src/components/theme/theme-document.ts +69 -4
- package/src/components/theme/theme-persistence.ts +172 -2
- package/src/components/theme/theme-provider.tsx +137 -51
- package/src/components/theme/theme-script.ts +201 -0
- package/src/components/theme/theme-state.ts +124 -3
- package/src/index.ts +34 -0
- package/src/lib/accent-roles.ts +338 -0
- package/src/lib/font-zoom.ts +74 -0
- package/src/lib/preference-store.ts +216 -0
- package/src/lib/theme-surface.ts +110 -0
- package/src/lib/themes.ts +58 -19
- package/src/lib/tokens.ts +17 -0
- package/src/styles/theme.css +38 -0
|
@@ -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 {
|
|
4
|
-
import {
|
|
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 {
|
|
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
|
-
}
|
|
@@ -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
|
+
}
|