@adea-ai/ui 0.123.1 → 0.124.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 +18 -0
- package/dist/components/theme/appearance-panel.js +1 -1
- 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 +5 -2
- package/dist/components/theme/theme-context.d.ts +13 -0
- package/dist/components/theme/theme-context.d.ts.map +1 -0
- package/dist/components/theme/theme-context.js +41 -0
- package/dist/components/theme/theme-context.js.map +1 -0
- package/dist/components/theme/theme-document.d.ts +76 -0
- package/dist/components/theme/theme-document.d.ts.map +1 -0
- package/dist/components/theme/theme-document.js +93 -0
- package/dist/components/theme/theme-document.js.map +1 -0
- package/dist/components/theme/theme-persistence.d.ts +50 -0
- package/dist/components/theme/theme-persistence.d.ts.map +1 -0
- package/dist/components/theme/theme-persistence.js +86 -0
- package/dist/components/theme/theme-persistence.js.map +1 -0
- package/dist/components/theme/theme-provider.d.ts +43 -75
- package/dist/components/theme/theme-provider.d.ts.map +1 -1
- package/dist/components/theme/theme-provider.js +50 -116
- package/dist/components/theme/theme-provider.js.map +1 -1
- package/dist/components/theme/theme-state.d.ts +142 -0
- package/dist/components/theme/theme-state.d.ts.map +1 -0
- package/dist/components/theme/theme-state.js +104 -0
- package/dist/components/theme/theme-state.js.map +1 -0
- package/dist/components/theme/theme-toggle.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -3
- package/dist/lib/appearance-font-settings.d.ts +1 -1
- package/dist/lib/appearance-font-settings.d.ts.map +1 -1
- package/dist/lib/appearance-font-settings.js.map +1 -1
- package/dist/lib/themes.d.ts +48 -0
- package/dist/lib/themes.d.ts.map +1 -1
- package/dist/lib/themes.js +150 -36
- package/dist/lib/themes.js.map +1 -1
- package/dist/r/registry.json +20 -0
- package/dist/r/src/components/theme/index.ts +3 -0
- package/dist/r/src/components/theme/theme-context.ts +43 -0
- package/dist/r/src/components/theme/theme-document.ts +168 -0
- package/dist/r/src/components/theme/theme-persistence.ts +140 -0
- package/dist/r/src/components/theme/theme-provider.tsx +126 -253
- package/dist/r/src/components/theme/theme-state.ts +225 -0
- package/dist/r/src/lib/appearance-font-settings.ts +1 -1
- package/dist/r/src/lib/themes.ts +181 -40
- package/dist/r/theming.json +20 -0
- package/package.json +1 -1
- package/registry.json +20 -0
- package/src/components/theme/index.ts +3 -0
- package/src/components/theme/theme-context.ts +43 -0
- package/src/components/theme/theme-document.ts +168 -0
- package/src/components/theme/theme-persistence.ts +140 -0
- package/src/components/theme/theme-provider.tsx +126 -253
- package/src/components/theme/theme-state.ts +225 -0
- package/src/index.ts +3 -0
- package/src/lib/appearance-font-settings.ts +1 -1
- package/src/lib/themes.ts +181 -40
package/dist/r/src/lib/themes.ts
CHANGED
|
@@ -57,6 +57,7 @@ import {
|
|
|
57
57
|
type ContrastFinding,
|
|
58
58
|
} from '@adea-ai/themes'
|
|
59
59
|
import { shadcnVariables } from '@adea-ai/themes/adapters/shadcn'
|
|
60
|
+
import { oklchToHex, parseColor } from '@adea-ai/themes/oklch'
|
|
60
61
|
import { isThemeAccentId } from './theme-accents'
|
|
61
62
|
|
|
62
63
|
export { isThemeAccentId } from './theme-accents'
|
|
@@ -179,10 +180,24 @@ export type ThemeVariant = {
|
|
|
179
180
|
/** The categorical series, in order. */
|
|
180
181
|
chart: readonly string[]
|
|
181
182
|
provenance: ThemeProvenance
|
|
183
|
+
/**
|
|
184
|
+
* Custom properties written verbatim over {@link themeCssVariables}, last.
|
|
185
|
+
*
|
|
186
|
+
* For a variant a host built from data it does not control the derivation of —
|
|
187
|
+
* an imported theme whose painted colours must stay exactly what the user
|
|
188
|
+
* imported. The catalogue's own variants never carry it, so the derived roles
|
|
189
|
+
* stay the only source of truth for them.
|
|
190
|
+
*/
|
|
191
|
+
tokens?: Readonly<Record<string, string>>
|
|
182
192
|
}
|
|
183
193
|
|
|
184
|
-
/**
|
|
185
|
-
|
|
194
|
+
/**
|
|
195
|
+
* The shadcn-vocabulary roles of one catalogue entry.
|
|
196
|
+
*
|
|
197
|
+
* The expensive half of a variant: `shadcnVariables` projects the whole palette
|
|
198
|
+
* (about 4 ms a theme), which is why {@link lazyVariant} defers it.
|
|
199
|
+
*/
|
|
200
|
+
function colorsOf(theme: AdeaThemeRecord): ThemeColors {
|
|
186
201
|
const projected = shadcnVariables(theme)
|
|
187
202
|
const token = (name: string): string => {
|
|
188
203
|
const value = projected[`--${name}`]
|
|
@@ -190,7 +205,7 @@ function toVariant(theme: AdeaThemeRecord): ThemeVariant {
|
|
|
190
205
|
return value
|
|
191
206
|
}
|
|
192
207
|
const destructiveAction = shadcnDestructiveProjection(theme)
|
|
193
|
-
|
|
208
|
+
return {
|
|
194
209
|
background: token('background'),
|
|
195
210
|
foreground: token('foreground'),
|
|
196
211
|
card: token('card'),
|
|
@@ -231,8 +246,62 @@ function toVariant(theme: AdeaThemeRecord): ThemeVariant {
|
|
|
231
246
|
sidebarBorder: token('sidebar-border'),
|
|
232
247
|
sidebarMutedForeground: token('sidebar-muted-foreground'),
|
|
233
248
|
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// The embedded terminal uses the theme's own canvas rather than a palette of its
|
|
252
|
+
// own. The ANSI colours came from the palette that produced the canvas, so they
|
|
253
|
+
// were chosen against it; a terminal on a different background would be the one
|
|
254
|
+
// surface in the application not lit by the active theme.
|
|
255
|
+
function terminalOf(theme: AdeaThemeRecord): ThemeTerminalPalette {
|
|
256
|
+
return {
|
|
257
|
+
background: theme.colors.background,
|
|
258
|
+
foreground: theme.colors.foreground,
|
|
259
|
+
cursor: theme.cursor,
|
|
260
|
+
selection: theme.selection,
|
|
261
|
+
...theme.ansi,
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function editorOf(theme: AdeaThemeRecord): ThemeEditorPalette {
|
|
234
266
|
const syntax = syntaxRoles(theme)
|
|
267
|
+
return {
|
|
268
|
+
keyword: syntax.keyword,
|
|
269
|
+
string: syntax.string,
|
|
270
|
+
number: syntax.number,
|
|
271
|
+
comment: syntax.comment,
|
|
272
|
+
function: syntax.function,
|
|
273
|
+
variable: syntax.variable,
|
|
274
|
+
type: syntax.type,
|
|
275
|
+
tag: syntax.tag,
|
|
276
|
+
attribute: syntax.attribute,
|
|
277
|
+
operator: syntax.operator,
|
|
278
|
+
heading: syntax.heading,
|
|
279
|
+
link: syntax.link,
|
|
280
|
+
diffAdd: syntax.diffAdd,
|
|
281
|
+
diffDelete: syntax.diffDelete,
|
|
282
|
+
diffHunk: syntax.diffHunk,
|
|
283
|
+
searchMatch: syntax.searchMatch,
|
|
284
|
+
}
|
|
285
|
+
}
|
|
235
286
|
|
|
287
|
+
function provenanceOf(theme: AdeaThemeRecord): ThemeProvenance {
|
|
288
|
+
return {
|
|
289
|
+
source: theme.provenance.project,
|
|
290
|
+
url: theme.provenance.url,
|
|
291
|
+
license: theme.provenance.license,
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* The design system's view of one catalogue record, built in full and at once.
|
|
297
|
+
*
|
|
298
|
+
* For a host that holds a record the catalogue does not — an imported theme, a
|
|
299
|
+
* reconstruction from stored data — and wants a variant it can hand to
|
|
300
|
+
* `ThemeProvider`'s `themes`. It throws for a record the shadcn projection cannot
|
|
301
|
+
* map, here and now, rather than on first paint. The catalogue itself goes through
|
|
302
|
+
* {@link lazyVariant} instead.
|
|
303
|
+
*/
|
|
304
|
+
export function variantFromRecord(theme: AdeaThemeRecord): ThemeVariant {
|
|
236
305
|
return {
|
|
237
306
|
id: theme.id,
|
|
238
307
|
family: theme.family,
|
|
@@ -240,45 +309,62 @@ function toVariant(theme: AdeaThemeRecord): ThemeVariant {
|
|
|
240
309
|
label: theme.label,
|
|
241
310
|
appearance: theme.appearance,
|
|
242
311
|
description: theme.description,
|
|
243
|
-
colors,
|
|
244
|
-
terminal:
|
|
245
|
-
|
|
246
|
-
// own. The ANSI colours came from the palette that produced the canvas, so they
|
|
247
|
-
// were chosen against it; a terminal on a different background would be the one
|
|
248
|
-
// surface in the application not lit by the active theme.
|
|
249
|
-
background: theme.colors.background,
|
|
250
|
-
foreground: theme.colors.foreground,
|
|
251
|
-
cursor: theme.cursor,
|
|
252
|
-
selection: theme.selection,
|
|
253
|
-
...theme.ansi,
|
|
254
|
-
},
|
|
255
|
-
editor: {
|
|
256
|
-
keyword: syntax.keyword,
|
|
257
|
-
string: syntax.string,
|
|
258
|
-
number: syntax.number,
|
|
259
|
-
comment: syntax.comment,
|
|
260
|
-
function: syntax.function,
|
|
261
|
-
variable: syntax.variable,
|
|
262
|
-
type: syntax.type,
|
|
263
|
-
tag: syntax.tag,
|
|
264
|
-
attribute: syntax.attribute,
|
|
265
|
-
operator: syntax.operator,
|
|
266
|
-
heading: syntax.heading,
|
|
267
|
-
link: syntax.link,
|
|
268
|
-
diffAdd: syntax.diffAdd,
|
|
269
|
-
diffDelete: syntax.diffDelete,
|
|
270
|
-
diffHunk: syntax.diffHunk,
|
|
271
|
-
searchMatch: syntax.searchMatch,
|
|
272
|
-
},
|
|
312
|
+
colors: colorsOf(theme),
|
|
313
|
+
terminal: terminalOf(theme),
|
|
314
|
+
editor: editorOf(theme),
|
|
273
315
|
chart: chartSeries(theme),
|
|
274
|
-
provenance:
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
316
|
+
provenance: provenanceOf(theme),
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Runs `build` on first call and returns that value from then on. A throw is not cached. */
|
|
321
|
+
function once<T>(build: () => T): () => T {
|
|
322
|
+
let built = false
|
|
323
|
+
let value: T
|
|
324
|
+
return () => {
|
|
325
|
+
if (!built) {
|
|
326
|
+
value = build()
|
|
327
|
+
built = true
|
|
328
|
+
}
|
|
329
|
+
return value
|
|
279
330
|
}
|
|
280
331
|
}
|
|
281
332
|
|
|
333
|
+
const lazy = <T>(build: () => T): PropertyDescriptor => ({
|
|
334
|
+
get: once(build),
|
|
335
|
+
enumerable: true,
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* {@link variantFromRecord} with the four derived palettes built on first read.
|
|
340
|
+
*
|
|
341
|
+
* Evaluating this module used to project all 34 catalogue themes up front, about
|
|
342
|
+
* 155 ms before a single one was needed, on the hydration path of every consumer.
|
|
343
|
+
* A page paints one variant, and a picker's labels and appearance are plain
|
|
344
|
+
* fields, so only a variant whose `colors`, `terminal`, `editor` or `chart` is read
|
|
345
|
+
* pays for them, once. The properties stay enumerable data-shaped accessors, so
|
|
346
|
+
* spreading, `Object.entries` and JSON see the same object they always did.
|
|
347
|
+
*/
|
|
348
|
+
function lazyVariant(theme: AdeaThemeRecord): ThemeVariant {
|
|
349
|
+
return Object.defineProperties(
|
|
350
|
+
{
|
|
351
|
+
id: theme.id,
|
|
352
|
+
family: theme.family,
|
|
353
|
+
familyLabel: theme.familyLabel,
|
|
354
|
+
label: theme.label,
|
|
355
|
+
appearance: theme.appearance,
|
|
356
|
+
description: theme.description,
|
|
357
|
+
provenance: provenanceOf(theme),
|
|
358
|
+
},
|
|
359
|
+
{
|
|
360
|
+
colors: lazy(() => colorsOf(theme)),
|
|
361
|
+
terminal: lazy(() => terminalOf(theme)),
|
|
362
|
+
editor: lazy(() => editorOf(theme)),
|
|
363
|
+
chart: lazy(() => chartSeries(theme)),
|
|
364
|
+
}
|
|
365
|
+
) as ThemeVariant
|
|
366
|
+
}
|
|
367
|
+
|
|
282
368
|
/**
|
|
283
369
|
* A variant's `--primary-subtle`: the tint over `var(--primary)` at the strength its
|
|
284
370
|
* catalogue record measures (`primarySubtleAlpha`). A variant that is not in the
|
|
@@ -290,13 +376,45 @@ export function primarySubtleFor(theme: Pick<ThemeVariant, 'id' | 'appearance'>)
|
|
|
290
376
|
}
|
|
291
377
|
|
|
292
378
|
/** The catalogue, in catalogue order. */
|
|
293
|
-
export const builtinThemes: readonly ThemeVariant[] = Object.freeze(catalogue.map(
|
|
379
|
+
export const builtinThemes: readonly ThemeVariant[] = Object.freeze(catalogue.map(lazyVariant))
|
|
294
380
|
|
|
295
381
|
export function themeById(id: string): ThemeVariant | undefined {
|
|
296
382
|
if (!hasTheme(id)) return undefined
|
|
297
383
|
return builtinThemes.find((theme) => theme.id === id)
|
|
298
384
|
}
|
|
299
385
|
|
|
386
|
+
/**
|
|
387
|
+
* The variant a stored id means, or the default of the same appearance.
|
|
388
|
+
*
|
|
389
|
+
* An id the registry does not hold — an imported theme that was deleted, a
|
|
390
|
+
* catalogue entry that was renamed — must still paint *something of the appearance
|
|
391
|
+
* being shown*. Falling back to whichever variant happens to be first in the
|
|
392
|
+
* registry painted a dark palette under a light `color-scheme` for a light id, so
|
|
393
|
+
* the fallback is the appearance's own default, then any variant of that
|
|
394
|
+
* appearance, then the registry's first entry only when it holds nothing else.
|
|
395
|
+
*
|
|
396
|
+
* `registry` is the builtin catalogue plus whatever extras the host supplies; the
|
|
397
|
+
* stored id is never rewritten, only resolved, so a host that keeps an unknown id
|
|
398
|
+
* keeps resolving it if the theme comes back.
|
|
399
|
+
*
|
|
400
|
+
* The fallback order is translated from Zeron's `ThemeRegistry::resolve` (MIT,
|
|
401
|
+
* Copyright (c) 2026 Wing); see NOTICE.
|
|
402
|
+
*/
|
|
403
|
+
export function resolveVariant(
|
|
404
|
+
registry: readonly ThemeVariant[],
|
|
405
|
+
id: string,
|
|
406
|
+
appearance: ThemeAppearance
|
|
407
|
+
): ThemeVariant {
|
|
408
|
+
const found = registry.find((variant) => variant.id === id)
|
|
409
|
+
if (found) return found
|
|
410
|
+
const defaultId = appearance === 'dark' ? defaultDarkThemeId : defaultLightThemeId
|
|
411
|
+
return (
|
|
412
|
+
registry.find((variant) => variant.id === defaultId) ??
|
|
413
|
+
registry.find((variant) => variant.appearance === appearance) ??
|
|
414
|
+
registry[0]!
|
|
415
|
+
)
|
|
416
|
+
}
|
|
417
|
+
|
|
300
418
|
/** The variants of one appearance, which is what a picker for that mode shows. */
|
|
301
419
|
export function themesForAppearance(appearance: ThemeAppearance): readonly ThemeVariant[] {
|
|
302
420
|
const ids = new Set(themesByAppearance(appearance).map((theme) => theme.id))
|
|
@@ -482,7 +600,7 @@ export function themeCssVariables(theme: ThemeVariant): Record<string, string> {
|
|
|
482
600
|
const c = theme.colors
|
|
483
601
|
const t = theme.terminal
|
|
484
602
|
const e = theme.editor
|
|
485
|
-
|
|
603
|
+
const derived = {
|
|
486
604
|
'--background': c.background,
|
|
487
605
|
'--foreground': c.foreground,
|
|
488
606
|
'--card': c.card,
|
|
@@ -587,6 +705,29 @@ export function themeCssVariables(theme: ThemeVariant): Record<string, string> {
|
|
|
587
705
|
'--chart-5': theme.chart[4] ?? c.destructive,
|
|
588
706
|
'--chart-6': theme.chart[5] ?? c.mutedForeground,
|
|
589
707
|
}
|
|
708
|
+
return theme.tokens ? { ...derived, ...theme.tokens } : derived
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Token values with every `oklch()` colour written as `#rrggbb`.
|
|
713
|
+
*
|
|
714
|
+
* For a host whose stylesheet, tests or native shell speak hex: the catalogue's
|
|
715
|
+
* roles are `oklch()` strings, and this is the one conversion, gamut-mapped by the
|
|
716
|
+
* catalogue's own `oklchToHex`. A value that is already hex, carries alpha
|
|
717
|
+
* (`rgba()`), or is not a plain colour (`color-mix()`, `var()`) is left byte for
|
|
718
|
+
* byte, because converting would either lose the alpha or be a guess. Suitable as
|
|
719
|
+
* `ThemeProvider`'s `transformTokens`.
|
|
720
|
+
*/
|
|
721
|
+
export function tokensAsHex(tokens: Readonly<Record<string, string>>): Record<string, string> {
|
|
722
|
+
const converted: Record<string, string> = {}
|
|
723
|
+
for (const [name, value] of Object.entries(tokens)) {
|
|
724
|
+
const trimmed = value.trim()
|
|
725
|
+
// `parseOklch` discards alpha, so a translucent oklch() stays as written.
|
|
726
|
+
const opaque = trimmed.startsWith('oklch(') && !trimmed.includes('/')
|
|
727
|
+
const parsed = opaque ? parseColor(trimmed) : undefined
|
|
728
|
+
converted[name] = parsed ? oklchToHex(parsed) : value
|
|
729
|
+
}
|
|
730
|
+
return converted
|
|
590
731
|
}
|
|
591
732
|
|
|
592
733
|
/* ---------------------------------------------------------------------------
|
package/dist/r/theming.json
CHANGED
|
@@ -28,11 +28,26 @@
|
|
|
28
28
|
"type": "registry:ui",
|
|
29
29
|
"target": "components/theming/index.ts"
|
|
30
30
|
},
|
|
31
|
+
{
|
|
32
|
+
"path": "src/components/theme/theme-context.ts",
|
|
33
|
+
"type": "registry:ui",
|
|
34
|
+
"target": "components/theming/theme-context.ts"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"path": "src/components/theme/theme-document.ts",
|
|
38
|
+
"type": "registry:ui",
|
|
39
|
+
"target": "components/theming/theme-document.ts"
|
|
40
|
+
},
|
|
31
41
|
{
|
|
32
42
|
"path": "src/components/theme/theme-mode-toggle.tsx",
|
|
33
43
|
"type": "registry:ui",
|
|
34
44
|
"target": "components/theming/theme-mode-toggle.tsx"
|
|
35
45
|
},
|
|
46
|
+
{
|
|
47
|
+
"path": "src/components/theme/theme-persistence.ts",
|
|
48
|
+
"type": "registry:ui",
|
|
49
|
+
"target": "components/theming/theme-persistence.ts"
|
|
50
|
+
},
|
|
36
51
|
{
|
|
37
52
|
"path": "src/components/theme/theme-picker.tsx",
|
|
38
53
|
"type": "registry:ui",
|
|
@@ -43,6 +58,11 @@
|
|
|
43
58
|
"type": "registry:ui",
|
|
44
59
|
"target": "components/theming/theme-provider.tsx"
|
|
45
60
|
},
|
|
61
|
+
{
|
|
62
|
+
"path": "src/components/theme/theme-state.ts",
|
|
63
|
+
"type": "registry:ui",
|
|
64
|
+
"target": "components/theming/theme-state.ts"
|
|
65
|
+
},
|
|
46
66
|
{
|
|
47
67
|
"path": "src/components/theme/theme-toggle.tsx",
|
|
48
68
|
"type": "registry:ui",
|
package/package.json
CHANGED
package/registry.json
CHANGED
|
@@ -2527,11 +2527,26 @@
|
|
|
2527
2527
|
"type": "registry:ui",
|
|
2528
2528
|
"target": "components/theming/index.ts"
|
|
2529
2529
|
},
|
|
2530
|
+
{
|
|
2531
|
+
"path": "src/components/theme/theme-context.ts",
|
|
2532
|
+
"type": "registry:ui",
|
|
2533
|
+
"target": "components/theming/theme-context.ts"
|
|
2534
|
+
},
|
|
2535
|
+
{
|
|
2536
|
+
"path": "src/components/theme/theme-document.ts",
|
|
2537
|
+
"type": "registry:ui",
|
|
2538
|
+
"target": "components/theming/theme-document.ts"
|
|
2539
|
+
},
|
|
2530
2540
|
{
|
|
2531
2541
|
"path": "src/components/theme/theme-mode-toggle.tsx",
|
|
2532
2542
|
"type": "registry:ui",
|
|
2533
2543
|
"target": "components/theming/theme-mode-toggle.tsx"
|
|
2534
2544
|
},
|
|
2545
|
+
{
|
|
2546
|
+
"path": "src/components/theme/theme-persistence.ts",
|
|
2547
|
+
"type": "registry:ui",
|
|
2548
|
+
"target": "components/theming/theme-persistence.ts"
|
|
2549
|
+
},
|
|
2535
2550
|
{
|
|
2536
2551
|
"path": "src/components/theme/theme-picker.tsx",
|
|
2537
2552
|
"type": "registry:ui",
|
|
@@ -2542,6 +2557,11 @@
|
|
|
2542
2557
|
"type": "registry:ui",
|
|
2543
2558
|
"target": "components/theming/theme-provider.tsx"
|
|
2544
2559
|
},
|
|
2560
|
+
{
|
|
2561
|
+
"path": "src/components/theme/theme-state.ts",
|
|
2562
|
+
"type": "registry:ui",
|
|
2563
|
+
"target": "components/theming/theme-state.ts"
|
|
2564
|
+
},
|
|
2545
2565
|
{
|
|
2546
2566
|
"path": "src/components/theme/theme-toggle.tsx",
|
|
2547
2567
|
"type": "registry:ui",
|
|
@@ -11,11 +11,14 @@ export {
|
|
|
11
11
|
ThemeProvider,
|
|
12
12
|
defaultThemeSelection,
|
|
13
13
|
inlineScriptLiteral,
|
|
14
|
+
localStoragePersistence,
|
|
14
15
|
resolvedDensity,
|
|
15
16
|
themeScript,
|
|
17
|
+
useOptionalTheme,
|
|
16
18
|
useTheme,
|
|
17
19
|
type ThemeContextValue,
|
|
18
20
|
type ThemeDensity,
|
|
21
|
+
type ThemePersistence,
|
|
19
22
|
type ThemeProviderProps,
|
|
20
23
|
type ThemeSelection,
|
|
21
24
|
} from './theme-provider'
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { createContext, useContext, type Context } from 'solid-js'
|
|
2
|
+
import type { ThemeContextValue } from './theme-state'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Where the context object lives: on the global, under a registered symbol.
|
|
6
|
+
*
|
|
7
|
+
* A context is an object identity, and a bundle can legitimately load this module
|
|
8
|
+
* twice — a dev graph where a relative import and a package subpath are distinct
|
|
9
|
+
* module records, or two copies of the package at different versions. Each copy
|
|
10
|
+
* would then call `createContext` and own its own object; a provider from one and a
|
|
11
|
+
* `useOptionalTheme()` from the other would never meet, and the second would return
|
|
12
|
+
* `undefined` as though no provider were mounted. `Symbol.for` is shared across
|
|
13
|
+
* every copy in a realm, so they all find the one context the first created.
|
|
14
|
+
*
|
|
15
|
+
* Both copies must agree on its shape. The context value only ever grows, which is
|
|
16
|
+
* why a newer provider under an older reader is safe and the reverse is a missing
|
|
17
|
+
* member, not a mismatch.
|
|
18
|
+
*/
|
|
19
|
+
const CONTEXT_KEY = Symbol.for('adea.ui.theme')
|
|
20
|
+
|
|
21
|
+
type ContextHost = { [CONTEXT_KEY]?: Context<ThemeContextValue | undefined> }
|
|
22
|
+
|
|
23
|
+
export function themeContext(): Context<ThemeContextValue | undefined> {
|
|
24
|
+
const host = globalThis as ContextHost
|
|
25
|
+
return (host[CONTEXT_KEY] ??= createContext<ThemeContextValue>())
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function useTheme(): ThemeContextValue {
|
|
29
|
+
const value = useContext(themeContext())
|
|
30
|
+
if (!value) throw new Error('useTheme must be called inside a <ThemeProvider>.')
|
|
31
|
+
return value
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The theme context, or `undefined` outside a `ThemeProvider`.
|
|
36
|
+
*
|
|
37
|
+
* For a component that can run both ways — a shared control rendered in a harness,
|
|
38
|
+
* a story, or a host that has not mounted a provider — and would rather degrade
|
|
39
|
+
* than throw.
|
|
40
|
+
*/
|
|
41
|
+
export function useOptionalTheme(): ThemeContextValue | undefined {
|
|
42
|
+
return useContext(themeContext())
|
|
43
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import {
|
|
2
|
+
applyAppearanceFontSettings,
|
|
3
|
+
normalizeAppearanceEditorFontSettings,
|
|
4
|
+
} from '#lib/appearance-font-settings'
|
|
5
|
+
import {
|
|
6
|
+
accentVariables,
|
|
7
|
+
themeCssVariables,
|
|
8
|
+
type ThemeAppearance,
|
|
9
|
+
type ThemeVariant,
|
|
10
|
+
} from '#lib/themes'
|
|
11
|
+
import { resolvedDensity, type ThemeSelection } from './theme-state'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The document half of the theme provider: the single writer of `<html>`.
|
|
15
|
+
*
|
|
16
|
+
* Written against the few members of an element it uses, so the rules can be tested
|
|
17
|
+
* on a stand-in and the provider's effect stays a one-line call.
|
|
18
|
+
*/
|
|
19
|
+
export type ThemeRoot = Pick<
|
|
20
|
+
HTMLElement,
|
|
21
|
+
'classList' | 'dataset' | 'style' | 'removeAttribute' | 'setAttribute'
|
|
22
|
+
>
|
|
23
|
+
|
|
24
|
+
export type ThemeDocumentState = {
|
|
25
|
+
/** What the document shows: the committed selection, or a preview laid over it. */
|
|
26
|
+
selection: ThemeSelection
|
|
27
|
+
appearance: ThemeAppearance
|
|
28
|
+
variant: ThemeVariant
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type ThemeDocumentOptions = {
|
|
32
|
+
/**
|
|
33
|
+
* Rewrites the custom properties just before they are written: the variant's
|
|
34
|
+
* roles with the accent laid over them. It sees every property of the pass and
|
|
35
|
+
* must return the full map; a name it drops is not written.
|
|
36
|
+
*/
|
|
37
|
+
transformTokens?: (tokens: Record<string, string>) => Record<string, string>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The custom properties a state paints: the variant's roles, with the accent
|
|
42
|
+
* selection laid over them, then `transformTokens`.
|
|
43
|
+
*
|
|
44
|
+
* The accent is applied *here* rather than left to the `[data-accent]` blocks in
|
|
45
|
+
* `theme.css`, because the properties are written inline and an inline declaration
|
|
46
|
+
* outranks every stylesheet selector however specific it is. The blocks still exist
|
|
47
|
+
* and are still correct — they are what styles the window before this runs — but
|
|
48
|
+
* they cannot win from here, so leaving the accent to them meant the attribute was
|
|
49
|
+
* set, the rules were present, and nothing changed on screen.
|
|
50
|
+
*/
|
|
51
|
+
export function themeDocumentTokens(
|
|
52
|
+
state: ThemeDocumentState,
|
|
53
|
+
options: ThemeDocumentOptions = {}
|
|
54
|
+
): { tokens: Record<string, string>; accented: boolean } {
|
|
55
|
+
const properties = { ...themeCssVariables(state.variant) }
|
|
56
|
+
const accent = accentVariables(state.selection.accent, state.appearance, state.selection)
|
|
57
|
+
if (accent) Object.assign(properties, accent)
|
|
58
|
+
return {
|
|
59
|
+
tokens: options.transformTokens ? options.transformTokens(properties) : properties,
|
|
60
|
+
accented: accent !== undefined,
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Write a state onto a root and report which custom properties it owns.
|
|
66
|
+
*
|
|
67
|
+
* Pass the previous call's return as `previous` and any palette property that pass
|
|
68
|
+
* wrote and this one does not is removed. For the catalogue the two sets are the
|
|
69
|
+
* same, so nothing is ever removed; it matters for a host variant that carries
|
|
70
|
+
* `tokens` the catalogue does not, which would otherwise stay inline, beating the
|
|
71
|
+
* stylesheet, after the user switched away from it.
|
|
72
|
+
*/
|
|
73
|
+
export function applyThemeToRoot(
|
|
74
|
+
root: ThemeRoot,
|
|
75
|
+
state: ThemeDocumentState,
|
|
76
|
+
options: ThemeDocumentOptions = {},
|
|
77
|
+
previous?: ReadonlySet<string>
|
|
78
|
+
): ReadonlySet<string> {
|
|
79
|
+
const { tokens, accented } = themeDocumentTokens(state, options)
|
|
80
|
+
|
|
81
|
+
for (const [name, value] of Object.entries(tokens)) {
|
|
82
|
+
root.style.setProperty(name, value)
|
|
83
|
+
}
|
|
84
|
+
if (previous) {
|
|
85
|
+
for (const name of previous) {
|
|
86
|
+
if (!(name in tokens)) root.style.removeProperty(name)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
root.classList.toggle('dark', state.appearance === 'dark')
|
|
91
|
+
root.dataset['theme'] = state.variant.id
|
|
92
|
+
root.dataset['appearance'] = state.appearance
|
|
93
|
+
root.style.colorScheme = state.appearance
|
|
94
|
+
|
|
95
|
+
if (!accented) root.removeAttribute('data-accent')
|
|
96
|
+
else root.dataset['accent'] = state.selection.accent
|
|
97
|
+
|
|
98
|
+
applyAppearanceFontSettings(
|
|
99
|
+
root,
|
|
100
|
+
normalizeAppearanceEditorFontSettings(state.selection.fonts, state.selection.font).settings
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
// Density is a pure token shift, so unlike the colour axes there is nothing to
|
|
104
|
+
// compute: the attribute selects a block in `theme.css`. It is applied here for
|
|
105
|
+
// the same reason as the others — this is the one place that knows what the
|
|
106
|
+
// current selection is, and it is the only writer of these attributes.
|
|
107
|
+
if (resolvedDensity(state.selection) === 'comfortable') root.removeAttribute('data-density')
|
|
108
|
+
else root.dataset['density'] = resolvedDensity(state.selection)
|
|
109
|
+
|
|
110
|
+
return new Set(Object.keys(tokens))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** What {@link applyWithoutTransitions} needs of a document. */
|
|
114
|
+
export type TransitionDocument = {
|
|
115
|
+
head: { appendChild(node: unknown): unknown }
|
|
116
|
+
createElement(tag: 'style'): {
|
|
117
|
+
textContent: string | null
|
|
118
|
+
offsetHeight?: number
|
|
119
|
+
remove(): void
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Run `apply` with every CSS transition switched off for the frame that carries
|
|
125
|
+
* it, so a theme switch cannot animate page-wide.
|
|
126
|
+
*
|
|
127
|
+
* Without this, flipping the custom properties on `<html>` starts a transition on
|
|
128
|
+
* every element that declares one, and each fades from the old palette to the new
|
|
129
|
+
* over its own duration: a theme change that looks like a rendering fault. The
|
|
130
|
+
* rule is removed two frames later, after the browser has committed the new styles
|
|
131
|
+
* with transitions off, and `restore` runs it early (the provider calls it when it
|
|
132
|
+
* is disposed mid-switch).
|
|
133
|
+
*
|
|
134
|
+
* `schedule` is the frame source; it defaults to `requestAnimationFrame` and falls
|
|
135
|
+
* back to running at once where there is none, which leaves transitions on rather
|
|
136
|
+
* than off.
|
|
137
|
+
*/
|
|
138
|
+
export function applyWithoutTransitions(
|
|
139
|
+
doc: TransitionDocument,
|
|
140
|
+
apply: () => void,
|
|
141
|
+
schedule: (callback: () => void) => void = defaultSchedule
|
|
142
|
+
): () => void {
|
|
143
|
+
const style = doc.createElement('style')
|
|
144
|
+
style.textContent = '*{transition:none !important}'
|
|
145
|
+
doc.head.appendChild(style)
|
|
146
|
+
// Reading a layout property forces the browser to commit the rule before the
|
|
147
|
+
// properties change; without it the two can be batched into one style recalc.
|
|
148
|
+
void style.offsetHeight
|
|
149
|
+
let removed = false
|
|
150
|
+
const restore = () => {
|
|
151
|
+
if (removed) return
|
|
152
|
+
removed = true
|
|
153
|
+
style.remove()
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
apply()
|
|
157
|
+
} catch (error) {
|
|
158
|
+
restore()
|
|
159
|
+
throw error
|
|
160
|
+
}
|
|
161
|
+
schedule(() => schedule(restore))
|
|
162
|
+
return restore
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function defaultSchedule(callback: () => void): void {
|
|
166
|
+
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(callback)
|
|
167
|
+
else callback()
|
|
168
|
+
}
|