@adea-ai/ui 0.125.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 +40 -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 +25 -1
- package/dist/components/theme/theme-document.d.ts.map +1 -1
- package/dist/components/theme/theme-document.js +18 -3
- 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 +63 -2
- package/dist/components/theme/theme-persistence.js.map +1 -1
- package/dist/components/theme/theme-provider.d.ts +40 -40
- package/dist/components/theme/theme-provider.d.ts.map +1 -1
- package/dist/components/theme/theme-provider.js +57 -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 +58 -0
- package/dist/components/theme/theme-state.d.ts.map +1 -1
- package/dist/components/theme/theme-state.js +22 -1
- package/dist/components/theme/theme-state.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -4
- 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 +9 -0
- package/dist/lib/themes.d.ts.map +1 -1
- package/dist/lib/themes.js +12 -1
- 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 +15 -0
- package/dist/r/registry.json +20 -0
- package/dist/r/src/components/theme/index.ts +6 -0
- package/dist/r/src/components/theme/theme-document.ts +51 -2
- package/dist/r/src/components/theme/theme-persistence.ts +167 -0
- package/dist/r/src/components/theme/theme-provider.tsx +115 -50
- package/dist/r/src/components/theme/theme-script.ts +201 -0
- package/dist/r/src/components/theme/theme-state.ts +72 -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 +14 -0
- 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 +20 -0
- package/src/components/theme/index.ts +6 -0
- package/src/components/theme/theme-document.ts +51 -2
- package/src/components/theme/theme-persistence.ts +167 -0
- package/src/components/theme/theme-provider.tsx +115 -50
- package/src/components/theme/theme-script.ts +201 -0
- package/src/components/theme/theme-state.ts +72 -0
- package/src/index.ts +26 -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 +14 -0
- package/src/lib/tokens.ts +17 -0
- package/src/styles/theme.css +38 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2026 Wing
|
|
3
|
+
* Licensed under the MIT License.
|
|
4
|
+
*
|
|
5
|
+
* `resolveSurface` is Zeron's `SurfacePreference.resolve` (https://github.com/zeronsh/zeron),
|
|
6
|
+
* extended by Adea's accepted port of it (`packages/ui/src/components/appearance.ts`)
|
|
7
|
+
* with the translucent capability gate and the reduced-transparency policy. Moved
|
|
8
|
+
* here so the shared provider and Adea run one implementation. See NOTICE.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The surface axis, and the policy that decides what it paints.
|
|
13
|
+
*
|
|
14
|
+
* A surface is how opaque the window's elevated panels are: `opaque` is the plain
|
|
15
|
+
* canvas, `frosted` is a tokenized translucent fill over a blur (works everywhere,
|
|
16
|
+
* in CSS alone), and `translucent` asks for the host window's own vibrancy, which only
|
|
17
|
+
* a desktop shell can honour.
|
|
18
|
+
*
|
|
19
|
+
* Nothing here touches the document or a signal. The provider reads the operating
|
|
20
|
+
* system and the host's capability, calls {@link resolveSurface}, and
|
|
21
|
+
* `theme-document.ts` writes the answer; the rule itself is a function of its four
|
|
22
|
+
* inputs, which is what makes it testable.
|
|
23
|
+
*/
|
|
24
|
+
export type ThemeSurface = 'opaque' | 'frosted' | 'translucent'
|
|
25
|
+
|
|
26
|
+
/** Every surface, in the order a picker offers them. */
|
|
27
|
+
export const themeSurfaces: readonly ThemeSurface[] = ['opaque', 'frosted', 'translucent']
|
|
28
|
+
|
|
29
|
+
/** The media query the operating system's Reduce Transparency setting answers. */
|
|
30
|
+
export const REDUCED_TRANSPARENCY_QUERY = '(prefers-reduced-transparency: reduce)'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* What the world says about transparency, as opposed to what the user prefers.
|
|
34
|
+
*/
|
|
35
|
+
export type SurfaceEnvironment = Readonly<{
|
|
36
|
+
/** The operating system asks for less transparency. */
|
|
37
|
+
osReducedTransparency: boolean
|
|
38
|
+
/** The user turned reduced transparency on inside the application. */
|
|
39
|
+
userReducedTransparency: boolean
|
|
40
|
+
/** The host window can render OS translucency (vibrancy, acrylic). */
|
|
41
|
+
nativeTranslucency: boolean
|
|
42
|
+
}>
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A stored value as a surface, or nothing.
|
|
46
|
+
*
|
|
47
|
+
* `undefined` rather than `opaque` for an unreadable value: the selection field is
|
|
48
|
+
* optional, and an absent field is how a store that never chose a surface stays
|
|
49
|
+
* byte-identical to what it was before the axis existed.
|
|
50
|
+
*/
|
|
51
|
+
export function normalizeSurface(value: unknown): ThemeSurface | undefined {
|
|
52
|
+
return value === 'opaque' || value === 'frosted' || value === 'translucent' ? value : undefined
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The surface to paint: the preference, after the accessibility override and the
|
|
57
|
+
* host's capability.
|
|
58
|
+
*
|
|
59
|
+
* The OS setting or the user's own flag forces an accessible opaque fallback. A
|
|
60
|
+
* translucent request on a host without native translucency renders the tokenized
|
|
61
|
+
* frosted surface instead of pretending to OS vibrancy.
|
|
62
|
+
*/
|
|
63
|
+
export function resolveSurface(
|
|
64
|
+
preference: ThemeSurface,
|
|
65
|
+
environment: SurfaceEnvironment
|
|
66
|
+
): ThemeSurface {
|
|
67
|
+
if (environment.osReducedTransparency || environment.userReducedTransparency) return 'opaque'
|
|
68
|
+
if (preference === 'translucent' && !environment.nativeTranslucency) return 'frosted'
|
|
69
|
+
return preference
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The fill opacity each surface paints, written as `--surface-alpha`.
|
|
74
|
+
*
|
|
75
|
+
* These are the values `--surface-frost` and `--surface-translucent` declare in
|
|
76
|
+
* `theme.css` (the stylesheet is the readable copy; `tests/theme-surface.test.ts`
|
|
77
|
+
* fails when the two disagree). They are numbers rather than `var()` references so
|
|
78
|
+
* the property reads the same to a script, a test and the stylesheet, whichever
|
|
79
|
+
* wrote it.
|
|
80
|
+
*/
|
|
81
|
+
export const surfaceAlpha: Readonly<Record<ThemeSurface, string>> = {
|
|
82
|
+
opaque: '1',
|
|
83
|
+
frosted: '0.92',
|
|
84
|
+
translucent: '0.8',
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** What a selection paints for the surface axis. */
|
|
88
|
+
export type SurfaceState = Readonly<{
|
|
89
|
+
/** The surface after the accessibility override and capability gate. */
|
|
90
|
+
surface: ThemeSurface
|
|
91
|
+
/** True while the OS or the user forces opaque surfaces. */
|
|
92
|
+
reduceTransparencyActive: boolean
|
|
93
|
+
}>
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The surface state for a preference and an environment.
|
|
97
|
+
*
|
|
98
|
+
* A preference of `undefined` is `opaque`: the axis is optional on a selection, and
|
|
99
|
+
* a host that never set it gets the plain canvas.
|
|
100
|
+
*/
|
|
101
|
+
export function resolveSurfaceState(
|
|
102
|
+
preference: ThemeSurface | undefined,
|
|
103
|
+
environment: SurfaceEnvironment
|
|
104
|
+
): SurfaceState {
|
|
105
|
+
return {
|
|
106
|
+
surface: resolveSurface(preference ?? 'opaque', environment),
|
|
107
|
+
reduceTransparencyActive:
|
|
108
|
+
environment.osReducedTransparency || environment.userReducedTransparency,
|
|
109
|
+
}
|
|
110
|
+
}
|
package/dist/r/src/lib/themes.ts
CHANGED
|
@@ -733,6 +733,20 @@ export function themeCssVariables(theme: ThemeVariant): Record<string, string> {
|
|
|
733
733
|
return theme.tokens ? { ...derived, ...theme.tokens } : derived
|
|
734
734
|
}
|
|
735
735
|
|
|
736
|
+
/**
|
|
737
|
+
* The `--terminal-*` properties of a variant: the palette a terminal paints.
|
|
738
|
+
*
|
|
739
|
+
* What a pinned terminal theme writes over the interface variant's own. Taken from
|
|
740
|
+
* {@link themeCssVariables} rather than rebuilt, so the names, and a custom
|
|
741
|
+
* variant's verbatim `tokens`, can never drift from what the interface variant
|
|
742
|
+
* would have written for the same palette.
|
|
743
|
+
*/
|
|
744
|
+
export function terminalCssVariables(theme: ThemeVariant): Record<string, string> {
|
|
745
|
+
return Object.fromEntries(
|
|
746
|
+
Object.entries(themeCssVariables(theme)).filter(([name]) => name.startsWith('--terminal-'))
|
|
747
|
+
)
|
|
748
|
+
}
|
|
749
|
+
|
|
736
750
|
/**
|
|
737
751
|
* Token values with every `oklch()` colour written as `#rrggbb`.
|
|
738
752
|
*
|
package/dist/r/src/lib/tokens.ts
CHANGED
|
@@ -122,6 +122,23 @@ export const colorTokens: TokenDefinition[] = [
|
|
|
122
122
|
themed: true,
|
|
123
123
|
description: 'Opacity of the chrome fill behind a blur.',
|
|
124
124
|
},
|
|
125
|
+
{
|
|
126
|
+
name: 'surface-alpha',
|
|
127
|
+
kind: 'number',
|
|
128
|
+
description:
|
|
129
|
+
"The opacity a frosted dialog's fill keeps, written by the provider's surface axis. 1 is opaque and never matches the frost rule.",
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
name: 'surface-frost',
|
|
133
|
+
kind: 'number',
|
|
134
|
+
description: 'The fill opacity `--surface-alpha` takes for the frosted surface: 0.92.',
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
name: 'surface-translucent',
|
|
138
|
+
kind: 'number',
|
|
139
|
+
description:
|
|
140
|
+
'The fill opacity `--surface-alpha` takes for the translucent surface: 0.8. A host without native translucency paints frosted instead.',
|
|
141
|
+
},
|
|
125
142
|
|
|
126
143
|
// The brand action
|
|
127
144
|
{
|
|
@@ -637,6 +637,15 @@
|
|
|
637
637
|
--z-menu: 140;
|
|
638
638
|
--z-tooltip: 160;
|
|
639
639
|
--z-toast: 180;
|
|
640
|
+
|
|
641
|
+
/* Surface glass. `--surface-alpha` is what the provider's surface axis writes: the
|
|
642
|
+
opacity a frosted dialog's fill keeps (1 = opaque, so the default never matches
|
|
643
|
+
the frost rule's blur). `--surface-frost` and `--surface-translucent` are the two
|
|
644
|
+
amounts it chooses between; `lib/theme-surface.ts` writes the same numbers, and
|
|
645
|
+
`tests/theme-surface.test.ts` fails when they disagree. */
|
|
646
|
+
--surface-alpha: 1;
|
|
647
|
+
--surface-frost: 0.92;
|
|
648
|
+
--surface-translucent: 0.8;
|
|
640
649
|
}
|
|
641
650
|
|
|
642
651
|
/* Compact density: the standard control ladder one rung tighter.
|
|
@@ -687,6 +696,35 @@
|
|
|
687
696
|
--shadow-xl: 0 20px 44px oklch(0 0 0 / 0.45);
|
|
688
697
|
}
|
|
689
698
|
|
|
699
|
+
/* The OS-level backstop for the surface axis. The provider resolves the effective
|
|
700
|
+
surface and the reduced-transparency policy and writes `--surface-alpha` inline,
|
|
701
|
+
which outranks this rule; this is what keeps a frost amount from outliving the
|
|
702
|
+
system's Reduce Transparency setting for a document the provider has not
|
|
703
|
+
reached yet (a server-rendered one, before hydration). */
|
|
704
|
+
@media (prefers-reduced-transparency: reduce) {
|
|
705
|
+
:root {
|
|
706
|
+
--surface-alpha: 1;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/* Tokenized frost: while the resolved surface asks for glass, a modal dialog goes
|
|
711
|
+
translucent and blurs the content behind it. The opaque default and both
|
|
712
|
+
reduced-transparency paths resolve `--surface-alpha` to 1 and never write
|
|
713
|
+
`data-surface='frosted'`, so frost only ever applies when the user chose it and
|
|
714
|
+
the platform can honour it. Native window vibrancy (a desktop shell's translucent
|
|
715
|
+
surface) remains the shell's work; this is the frost the surface preference
|
|
716
|
+
promises everywhere else.
|
|
717
|
+
|
|
718
|
+
Modal dialogs only: a sheet carries `data-variant` and keeps its own bands. */
|
|
719
|
+
[data-surface='frosted'] [role='dialog']:not([data-variant]) {
|
|
720
|
+
background-color: color-mix(
|
|
721
|
+
in srgb,
|
|
722
|
+
var(--background) calc(var(--surface-alpha) * 100%),
|
|
723
|
+
transparent
|
|
724
|
+
);
|
|
725
|
+
backdrop-filter: blur(1.25rem) saturate(140%);
|
|
726
|
+
}
|
|
727
|
+
|
|
690
728
|
/* ---------------------------------------------------------------------------
|
|
691
729
|
* 4b. Named CSS hooks
|
|
692
730
|
*
|
package/dist/r/theming.json
CHANGED
|
@@ -58,6 +58,11 @@
|
|
|
58
58
|
"type": "registry:ui",
|
|
59
59
|
"target": "components/theming/theme-provider.tsx"
|
|
60
60
|
},
|
|
61
|
+
{
|
|
62
|
+
"path": "src/components/theme/theme-script.ts",
|
|
63
|
+
"type": "registry:ui",
|
|
64
|
+
"target": "components/theming/theme-script.ts"
|
|
65
|
+
},
|
|
61
66
|
{
|
|
62
67
|
"path": "src/components/theme/theme-state.ts",
|
|
63
68
|
"type": "registry:ui",
|
package/package.json
CHANGED
package/registry.json
CHANGED
|
@@ -2557,6 +2557,11 @@
|
|
|
2557
2557
|
"type": "registry:ui",
|
|
2558
2558
|
"target": "components/theming/theme-provider.tsx"
|
|
2559
2559
|
},
|
|
2560
|
+
{
|
|
2561
|
+
"path": "src/components/theme/theme-script.ts",
|
|
2562
|
+
"type": "registry:ui",
|
|
2563
|
+
"target": "components/theming/theme-script.ts"
|
|
2564
|
+
},
|
|
2560
2565
|
{
|
|
2561
2566
|
"path": "src/components/theme/theme-state.ts",
|
|
2562
2567
|
"type": "registry:ui",
|
|
@@ -2674,6 +2679,11 @@
|
|
|
2674
2679
|
"type": "registry:lib",
|
|
2675
2680
|
"target": "lib/font-catalog.ts"
|
|
2676
2681
|
},
|
|
2682
|
+
{
|
|
2683
|
+
"path": "src/lib/font-zoom.ts",
|
|
2684
|
+
"type": "registry:lib",
|
|
2685
|
+
"target": "lib/font-zoom.ts"
|
|
2686
|
+
},
|
|
2677
2687
|
{
|
|
2678
2688
|
"path": "src/lib/form-field.ts",
|
|
2679
2689
|
"type": "registry:lib",
|
|
@@ -2689,6 +2699,11 @@
|
|
|
2689
2699
|
"type": "registry:lib",
|
|
2690
2700
|
"target": "lib/overlay.ts"
|
|
2691
2701
|
},
|
|
2702
|
+
{
|
|
2703
|
+
"path": "src/lib/preference-store.ts",
|
|
2704
|
+
"type": "registry:lib",
|
|
2705
|
+
"target": "lib/preference-store.ts"
|
|
2706
|
+
},
|
|
2692
2707
|
{
|
|
2693
2708
|
"path": "src/lib/safe-script.ts",
|
|
2694
2709
|
"type": "registry:lib",
|
|
@@ -2699,6 +2714,11 @@
|
|
|
2699
2714
|
"type": "registry:lib",
|
|
2700
2715
|
"target": "lib/theme-accents.ts"
|
|
2701
2716
|
},
|
|
2717
|
+
{
|
|
2718
|
+
"path": "src/lib/theme-surface.ts",
|
|
2719
|
+
"type": "registry:lib",
|
|
2720
|
+
"target": "lib/theme-surface.ts"
|
|
2721
|
+
},
|
|
2702
2722
|
{
|
|
2703
2723
|
"path": "src/lib/themes.ts",
|
|
2704
2724
|
"type": "registry:lib",
|
|
@@ -9,6 +9,7 @@ export {
|
|
|
9
9
|
} from './theme-picker'
|
|
10
10
|
export {
|
|
11
11
|
ThemeProvider,
|
|
12
|
+
createVersionedPersistence,
|
|
12
13
|
defaultThemeSelection,
|
|
13
14
|
inlineScriptLiteral,
|
|
14
15
|
localStoragePersistence,
|
|
@@ -20,7 +21,12 @@ export {
|
|
|
20
21
|
type ThemeDensity,
|
|
21
22
|
type ThemePersistence,
|
|
22
23
|
type ThemeProviderProps,
|
|
24
|
+
type ThemeScriptOptions,
|
|
23
25
|
type ThemeSelection,
|
|
26
|
+
type ThemeStorageSchema,
|
|
27
|
+
type ThemeSurface,
|
|
28
|
+
type VersionedCodec,
|
|
29
|
+
type VersionedPersistenceOptions,
|
|
24
30
|
} from './theme-provider'
|
|
25
31
|
export { ThemeToggle, type ThemeToggleProps } from './theme-toggle'
|
|
26
32
|
export { ThemeModeToggle, type ThemeModeToggleProps } from './theme-mode-toggle'
|
|
@@ -4,11 +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'
|
|
11
13
|
import { isCustomAccent } from '#lib/accent-roles'
|
|
14
|
+
import { surfaceAlpha, type SurfaceState } from '#lib/theme-surface'
|
|
12
15
|
import { resolvedDensity, type ThemeSelection } from './theme-state'
|
|
13
16
|
|
|
14
17
|
/**
|
|
@@ -27,6 +30,16 @@ export type ThemeDocumentState = {
|
|
|
27
30
|
selection: ThemeSelection
|
|
28
31
|
appearance: ThemeAppearance
|
|
29
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
|
|
30
43
|
}
|
|
31
44
|
|
|
32
45
|
export type ThemeDocumentOptions = {
|
|
@@ -42,11 +55,29 @@ export type ThemeDocumentOptions = {
|
|
|
42
55
|
* only a custom `#rrggbb` is held to the default floor of 3:1.
|
|
43
56
|
*/
|
|
44
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
|
|
45
75
|
}
|
|
46
76
|
|
|
47
77
|
/**
|
|
48
78
|
* The custom properties a state paints: the variant's roles, with the accent
|
|
49
|
-
* selection laid over them,
|
|
79
|
+
* selection laid over them, the pinned terminal palette over that, the surface
|
|
80
|
+
* alpha, then `transformTokens`.
|
|
50
81
|
*
|
|
51
82
|
* The accent is applied *here* rather than left to the `[data-accent]` blocks in
|
|
52
83
|
* `theme.css`, because the properties are written inline and an inline declaration
|
|
@@ -59,13 +90,20 @@ export function themeDocumentTokens(
|
|
|
59
90
|
state: ThemeDocumentState,
|
|
60
91
|
options: ThemeDocumentOptions = {}
|
|
61
92
|
): { tokens: Record<string, string>; accented: boolean } {
|
|
62
|
-
const
|
|
93
|
+
const fromStylesheet = options.palette === 'stylesheet' && isCatalogueVariant(state.variant)
|
|
94
|
+
const properties = fromStylesheet ? {} : { ...themeCssVariables(state.variant) }
|
|
63
95
|
const accent = accentVariables(state.selection.accent, state.appearance, state.selection, {
|
|
64
96
|
background: state.variant.colors.background,
|
|
65
97
|
floor: options.accentContrastFloor,
|
|
66
98
|
variantId: state.variant.id,
|
|
67
99
|
})
|
|
68
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]
|
|
69
107
|
return {
|
|
70
108
|
tokens: options.transformTokens ? options.transformTokens(properties) : properties,
|
|
71
109
|
accented: accent !== undefined,
|
|
@@ -111,6 +149,17 @@ export function applyThemeToRoot(
|
|
|
111
149
|
? 'custom'
|
|
112
150
|
: state.selection.accent
|
|
113
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
|
+
}
|
|
162
|
+
|
|
114
163
|
applyAppearanceFontSettings(
|
|
115
164
|
root,
|
|
116
165
|
normalizeAppearanceEditorFontSettings(state.selection.fonts, state.selection.font).settings
|
|
@@ -2,6 +2,15 @@ import { normalizeCustomAccent } from '#lib/accent-roles'
|
|
|
2
2
|
import { accentPresets } from '#lib/tokens'
|
|
3
3
|
import { normalizeAppearanceEditorFontSettings } from '#lib/appearance-font-settings'
|
|
4
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'
|
|
5
14
|
import { defaultThemeSelection, resolvedDensity, type ThemeSelection } from './theme-state'
|
|
6
15
|
|
|
7
16
|
/**
|
|
@@ -83,6 +92,17 @@ function readSelection(storageKey: string): ThemeSelection {
|
|
|
83
92
|
// document effect removes the `data-density` that `themeScript` set for the first paint, and
|
|
84
93
|
// the next `setSelection` rewrites the store without it.
|
|
85
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,
|
|
86
106
|
}
|
|
87
107
|
} catch {
|
|
88
108
|
return defaultThemeSelection
|
|
@@ -141,3 +161,150 @@ export function writePersisted(persistence: ThemePersistence, next: ThemeSelecti
|
|
|
141
161
|
// session and does not survive the reload.
|
|
142
162
|
}
|
|
143
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
|
+
}
|