@adea-ai/ui 0.125.0 → 0.128.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/dist/NOTICE +40 -0
  2. package/dist/components/theme/index.d.ts +1 -1
  3. package/dist/components/theme/index.d.ts.map +1 -1
  4. package/dist/components/theme/index.js +4 -3
  5. package/dist/components/theme/theme-document.d.ts +37 -2
  6. package/dist/components/theme/theme-document.d.ts.map +1 -1
  7. package/dist/components/theme/theme-document.js +23 -3
  8. package/dist/components/theme/theme-document.js.map +1 -1
  9. package/dist/components/theme/theme-persistence.d.ts +87 -0
  10. package/dist/components/theme/theme-persistence.d.ts.map +1 -1
  11. package/dist/components/theme/theme-persistence.js +63 -2
  12. package/dist/components/theme/theme-persistence.js.map +1 -1
  13. package/dist/components/theme/theme-provider.d.ts +55 -41
  14. package/dist/components/theme/theme-provider.d.ts.map +1 -1
  15. package/dist/components/theme/theme-provider.js +59 -52
  16. package/dist/components/theme/theme-provider.js.map +1 -1
  17. package/dist/components/theme/theme-script.d.ts +100 -0
  18. package/dist/components/theme/theme-script.d.ts.map +1 -0
  19. package/dist/components/theme/theme-script.js +57 -0
  20. package/dist/components/theme/theme-script.js.map +1 -0
  21. package/dist/components/theme/theme-state.d.ts +58 -0
  22. package/dist/components/theme/theme-state.d.ts.map +1 -1
  23. package/dist/components/theme/theme-state.js +22 -1
  24. package/dist/components/theme/theme-state.js.map +1 -1
  25. package/dist/index.d.ts +4 -1
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +8 -4
  28. package/dist/lib/font-zoom.d.ts +25 -0
  29. package/dist/lib/font-zoom.d.ts.map +1 -0
  30. package/dist/lib/font-zoom.js +45 -0
  31. package/dist/lib/font-zoom.js.map +1 -0
  32. package/dist/lib/preference-store.d.ts +126 -0
  33. package/dist/lib/preference-store.d.ts.map +1 -0
  34. package/dist/lib/preference-store.js +126 -0
  35. package/dist/lib/preference-store.js.map +1 -0
  36. package/dist/lib/theme-surface.d.ts +71 -0
  37. package/dist/lib/theme-surface.d.ts.map +1 -0
  38. package/dist/lib/theme-surface.js +62 -0
  39. package/dist/lib/theme-surface.js.map +1 -0
  40. package/dist/lib/themes.d.ts +57 -1
  41. package/dist/lib/themes.d.ts.map +1 -1
  42. package/dist/lib/themes.js +81 -4
  43. package/dist/lib/themes.js.map +1 -1
  44. package/dist/lib/tokens.d.ts.map +1 -1
  45. package/dist/lib/tokens.js +15 -0
  46. package/dist/lib/tokens.js.map +1 -1
  47. package/dist/r/lib.json +15 -0
  48. package/dist/r/registry.json +26 -1
  49. package/dist/r/src/components/theme/index.ts +7 -0
  50. package/dist/r/src/components/theme/theme-document.ts +77 -2
  51. package/dist/r/src/components/theme/theme-persistence.ts +167 -0
  52. package/dist/r/src/components/theme/theme-provider.tsx +132 -50
  53. package/dist/r/src/components/theme/theme-script.ts +201 -0
  54. package/dist/r/src/components/theme/theme-state.ts +72 -0
  55. package/dist/r/src/lib/font-zoom.ts +74 -0
  56. package/dist/r/src/lib/preference-store.ts +216 -0
  57. package/dist/r/src/lib/theme-surface.ts +110 -0
  58. package/dist/r/src/lib/themes.ts +115 -2
  59. package/dist/r/src/lib/tokens.ts +17 -0
  60. package/dist/r/src/styles/theme-variants.css +3010 -0
  61. package/dist/r/src/styles/theme.css +38 -0
  62. package/dist/r/theme.json +6 -1
  63. package/dist/r/theming.json +5 -0
  64. package/package.json +2 -1
  65. package/registry.json +26 -1
  66. package/src/components/theme/index.ts +7 -0
  67. package/src/components/theme/theme-document.ts +77 -2
  68. package/src/components/theme/theme-persistence.ts +167 -0
  69. package/src/components/theme/theme-provider.tsx +132 -50
  70. package/src/components/theme/theme-script.ts +201 -0
  71. package/src/components/theme/theme-state.ts +72 -0
  72. package/src/index.ts +29 -0
  73. package/src/lib/font-zoom.ts +74 -0
  74. package/src/lib/preference-store.ts +216 -0
  75. package/src/lib/theme-surface.ts +110 -0
  76. package/src/lib/themes.ts +115 -2
  77. package/src/lib/tokens.ts +17 -0
  78. package/src/styles/theme-variants.css +3010 -0
  79. package/src/styles/theme.css +38 -0
@@ -0,0 +1,216 @@
1
+ /*
2
+ * Copyright (c) 2026 Wing
3
+ * Licensed under the MIT License.
4
+ *
5
+ * The recovery behaviour here (quarantine before any write, the envelope shape, the
6
+ * legacy key that is read once and never deleted) is Adea's accepted port of Zeron's
7
+ * preference store (https://github.com/zeronsh/zeron),
8
+ * `packages/ui/src/components/appearance.ts`, moved here so the shared provider and
9
+ * Adea run one implementation. See NOTICE.
10
+ */
11
+
12
+ /**
13
+ * A versioned preference store: read one JSON document, refuse to guess about what
14
+ * it cannot read, and keep what it refused.
15
+ *
16
+ * Nothing here knows what a preference *is*. A host hands the engine a key, a
17
+ * version and (through `createVersionedPersistence`) a codec; the engine owns only
18
+ * the part every versioned store gets wrong the same way, which is what happens to
19
+ * a document that is not the one the code expects:
20
+ *
21
+ * - A document that is not JSON, or not a record, or carries a version the code
22
+ * does not know (usually a newer build's) is **quarantined, byte for byte, into a
23
+ * recovery key** before anything else happens. The store then answers as if
24
+ * nothing were stored. Because the quarantine runs at read time, ahead of any
25
+ * write, saving valid preferences later cannot destroy the unread original.
26
+ * - A legacy key is consulted only when the main key is *absent*, never when it is
27
+ * unreadable: a corrupt main document is not "no preference", and falling back to
28
+ * an older, staler key would quietly restore a choice the user has since changed.
29
+ * The legacy key is never deleted.
30
+ * - Blocked storage (a sandboxed iframe, a privacy mode) throws on the mere access
31
+ * of the `localStorage` global. Every path here treats that as "nothing stored".
32
+ *
33
+ * The reads and writes are against a {@link PreferenceStorage}, so the rules run in a
34
+ * test without a DOM.
35
+ */
36
+
37
+ /** The part of `Storage` the engine uses. */
38
+ export type PreferenceStorage = Pick<Storage, 'getItem' | 'setItem'>
39
+
40
+ /** Why an unread document was quarantined. */
41
+ export type RecoveryReason = 'corrupt_json' | 'unsupported_record'
42
+
43
+ /**
44
+ * What is written to the recovery key.
45
+ *
46
+ * The shape is a compatibility surface: Adea's stored envelopes already have it, and
47
+ * a tool that inspects a user's storage reads it. Do not rename a field.
48
+ */
49
+ export type RecoveryEnvelope = Readonly<{
50
+ schemaVersion: 1
51
+ reason: RecoveryReason
52
+ /** An ISO-8601 timestamp of the capture; refreshed by each read of the same bytes. */
53
+ capturedAt: string
54
+ /** The document exactly as it was stored. */
55
+ raw: string
56
+ }>
57
+
58
+ /**
59
+ * The preference store, or nothing.
60
+ *
61
+ * The `typeof` guard sits inside the `try` for a reason: where storage is blocked,
62
+ * merely *reading* the `localStorage` global throws, so a guard in front of the `try`
63
+ * is itself the crash.
64
+ */
65
+ export function defaultPreferenceStorage(): PreferenceStorage | undefined {
66
+ try {
67
+ return typeof localStorage === 'undefined' ? undefined : localStorage
68
+ } catch {
69
+ return undefined
70
+ }
71
+ }
72
+
73
+ /** The envelope for a document, stamped with `now`. */
74
+ export function recoveryEnvelope(
75
+ raw: string,
76
+ reason: RecoveryReason,
77
+ now: Date = new Date()
78
+ ): RecoveryEnvelope {
79
+ return { schemaVersion: 1, reason, capturedAt: now.toISOString(), raw }
80
+ }
81
+
82
+ /**
83
+ * Quarantine an unread document into `recoveryKey`.
84
+ *
85
+ * Idempotent, so re-reading the same unread value refreshes the capture without
86
+ * losing it. Best effort: a store that cannot be written leaves the caller to fail
87
+ * closed on the defaults, which is the same outcome as not having a recovery key at
88
+ * all. Never throws.
89
+ *
90
+ * Exported so a host quarantines its *other* documents (a theme library, say) with
91
+ * the same envelope. Give each document its own `recoveryKey`: two documents that
92
+ * share one overwrite each other's capture.
93
+ */
94
+ export function quarantineRecord(
95
+ storage: PreferenceStorage,
96
+ recoveryKey: string,
97
+ raw: string,
98
+ reason: RecoveryReason,
99
+ now: () => Date = () => new Date()
100
+ ): void {
101
+ try {
102
+ storage.setItem(recoveryKey, JSON.stringify(recoveryEnvelope(raw, reason, now())))
103
+ } catch {
104
+ // Quarantine is best effort; the active preference still fails closed.
105
+ }
106
+ }
107
+
108
+ /** Where a versioned document lives and which version the code understands. */
109
+ export type VersionedRecordSchema = {
110
+ /** The storage key of the document. */
111
+ key: string
112
+ /**
113
+ * The only `version` the code reads. Absent, the document is unversioned and any
114
+ * JSON object is accepted; present, a record whose top-level `version` is not
115
+ * exactly this number is unsupported (newer or older), not "close enough".
116
+ */
117
+ version?: number
118
+ /** Where an unread document is kept. Default `${key}.recovery`. */
119
+ recoveryKey?: string
120
+ }
121
+
122
+ /**
123
+ * What reading the document found.
124
+ *
125
+ * - `absent`: nothing is stored under the key.
126
+ * - `record`: a JSON object this code can read; `raw` is the stored text.
127
+ * - `quarantined`: something was stored and cannot be read. It has already been
128
+ * written to the recovery key.
129
+ * - `unavailable`: the store itself could not be read.
130
+ */
131
+ export type VersionedRead =
132
+ | { status: 'absent' }
133
+ | { status: 'record'; record: Readonly<Record<string, unknown>>; raw: string }
134
+ | { status: 'quarantined'; reason: RecoveryReason; raw: string }
135
+ | { status: 'unavailable' }
136
+
137
+ /** The default recovery key for a document key. */
138
+ export function recoveryKeyFor(schema: VersionedRecordSchema): string {
139
+ return schema.recoveryKey ?? `${schema.key}.recovery`
140
+ }
141
+
142
+ /**
143
+ * Read the document under `schema.key`.
144
+ *
145
+ * The classification is the contract: a value that is not valid JSON, or is JSON but
146
+ * not an object (`null`, a number, a string), is `corrupt_json`; an object (or array)
147
+ * that is not a record of the supported version is `unsupported_record`. Both are
148
+ * quarantined before this returns. An empty string is a stored value, so it is
149
+ * corrupt rather than absent.
150
+ */
151
+ export function readVersionedRecord(
152
+ storage: PreferenceStorage,
153
+ schema: VersionedRecordSchema,
154
+ now?: () => Date
155
+ ): VersionedRead {
156
+ let raw: string | null
157
+ try {
158
+ raw = storage.getItem(schema.key)
159
+ } catch {
160
+ return { status: 'unavailable' }
161
+ }
162
+ if (raw === null) return { status: 'absent' }
163
+
164
+ const recoveryKey = recoveryKeyFor(schema)
165
+ let parsed: unknown
166
+ try {
167
+ parsed = JSON.parse(raw)
168
+ } catch {
169
+ quarantineRecord(storage, recoveryKey, raw, 'corrupt_json', now)
170
+ return { status: 'quarantined', reason: 'corrupt_json', raw }
171
+ }
172
+ if (typeof parsed !== 'object' || parsed === null) {
173
+ quarantineRecord(storage, recoveryKey, raw, 'corrupt_json', now)
174
+ return { status: 'quarantined', reason: 'corrupt_json', raw }
175
+ }
176
+ if (
177
+ Array.isArray(parsed) ||
178
+ (schema.version !== undefined && (parsed as { version?: unknown }).version !== schema.version)
179
+ ) {
180
+ quarantineRecord(storage, recoveryKey, raw, 'unsupported_record', now)
181
+ return { status: 'quarantined', reason: 'unsupported_record', raw }
182
+ }
183
+ return { status: 'record', record: parsed as Record<string, unknown>, raw }
184
+ }
185
+
186
+ /**
187
+ * The legacy single-key value, or `null`.
188
+ *
189
+ * Only the caller decides whether to look: the engine's rule is to ask when the main
190
+ * key is `absent`, not when it was quarantined.
191
+ */
192
+ export function readLegacyValue(storage: PreferenceStorage, legacyKey: string): string | null {
193
+ try {
194
+ return storage.getItem(legacyKey)
195
+ } catch {
196
+ return null
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Write `document` to `key` as JSON. Returns whether it was stored; a full or blocked
202
+ * store returns `false` and never throws, because losing a preference must not break
203
+ * the switch that tried to save it.
204
+ */
205
+ export function writeVersionedRecord(
206
+ storage: PreferenceStorage,
207
+ key: string,
208
+ document: unknown
209
+ ): boolean {
210
+ try {
211
+ storage.setItem(key, JSON.stringify(document))
212
+ return true
213
+ } catch {
214
+ return false
215
+ }
216
+ }
@@ -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
+ }
@@ -37,6 +37,7 @@ import {
37
37
  ACCENTS,
38
38
  accentForeground,
39
39
  chartSeries,
40
+ editorRolesHex,
40
41
  getAccent,
41
42
  getTheme,
42
43
  hasTheme,
@@ -613,6 +614,97 @@ export function accentRolesForSelection(
613
614
  })
614
615
  }
615
616
 
617
+ /**
618
+ * Which projection fills the `--editor-*` ramp.
619
+ *
620
+ * - `syntax` (the default) is the catalogue's quiet syntax roles, as `ThemeVariant.editor`
621
+ * holds them: a comment may sit below body-text contrast, because in a code view
622
+ * it is meant to recede.
623
+ * - `floor` holds every editor role to 4.5:1 on the canvas (`editorRolesHex`), for a
624
+ * host that renders code as content rather than as decoration. It is hex, where
625
+ * `syntax` is `oklch()`.
626
+ */
627
+ export type EditorRolesMode = 'syntax' | 'floor'
628
+
629
+ /** Options for {@link themeCssVariables}. */
630
+ export type ThemeCssVariableOptions = {
631
+ /** The `--editor-*` projection. Default `syntax`: absent, the output is unchanged. */
632
+ editor?: EditorRolesMode
633
+ }
634
+
635
+ /**
636
+ * True for the catalogue's own variant, not a host's copy that merely shares its id.
637
+ *
638
+ * Identity rather than id, because a host's imported theme may reuse a catalogue id
639
+ * with different colours, and projecting the catalogue's record for it would paint
640
+ * the wrong palette.
641
+ */
642
+ export function isCatalogueVariant(variant: ThemeVariant): boolean {
643
+ return themeById(variant.id) === variant
644
+ }
645
+
646
+ /**
647
+ * The editor palette held to 4.5:1, or `fallback` for a record that cannot reach it.
648
+ *
649
+ * Some palettes (an imported one, most often) cannot be repaired to the floor from
650
+ * their own ramp within the catalogue's lightness budget, and `editorRolesHex` says
651
+ * so by throwing. A palette that paints nothing is worse than one that paints its
652
+ * quiet roles, so the throw is the cue to fall back rather than to fail. `project` is
653
+ * a parameter so that path can be exercised: no catalogue theme takes it.
654
+ */
655
+ export function floorEditorPalette(
656
+ record: AdeaTheme,
657
+ fallback: ThemeEditorPalette,
658
+ project: (theme: AdeaTheme) => Record<keyof ThemeEditorPalette, string> = editorRolesHex
659
+ ): ThemeEditorPalette {
660
+ try {
661
+ const roles = project(record)
662
+ return {
663
+ keyword: roles.keyword,
664
+ string: roles.string,
665
+ number: roles.number,
666
+ comment: roles.comment,
667
+ function: roles.function,
668
+ variable: roles.variable,
669
+ type: roles.type,
670
+ tag: roles.tag,
671
+ attribute: roles.attribute,
672
+ operator: roles.operator,
673
+ heading: roles.heading,
674
+ link: roles.link,
675
+ diffAdd: roles.diffAdd,
676
+ diffDelete: roles.diffDelete,
677
+ diffHunk: roles.diffHunk,
678
+ searchMatch: roles.searchMatch,
679
+ }
680
+ } catch {
681
+ return fallback
682
+ }
683
+ }
684
+
685
+ const floorEditorCache = new Map<string, ThemeEditorPalette>()
686
+
687
+ /**
688
+ * The editor palette a variant paints under a projection.
689
+ *
690
+ * `floor` applies to the catalogue's own variants, whose record the catalogue holds.
691
+ * A variant it does not hold (a host's imported theme) has no record to project and
692
+ * keeps its own `editor`; such a theme normally carries its roles in `tokens`, which
693
+ * win over either projection anyway. Computed once per theme: the repair is a search.
694
+ */
695
+ export function editorPaletteFor(
696
+ theme: ThemeVariant,
697
+ mode: EditorRolesMode = 'syntax'
698
+ ): ThemeEditorPalette {
699
+ if (mode !== 'floor' || !isCatalogueVariant(theme)) return theme.editor
700
+ const cached = floorEditorCache.get(theme.id)
701
+ if (cached) return cached
702
+ const record = getTheme(theme.id)
703
+ const palette = record ? floorEditorPalette(record, theme.editor) : theme.editor
704
+ floorEditorCache.set(theme.id, palette)
705
+ return palette
706
+ }
707
+
616
708
  /**
617
709
  * The roles a variant sets, as CSS custom properties.
618
710
  *
@@ -620,11 +712,18 @@ export function accentRolesForSelection(
620
712
  * names. A provider applies this; nothing else repeats it. The `--terminal-*` and
621
713
  * `--editor-*` ramps are presentations of the catalogue's ANSI and syntax data rather
622
714
  * than separate roles, which is why they are built here and not in the catalogue.
715
+ *
716
+ * `options.editor` picks the `--editor-*` projection ({@link EditorRolesMode}).
717
+ * Absent it, the result is what it has always been; the generated `theme-variants.css`
718
+ * and the design-system artifact are built without it.
623
719
  */
624
- export function themeCssVariables(theme: ThemeVariant): Record<string, string> {
720
+ export function themeCssVariables(
721
+ theme: ThemeVariant,
722
+ options: ThemeCssVariableOptions = {}
723
+ ): Record<string, string> {
625
724
  const c = theme.colors
626
725
  const t = theme.terminal
627
- const e = theme.editor
726
+ const e = editorPaletteFor(theme, options.editor)
628
727
  const derived = {
629
728
  '--background': c.background,
630
729
  '--foreground': c.foreground,
@@ -733,6 +832,20 @@ export function themeCssVariables(theme: ThemeVariant): Record<string, string> {
733
832
  return theme.tokens ? { ...derived, ...theme.tokens } : derived
734
833
  }
735
834
 
835
+ /**
836
+ * The `--terminal-*` properties of a variant: the palette a terminal paints.
837
+ *
838
+ * What a pinned terminal theme writes over the interface variant's own. Taken from
839
+ * {@link themeCssVariables} rather than rebuilt, so the names, and a custom
840
+ * variant's verbatim `tokens`, can never drift from what the interface variant
841
+ * would have written for the same palette.
842
+ */
843
+ export function terminalCssVariables(theme: ThemeVariant): Record<string, string> {
844
+ return Object.fromEntries(
845
+ Object.entries(themeCssVariables(theme)).filter(([name]) => name.startsWith('--terminal-'))
846
+ )
847
+ }
848
+
736
849
  /**
737
850
  * Token values with every `oklch()` colour written as `#rrggbb`.
738
851
  *
@@ -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
  {