@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
|
@@ -1,35 +1,39 @@
|
|
|
1
1
|
import type { JSX } from 'solid-js'
|
|
2
|
-
import {
|
|
3
|
-
createContext,
|
|
4
|
-
createEffect,
|
|
5
|
-
createMemo,
|
|
6
|
-
createSignal,
|
|
7
|
-
onCleanup,
|
|
8
|
-
splitProps,
|
|
9
|
-
useContext,
|
|
10
|
-
} from 'solid-js'
|
|
11
|
-
import { accentPresets } from '../../lib/tokens'
|
|
2
|
+
import { createEffect, createMemo, createSignal, onCleanup, splitProps } from 'solid-js'
|
|
12
3
|
import { safeScriptStringLiteral } from '../../lib/safe-script'
|
|
4
|
+
import { fontSettingsBootstrapScript } from '../../lib/appearance-font-settings'
|
|
13
5
|
import {
|
|
14
|
-
applyAppearanceFontSettings,
|
|
15
|
-
DEFAULT_APPEARANCE_EDITOR_FONT_SETTINGS,
|
|
16
|
-
fontSettingsBootstrapScript,
|
|
17
|
-
normalizeAppearanceEditorFontSettings,
|
|
18
|
-
type AppearanceEditorFontSettings,
|
|
19
|
-
} from '../../lib/appearance-font-settings'
|
|
20
|
-
import type { AccentPreset } from '@adea-ai/themes'
|
|
21
|
-
import {
|
|
22
|
-
accentVariables,
|
|
23
6
|
builtinThemes,
|
|
24
|
-
defaultDarkThemeId,
|
|
25
|
-
defaultLightThemeId,
|
|
26
|
-
isThemeAccentId,
|
|
27
7
|
themeAccentsFor,
|
|
28
|
-
themeById,
|
|
29
|
-
themeCssVariables,
|
|
30
8
|
type ThemeAppearance,
|
|
31
9
|
type ThemeVariant,
|
|
32
10
|
} from '../../lib/themes'
|
|
11
|
+
import { themeContext } from './theme-context'
|
|
12
|
+
import { applyThemeToRoot, applyWithoutTransitions } from './theme-document'
|
|
13
|
+
import {
|
|
14
|
+
localStoragePersistence,
|
|
15
|
+
readPersisted,
|
|
16
|
+
writePersisted,
|
|
17
|
+
type ThemePersistence,
|
|
18
|
+
} from './theme-persistence'
|
|
19
|
+
import {
|
|
20
|
+
initialThemeSelection,
|
|
21
|
+
mergeRegistry,
|
|
22
|
+
mergeThemeSelection,
|
|
23
|
+
resolveThemeState,
|
|
24
|
+
type ThemeContextValue,
|
|
25
|
+
type ThemeSelection,
|
|
26
|
+
} from './theme-state'
|
|
27
|
+
|
|
28
|
+
export { useOptionalTheme, useTheme } from './theme-context'
|
|
29
|
+
export { localStoragePersistence, type ThemePersistence } from './theme-persistence'
|
|
30
|
+
export {
|
|
31
|
+
defaultThemeSelection,
|
|
32
|
+
resolvedDensity,
|
|
33
|
+
type ThemeContextValue,
|
|
34
|
+
type ThemeDensity,
|
|
35
|
+
type ThemeSelection,
|
|
36
|
+
} from './theme-state'
|
|
33
37
|
|
|
34
38
|
/**
|
|
35
39
|
* ThemeProvider.
|
|
@@ -39,19 +43,17 @@ import {
|
|
|
39
43
|
*
|
|
40
44
|
* ## What a preference is
|
|
41
45
|
*
|
|
42
|
-
* Four independent choices, and keeping them independent is the
|
|
46
|
+
* Four independent choices (`ThemeSelection`), and keeping them independent is the
|
|
47
|
+
* point: appearance, a theme per appearance, an accent and fonts. They compose: a
|
|
48
|
+
* user who wants Nord with a violet accent and the system font should not need
|
|
49
|
+
* someone to author that combination.
|
|
43
50
|
*
|
|
44
|
-
*
|
|
45
|
-
* - **a theme per appearance** — so a user can run Catppuccin Mocha by night and
|
|
46
|
-
* Solarized Light by day without re-choosing when the system flips.
|
|
47
|
-
* - **an accent** — adea's six presets, or one of the accents the selected theme
|
|
48
|
-
* pair carries itself (`ansi-blue`…), which re-colour the interactive roles on
|
|
49
|
-
* top of whichever theme is active.
|
|
50
|
-
* - **fonts** — separate family and size choices for UI, content and code.
|
|
51
|
+
* ## Where the rules live
|
|
51
52
|
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
53
|
+
* This component is wiring. What a selection merges to and resolves to is
|
|
54
|
+
* `theme-state.ts`; what lands on `<html>` is `theme-document.ts`; where the
|
|
55
|
+
* preference is kept is `theme-persistence.ts`. Each is plain code a test can call,
|
|
56
|
+
* which is the reason none of it is inlined here.
|
|
55
57
|
*
|
|
56
58
|
* ## Why the variants are applied as inline properties
|
|
57
59
|
*
|
|
@@ -62,167 +64,6 @@ import {
|
|
|
62
64
|
* stylesheet. `theme.css` keeps the default theme's values so the first paint is
|
|
63
65
|
* correct before any script runs.
|
|
64
66
|
*/
|
|
65
|
-
export type ThemeSelection = {
|
|
66
|
-
/** `system` follows `prefers-color-scheme` and updates when it changes. */
|
|
67
|
-
appearance: ThemeAppearance | 'system'
|
|
68
|
-
/** The variant to use when the resolved appearance is light. */
|
|
69
|
-
lightThemeId: string
|
|
70
|
-
/** The variant to use when the resolved appearance is dark. */
|
|
71
|
-
darkThemeId: string
|
|
72
|
-
/**
|
|
73
|
-
* An `accentPresets` id, a theme accent id from `themeAccents()` (`ansi-blue`…),
|
|
74
|
-
* or `theme` for the variant's own primary. A theme accent the current pair does
|
|
75
|
-
* not offer falls back to the variant's own primary.
|
|
76
|
-
*/
|
|
77
|
-
accent: string
|
|
78
|
-
/** UI-family alias for consumers of the original single-font preference. */
|
|
79
|
-
font: string
|
|
80
|
-
/** UI, content and code font settings. Omitted legacy records are migrated from `font`. */
|
|
81
|
-
fonts?: AppearanceEditorFontSettings
|
|
82
|
-
/**
|
|
83
|
-
* `comfortable` is the system's default geometry; `compact` moves the control
|
|
84
|
-
* ladder down one rung.
|
|
85
|
-
*
|
|
86
|
-
* Optional, and defaulted on read, so a preference stored before density existed
|
|
87
|
-
* keeps working and `ThemeSelection` stays assignable without it.
|
|
88
|
-
*/
|
|
89
|
-
density?: ThemeDensity
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
export const defaultThemeSelection: ThemeSelection = {
|
|
93
|
-
appearance: 'system',
|
|
94
|
-
lightThemeId: defaultLightThemeId,
|
|
95
|
-
darkThemeId: defaultDarkThemeId,
|
|
96
|
-
accent: 'theme',
|
|
97
|
-
font: 'system',
|
|
98
|
-
fonts: DEFAULT_APPEARANCE_EDITOR_FONT_SETTINGS,
|
|
99
|
-
density: 'comfortable',
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* The two density rungs.
|
|
104
|
-
*
|
|
105
|
-
* `comfortable` is the geometry in `theme.css`, so it is the absence of an
|
|
106
|
-
* attribute rather than a value: a document that ships no `data-density` at all
|
|
107
|
-
* renders correctly, which is what keeps this additive.
|
|
108
|
-
*/
|
|
109
|
-
export type ThemeDensity = 'comfortable' | 'compact'
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* A stored selection's density, defaulted.
|
|
113
|
-
*
|
|
114
|
-
* The stored value is read through here rather than trusted, because a preference
|
|
115
|
-
* written before density existed has no `density` key and a hand-edited store may
|
|
116
|
-
* hold anything.
|
|
117
|
-
*/
|
|
118
|
-
export function resolvedDensity(selection: ThemeSelection): ThemeDensity {
|
|
119
|
-
return selection.density === 'compact' ? 'compact' : 'comfortable'
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
export type ThemeContextValue = {
|
|
123
|
-
/** The preference as stored. */
|
|
124
|
-
selection: () => ThemeSelection
|
|
125
|
-
/** What the preference resolves to right now, after `system`. */
|
|
126
|
-
resolvedAppearance: () => ThemeAppearance
|
|
127
|
-
/** The variant in use. */
|
|
128
|
-
variant: () => ThemeVariant
|
|
129
|
-
/** Update part of the preference. */
|
|
130
|
-
setSelection: (patch: Partial<ThemeSelection>) => void
|
|
131
|
-
/** The catalogue, so a picker does not import it separately. */
|
|
132
|
-
themes: readonly ThemeVariant[]
|
|
133
|
-
/**
|
|
134
|
-
* The accents the selected light/dark pair carries, offered beside the presets.
|
|
135
|
-
* Follows the selection: a different theme pair may offer different slots.
|
|
136
|
-
*/
|
|
137
|
-
themeAccents: () => readonly AccentPreset[]
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const ThemeContext = createContext<ThemeContextValue>()
|
|
141
|
-
|
|
142
|
-
export function useTheme(): ThemeContextValue {
|
|
143
|
-
const value = useContext(ThemeContext)
|
|
144
|
-
if (!value) throw new Error('useTheme must be called inside a <ThemeProvider>.')
|
|
145
|
-
return value
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* The preference store, or nothing.
|
|
150
|
-
*
|
|
151
|
-
* Only ever called inside a `try`: where storage is blocked — a sandboxed iframe, a
|
|
152
|
-
* privacy mode, a denied site-data permission — merely *reading* the `localStorage`
|
|
153
|
-
* global throws ("Access is denied", `SecurityError`), so even the `typeof` guard
|
|
154
|
-
* has to sit inside the `try` rather than in front of it. Without a store the
|
|
155
|
-
* preference lives only in the provider's signal for the session.
|
|
156
|
-
*/
|
|
157
|
-
function preferenceStore(): Storage | undefined {
|
|
158
|
-
return typeof localStorage === 'undefined' ? undefined : localStorage
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/** The stored preference, validated against the catalogue. */
|
|
162
|
-
function readSelection(storageKey: string): ThemeSelection {
|
|
163
|
-
try {
|
|
164
|
-
const raw = preferenceStore()?.getItem(storageKey)
|
|
165
|
-
if (!raw) return defaultThemeSelection
|
|
166
|
-
const parsed = JSON.parse(raw) as Partial<ThemeSelection>
|
|
167
|
-
const fonts = normalizeAppearanceEditorFontSettings(parsed.fonts, parsed.font).settings
|
|
168
|
-
return {
|
|
169
|
-
appearance:
|
|
170
|
-
parsed.appearance === 'light' ||
|
|
171
|
-
parsed.appearance === 'dark' ||
|
|
172
|
-
parsed.appearance === 'system'
|
|
173
|
-
? parsed.appearance
|
|
174
|
-
: defaultThemeSelection.appearance,
|
|
175
|
-
// An unknown id would leave the document with no theme at all, so a stale
|
|
176
|
-
// preference from an older catalogue falls back rather than applying nothing.
|
|
177
|
-
lightThemeId:
|
|
178
|
-
themeById(parsed.lightThemeId ?? '')?.appearance === 'light'
|
|
179
|
-
? (parsed.lightThemeId as string)
|
|
180
|
-
: defaultLightThemeId,
|
|
181
|
-
darkThemeId:
|
|
182
|
-
themeById(parsed.darkThemeId ?? '')?.appearance === 'dark'
|
|
183
|
-
? (parsed.darkThemeId as string)
|
|
184
|
-
: defaultDarkThemeId,
|
|
185
|
-
// A theme accent is kept whenever it names a slot, even one the stored pair
|
|
186
|
-
// cannot offer: the id is role-shaped so it survives a theme switch, and an
|
|
187
|
-
// unoffered slot already resolves to the theme's own primary when applied.
|
|
188
|
-
accent:
|
|
189
|
-
typeof parsed.accent === 'string' &&
|
|
190
|
-
(accentPresets.some((preset) => preset.id === parsed.accent) ||
|
|
191
|
-
isThemeAccentId(parsed.accent))
|
|
192
|
-
? parsed.accent
|
|
193
|
-
: defaultThemeSelection.accent,
|
|
194
|
-
font: fonts.ui.family,
|
|
195
|
-
fonts,
|
|
196
|
-
// Every axis has to be read back here, or it is lost twice: the effect below
|
|
197
|
-
// removes the `data-density` that `themeScript` set for the first paint, and
|
|
198
|
-
// the next `setSelection` rewrites the store without it.
|
|
199
|
-
density: resolvedDensity(parsed as ThemeSelection),
|
|
200
|
-
}
|
|
201
|
-
} catch {
|
|
202
|
-
return defaultThemeSelection
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/** Keep the deprecated single-font view synchronized with the UI axis. */
|
|
207
|
-
function mergeThemeSelection(base: ThemeSelection, patch: Partial<ThemeSelection>): ThemeSelection {
|
|
208
|
-
const merged = { ...base, ...patch }
|
|
209
|
-
let fonts: AppearanceEditorFontSettings
|
|
210
|
-
|
|
211
|
-
if (patch.fonts !== undefined) {
|
|
212
|
-
fonts = normalizeAppearanceEditorFontSettings(patch.fonts).settings
|
|
213
|
-
} else if (patch.font !== undefined) {
|
|
214
|
-
const currentFonts = normalizeAppearanceEditorFontSettings(base.fonts, base.font).settings
|
|
215
|
-
fonts = normalizeAppearanceEditorFontSettings({
|
|
216
|
-
...currentFonts,
|
|
217
|
-
ui: { ...currentFonts.ui, family: patch.font },
|
|
218
|
-
}).settings
|
|
219
|
-
} else {
|
|
220
|
-
fonts = normalizeAppearanceEditorFontSettings(merged.fonts, merged.font).settings
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
return { ...merged, fonts, font: fonts.ui.family }
|
|
224
|
-
}
|
|
225
|
-
|
|
226
67
|
export type ThemeProviderProps = {
|
|
227
68
|
children?: JSX.Element
|
|
228
69
|
/** Where the preference is persisted. Distinct per application. */
|
|
@@ -242,14 +83,60 @@ export type ThemeProviderProps = {
|
|
|
242
83
|
* seeds from `initial`, and persists every `setSelection`.
|
|
243
84
|
*/
|
|
244
85
|
selection?: Partial<ThemeSelection>
|
|
86
|
+
/**
|
|
87
|
+
* Where the preference is kept, in place of `localStorage` under `storageKey`.
|
|
88
|
+
*
|
|
89
|
+
* Read once when the provider is created, so changing the prop later does not
|
|
90
|
+
* move the store. `storageKey` is ignored when this is given.
|
|
91
|
+
*/
|
|
92
|
+
persistence?: ThemePersistence
|
|
93
|
+
/**
|
|
94
|
+
* Variants the host adds to the built-in catalogue — themes a user imported.
|
|
95
|
+
*
|
|
96
|
+
* A function is read inside the provider's reactive scope, so one that reads a
|
|
97
|
+
* signal keeps the picker and the document current when the set changes. A
|
|
98
|
+
* stored id that names no variant, built in or added, paints the default of its
|
|
99
|
+
* appearance and stays in the selection untouched.
|
|
100
|
+
*/
|
|
101
|
+
themes?: readonly ThemeVariant[] | (() => readonly ThemeVariant[])
|
|
102
|
+
/**
|
|
103
|
+
* Rewrites the custom properties of each pass before they are written to
|
|
104
|
+
* `<html>`: the variant's roles with the accent over them. `tokensAsHex` from
|
|
105
|
+
* `lib/themes` is the one the system ships.
|
|
106
|
+
*/
|
|
107
|
+
transformTokens?: (tokens: Record<string, string>) => Record<string, string>
|
|
108
|
+
/**
|
|
109
|
+
* Switch CSS transitions off for the frame that carries a change, so a theme
|
|
110
|
+
* switch cannot animate the whole page. Off by default: it adds a `<style>`
|
|
111
|
+
* element to `<head>` for two frames on every change.
|
|
112
|
+
*/
|
|
113
|
+
disableTransitionOnChange?: boolean
|
|
245
114
|
}
|
|
246
115
|
|
|
247
116
|
export function ThemeProvider(props: ThemeProviderProps) {
|
|
248
|
-
const [local] = splitProps(props, [
|
|
117
|
+
const [local] = splitProps(props, [
|
|
118
|
+
'children',
|
|
119
|
+
'storageKey',
|
|
120
|
+
'initial',
|
|
121
|
+
'selection',
|
|
122
|
+
'persistence',
|
|
123
|
+
'themes',
|
|
124
|
+
'transformTokens',
|
|
125
|
+
'disableTransitionOnChange',
|
|
126
|
+
])
|
|
249
127
|
const storageKey = () => local.storageKey ?? 'adea-appearance'
|
|
128
|
+
const persistence = local.persistence ?? localStoragePersistence(storageKey)
|
|
129
|
+
|
|
130
|
+
// Read before the first resolution and before the store is, so a persistence whose
|
|
131
|
+
// answer depends on the extras (an id it only accepts if the theme exists) sees
|
|
132
|
+
// them.
|
|
133
|
+
const extraThemes = createMemo(() =>
|
|
134
|
+
typeof local.themes === 'function' ? local.themes() : (local.themes ?? [])
|
|
135
|
+
)
|
|
136
|
+
const registry = createMemo(() => mergeRegistry(builtinThemes, extraThemes()))
|
|
250
137
|
|
|
251
138
|
const [selection, setSelectionState] = createSignal<ThemeSelection>(
|
|
252
|
-
|
|
139
|
+
initialThemeSelection(readPersisted(persistence), local.initial)
|
|
253
140
|
)
|
|
254
141
|
|
|
255
142
|
// The controlled half of the contract. A patch from the owner replaces the
|
|
@@ -272,60 +159,43 @@ export function ThemeProvider(props: ThemeProviderProps) {
|
|
|
272
159
|
onCleanup(() => query.removeEventListener('change', onChange))
|
|
273
160
|
}
|
|
274
161
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
162
|
+
// What the stored preference resolves to. `selection()`, `variant()` and
|
|
163
|
+
// `resolvedAppearance()` answer from this and never from a preview, so a picker
|
|
164
|
+
// that previews a draft still reads back what is committed.
|
|
165
|
+
const committed = createMemo(() => resolveThemeState(selection(), systemAppearance(), registry()))
|
|
166
|
+
const resolvedAppearance = createMemo<ThemeAppearance>(() => committed().appearance)
|
|
167
|
+
const variant = createMemo<ThemeVariant>(() => committed().variant)
|
|
168
|
+
|
|
169
|
+
const [draft, setDraft] = createSignal<Partial<ThemeSelection> | undefined>()
|
|
170
|
+
const shownSelection = createMemo(() => {
|
|
171
|
+
const preview = draft()
|
|
172
|
+
return preview ? mergeThemeSelection(selection(), preview) : selection()
|
|
284
173
|
})
|
|
174
|
+
const shown = createMemo(() =>
|
|
175
|
+
draft() ? resolveThemeState(shownSelection(), systemAppearance(), registry()) : committed()
|
|
176
|
+
)
|
|
285
177
|
|
|
178
|
+
let written: ReadonlySet<string> | undefined
|
|
179
|
+
let restoreTransitions: (() => void) | undefined
|
|
286
180
|
createEffect(() => {
|
|
287
181
|
if (typeof document === 'undefined') return
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
const accent = accentVariables(selection().accent, resolvedAppearance(), selection())
|
|
303
|
-
if (accent) Object.assign(properties, accent)
|
|
304
|
-
|
|
305
|
-
for (const [name, value] of Object.entries(properties)) {
|
|
306
|
-
root.style.setProperty(name, value)
|
|
182
|
+
const state = {
|
|
183
|
+
selection: shownSelection(),
|
|
184
|
+
appearance: shown().appearance,
|
|
185
|
+
variant: shown().variant,
|
|
186
|
+
}
|
|
187
|
+
const options = { transformTokens: local.transformTokens }
|
|
188
|
+
const apply = () => {
|
|
189
|
+
written = applyThemeToRoot(document.documentElement, state, options, written)
|
|
190
|
+
}
|
|
191
|
+
if (local.disableTransitionOnChange) {
|
|
192
|
+
restoreTransitions?.()
|
|
193
|
+
restoreTransitions = applyWithoutTransitions(document, apply)
|
|
194
|
+
} else {
|
|
195
|
+
apply()
|
|
307
196
|
}
|
|
308
|
-
|
|
309
|
-
root.classList.toggle('dark', resolvedAppearance() === 'dark')
|
|
310
|
-
root.dataset['theme'] = active.id
|
|
311
|
-
root.dataset['appearance'] = resolvedAppearance()
|
|
312
|
-
root.style.colorScheme = resolvedAppearance()
|
|
313
|
-
|
|
314
|
-
if (!accent) root.removeAttribute('data-accent')
|
|
315
|
-
else root.dataset['accent'] = selection().accent
|
|
316
|
-
|
|
317
|
-
applyAppearanceFontSettings(
|
|
318
|
-
root,
|
|
319
|
-
normalizeAppearanceEditorFontSettings(selection().fonts, selection().font).settings
|
|
320
|
-
)
|
|
321
|
-
|
|
322
|
-
// Density is a pure token shift, so unlike the colour axes there is nothing to
|
|
323
|
-
// compute: the attribute selects a block in `theme.css`. It is applied here for
|
|
324
|
-
// the same reason as the others — this is the one place that knows what the
|
|
325
|
-
// current selection is, and it is the only writer of these attributes.
|
|
326
|
-
if (resolvedDensity(selection()) === 'comfortable') root.removeAttribute('data-density')
|
|
327
|
-
else root.dataset['density'] = resolvedDensity(selection())
|
|
328
197
|
})
|
|
198
|
+
onCleanup(() => restoreTransitions?.())
|
|
329
199
|
|
|
330
200
|
const themeAccents = createMemo(() =>
|
|
331
201
|
themeAccentsFor(selection().lightThemeId, selection().darkThemeId)
|
|
@@ -337,19 +207,22 @@ export function ThemeProvider(props: ThemeProviderProps) {
|
|
|
337
207
|
variant,
|
|
338
208
|
themes: builtinThemes,
|
|
339
209
|
themeAccents,
|
|
210
|
+
allThemes: registry,
|
|
211
|
+
preview(next) {
|
|
212
|
+
setDraft(next ? { ...next } : undefined)
|
|
213
|
+
},
|
|
214
|
+
previewing: () => draft() !== undefined,
|
|
340
215
|
setSelection(patch) {
|
|
341
216
|
const next = mergeThemeSelection(selection(), patch)
|
|
342
217
|
setSelectionState(next)
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
// A full, blocked or unavailable store must not break the switch: the
|
|
347
|
-
// preference simply does not survive the reload.
|
|
348
|
-
}
|
|
218
|
+
// The committed selection is now what should be showing.
|
|
219
|
+
setDraft(undefined)
|
|
220
|
+
writePersisted(persistence, next)
|
|
349
221
|
},
|
|
350
222
|
}
|
|
351
223
|
|
|
352
|
-
|
|
224
|
+
const Context = themeContext()
|
|
225
|
+
return <Context.Provider value={value}>{local.children}</Context.Provider>
|
|
353
226
|
}
|
|
354
227
|
|
|
355
228
|
/**
|
|
@@ -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
|
+
}
|
|
@@ -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)
|