@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
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_APPEARANCE_EDITOR_FONT_SETTINGS,
|
|
3
|
+
normalizeAppearanceEditorFontSettings,
|
|
4
|
+
type AppearanceEditorFontSettings,
|
|
5
|
+
} from '#lib/appearance-font-settings'
|
|
6
|
+
import type { AccentPreset } from '@adea-ai/themes'
|
|
7
|
+
import {
|
|
8
|
+
defaultDarkThemeId,
|
|
9
|
+
defaultLightThemeId,
|
|
10
|
+
resolveVariant,
|
|
11
|
+
type ThemeAppearance,
|
|
12
|
+
type ThemeVariant,
|
|
13
|
+
} from '#lib/themes'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The pure half of the theme provider: what a selection is, how two selections
|
|
17
|
+
* merge, and what a selection resolves to.
|
|
18
|
+
*
|
|
19
|
+
* Nothing here touches the document, storage or a signal, so each rule can be
|
|
20
|
+
* tested by calling it. `ThemeProvider` owns the reactivity and wires these to it;
|
|
21
|
+
* `theme-document.ts` owns the writes; `theme-persistence.ts` owns the store.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Four independent choices, and keeping them independent is the point:
|
|
26
|
+
*
|
|
27
|
+
* - **appearance** — light, dark, or follow the system.
|
|
28
|
+
* - **a theme per appearance** — so a user can run Catppuccin Mocha by night and
|
|
29
|
+
* Solarized Light by day without re-choosing when the system flips.
|
|
30
|
+
* - **an accent** — adea's six presets, or one of the accents the selected theme
|
|
31
|
+
* pair carries itself (`ansi-blue`…), which re-colour the interactive roles on
|
|
32
|
+
* top of whichever theme is active.
|
|
33
|
+
* - **fonts** — separate family and size choices for UI, content and code.
|
|
34
|
+
*/
|
|
35
|
+
export type ThemeSelection = {
|
|
36
|
+
/** `system` follows `prefers-color-scheme` and updates when it changes. */
|
|
37
|
+
appearance: ThemeAppearance | 'system'
|
|
38
|
+
/** The variant to use when the resolved appearance is light. */
|
|
39
|
+
lightThemeId: string
|
|
40
|
+
/** The variant to use when the resolved appearance is dark. */
|
|
41
|
+
darkThemeId: string
|
|
42
|
+
/**
|
|
43
|
+
* An `accentPresets` id, a theme accent id from `themeAccents()` (`ansi-blue`…),
|
|
44
|
+
* or `theme` for the variant's own primary. A theme accent the current pair does
|
|
45
|
+
* not offer falls back to the variant's own primary.
|
|
46
|
+
*/
|
|
47
|
+
accent: string
|
|
48
|
+
/** UI-family alias for consumers of the original single-font preference. */
|
|
49
|
+
font: string
|
|
50
|
+
/** UI, content and code font settings. Omitted legacy records are migrated from `font`. */
|
|
51
|
+
fonts?: AppearanceEditorFontSettings
|
|
52
|
+
/**
|
|
53
|
+
* `comfortable` is the system's default geometry; `compact` moves the control
|
|
54
|
+
* ladder down one rung.
|
|
55
|
+
*
|
|
56
|
+
* Optional, and defaulted on read, so a preference stored before density existed
|
|
57
|
+
* keeps working and `ThemeSelection` stays assignable without it.
|
|
58
|
+
*/
|
|
59
|
+
density?: ThemeDensity
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The two density rungs.
|
|
64
|
+
*
|
|
65
|
+
* `comfortable` is the geometry in `theme.css`, so it is the absence of an
|
|
66
|
+
* attribute rather than a value: a document that ships no `data-density` at all
|
|
67
|
+
* renders correctly, which is what keeps this additive.
|
|
68
|
+
*/
|
|
69
|
+
export type ThemeDensity = 'comfortable' | 'compact'
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Deliberately lists only the fields every consumer has always had. A field added
|
|
73
|
+
* to {@link ThemeSelection} later gets no default here: `JSON.stringify` drops an
|
|
74
|
+
* `undefined`, so a store written by a consumer that never sets the field stays
|
|
75
|
+
* byte-identical to what it was before the field existed.
|
|
76
|
+
*/
|
|
77
|
+
export const defaultThemeSelection: ThemeSelection = {
|
|
78
|
+
appearance: 'system',
|
|
79
|
+
lightThemeId: defaultLightThemeId,
|
|
80
|
+
darkThemeId: defaultDarkThemeId,
|
|
81
|
+
accent: 'theme',
|
|
82
|
+
font: 'system',
|
|
83
|
+
fonts: DEFAULT_APPEARANCE_EDITOR_FONT_SETTINGS,
|
|
84
|
+
density: 'comfortable',
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A stored selection's density, defaulted.
|
|
89
|
+
*
|
|
90
|
+
* The stored value is read through here rather than trusted, because a preference
|
|
91
|
+
* written before density existed has no `density` key and a hand-edited store may
|
|
92
|
+
* hold anything.
|
|
93
|
+
*/
|
|
94
|
+
export function resolvedDensity(selection: ThemeSelection): ThemeDensity {
|
|
95
|
+
return selection.density === 'compact' ? 'compact' : 'comfortable'
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export type ThemeContextValue = {
|
|
99
|
+
/** The preference as stored. A {@link ThemeContextValue.preview} does not change it. */
|
|
100
|
+
selection: () => ThemeSelection
|
|
101
|
+
/** What the preference resolves to right now, after `system`. */
|
|
102
|
+
resolvedAppearance: () => ThemeAppearance
|
|
103
|
+
/** The variant in use. */
|
|
104
|
+
variant: () => ThemeVariant
|
|
105
|
+
/** Update part of the preference. */
|
|
106
|
+
setSelection: (patch: Partial<ThemeSelection>) => void
|
|
107
|
+
/**
|
|
108
|
+
* The built-in catalogue, so a picker does not import it separately. A plain
|
|
109
|
+
* array: {@link ThemeContextValue.allThemes} is the one that follows a host's
|
|
110
|
+
* extra themes.
|
|
111
|
+
*/
|
|
112
|
+
themes: readonly ThemeVariant[]
|
|
113
|
+
/**
|
|
114
|
+
* The accents the selected light/dark pair carries, offered beside the presets.
|
|
115
|
+
* Follows the selection: a different theme pair may offer different slots.
|
|
116
|
+
*/
|
|
117
|
+
themeAccents: () => readonly AccentPreset[]
|
|
118
|
+
/**
|
|
119
|
+
* The built-in catalogue followed by the provider's `themes` prop. Reactive when
|
|
120
|
+
* that prop is a function that reads signals.
|
|
121
|
+
*/
|
|
122
|
+
allThemes: () => readonly ThemeVariant[]
|
|
123
|
+
/**
|
|
124
|
+
* Paint the document from `draft` laid over the stored selection, without
|
|
125
|
+
* persisting it or changing what {@link ThemeContextValue.selection} returns.
|
|
126
|
+
* `undefined` ends the preview. A {@link ThemeContextValue.setSelection} ends it
|
|
127
|
+
* too, because the committed selection is then what should be showing.
|
|
128
|
+
*/
|
|
129
|
+
preview: (draft: Partial<ThemeSelection> | undefined) => void
|
|
130
|
+
/** True while a {@link ThemeContextValue.preview} draft is on the document. */
|
|
131
|
+
previewing: () => boolean
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** A selection with the `undefined` keys of a patch left out. */
|
|
135
|
+
function definedOnly<T extends object>(value: T): Partial<T> {
|
|
136
|
+
return Object.fromEntries(
|
|
137
|
+
Object.entries(value).filter(([, entry]) => entry !== undefined)
|
|
138
|
+
) as Partial<T>
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Keep the deprecated single-font view synchronized with the UI axis. */
|
|
142
|
+
export function mergeThemeSelection(
|
|
143
|
+
base: ThemeSelection,
|
|
144
|
+
patch: Partial<ThemeSelection>
|
|
145
|
+
): ThemeSelection {
|
|
146
|
+
const merged = { ...base, ...patch }
|
|
147
|
+
let fonts: AppearanceEditorFontSettings
|
|
148
|
+
|
|
149
|
+
if (patch.fonts !== undefined) {
|
|
150
|
+
fonts = normalizeAppearanceEditorFontSettings(patch.fonts).settings
|
|
151
|
+
} else if (patch.font !== undefined) {
|
|
152
|
+
const currentFonts = normalizeAppearanceEditorFontSettings(base.fonts, base.font).settings
|
|
153
|
+
fonts = normalizeAppearanceEditorFontSettings({
|
|
154
|
+
...currentFonts,
|
|
155
|
+
ui: { ...currentFonts.ui, family: patch.font },
|
|
156
|
+
}).settings
|
|
157
|
+
} else {
|
|
158
|
+
fonts = normalizeAppearanceEditorFontSettings(merged.fonts, merged.font).settings
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return { ...merged, fonts, font: fonts.ui.family }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The selection a provider starts from: the defaults, then whatever the store
|
|
166
|
+
* returned, then `initial`.
|
|
167
|
+
*
|
|
168
|
+
* `initial` wins over the store on every mount. That precedence is part of the
|
|
169
|
+
* contract Cortana relies on (its `initial` pins `appearance` and `font`), and it
|
|
170
|
+
* is why the store's answer is only the middle layer. A store that returned
|
|
171
|
+
* nothing, or threw, or answered with explicit `undefined`s, leaves the defaults
|
|
172
|
+
* standing for those fields.
|
|
173
|
+
*/
|
|
174
|
+
export function initialThemeSelection(
|
|
175
|
+
stored: Partial<ThemeSelection> | undefined,
|
|
176
|
+
initial: Partial<ThemeSelection> | undefined
|
|
177
|
+
): ThemeSelection {
|
|
178
|
+
const base = mergeThemeSelection(defaultThemeSelection, definedOnly(stored ?? {}))
|
|
179
|
+
return mergeThemeSelection(base, initial ?? {})
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** `system` resolved against what the operating system reports. */
|
|
183
|
+
export function resolveAppearance(
|
|
184
|
+
preference: ThemeSelection['appearance'],
|
|
185
|
+
system: ThemeAppearance
|
|
186
|
+
): ThemeAppearance {
|
|
187
|
+
return preference === 'system' ? system : preference
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The theme id the selection names for a resolved appearance. */
|
|
191
|
+
export function selectedThemeId(selection: ThemeSelection, appearance: ThemeAppearance): string {
|
|
192
|
+
return appearance === 'dark' ? selection.darkThemeId : selection.lightThemeId
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* What a selection paints: the resolved appearance and the variant for it.
|
|
197
|
+
*
|
|
198
|
+
* An id the registry does not hold resolves to the appearance's default rather than
|
|
199
|
+
* being rewritten (see `resolveVariant`), so the selection a host stores is never
|
|
200
|
+
* changed by being unresolvable.
|
|
201
|
+
*/
|
|
202
|
+
export function resolveThemeState(
|
|
203
|
+
selection: ThemeSelection,
|
|
204
|
+
system: ThemeAppearance,
|
|
205
|
+
registry: readonly ThemeVariant[]
|
|
206
|
+
): { appearance: ThemeAppearance; variant: ThemeVariant } {
|
|
207
|
+
const appearance = resolveAppearance(selection.appearance, system)
|
|
208
|
+
return {
|
|
209
|
+
appearance,
|
|
210
|
+
variant: resolveVariant(registry, selectedThemeId(selection, appearance), appearance),
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The registry a provider resolves against: the catalogue, then the host's extras.
|
|
216
|
+
*
|
|
217
|
+
* The catalogue wins an id collision, because it is listed first and `resolveVariant`
|
|
218
|
+
* takes the first match: a host's variant can add a theme but cannot shadow one.
|
|
219
|
+
*/
|
|
220
|
+
export function mergeRegistry(
|
|
221
|
+
builtin: readonly ThemeVariant[],
|
|
222
|
+
extra: readonly ThemeVariant[] | undefined
|
|
223
|
+
): readonly ThemeVariant[] {
|
|
224
|
+
return extra && extra.length > 0 ? [...builtin, ...extra] : builtin
|
|
225
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -135,6 +135,7 @@ export {
|
|
|
135
135
|
defaultLightThemeId,
|
|
136
136
|
isThemeAccentId,
|
|
137
137
|
resolveAccentPreset,
|
|
138
|
+
resolveVariant,
|
|
138
139
|
themeAccentsFor,
|
|
139
140
|
themeById,
|
|
140
141
|
themeCssVariables,
|
|
@@ -142,8 +143,10 @@ export {
|
|
|
142
143
|
themeRecordById,
|
|
143
144
|
themeRecords,
|
|
144
145
|
themesForAppearance,
|
|
146
|
+
tokensAsHex,
|
|
145
147
|
validateTheme,
|
|
146
148
|
validateThemeRegistry,
|
|
149
|
+
variantFromRecord,
|
|
147
150
|
withAccent,
|
|
148
151
|
type ThemeAppearance,
|
|
149
152
|
type ThemeColors,
|
|
@@ -181,7 +181,7 @@ export function normalizeAppearanceEditorFontSettings(
|
|
|
181
181
|
* stylesheet and survives CSS import reordering.
|
|
182
182
|
*/
|
|
183
183
|
export function applyAppearanceFontSettings(
|
|
184
|
-
root: HTMLElement,
|
|
184
|
+
root: Pick<HTMLElement, 'removeAttribute' | 'setAttribute' | 'style'>,
|
|
185
185
|
value: unknown
|
|
186
186
|
): NormalizedAppearanceEditorFontSettings {
|
|
187
187
|
const normalized = normalizeAppearanceEditorFontSettings(value)
|
package/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
|
/* ---------------------------------------------------------------------------
|