@adea-ai/ui 0.102.0 → 0.103.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/README.md +27 -11
- package/dist/components/composites/appearance-editor/appearance-choices.d.ts +7 -0
- package/dist/components/composites/appearance-editor/appearance-choices.d.ts.map +1 -1
- package/dist/components/composites/appearance-editor/appearance-choices.js +44 -3
- package/dist/components/composites/appearance-editor/appearance-choices.js.map +1 -1
- package/dist/components/composites/appearance-editor/appearance-editor.d.ts.map +1 -1
- package/dist/components/composites/appearance-editor/appearance-editor.js +7 -3
- package/dist/components/composites/appearance-editor/appearance-editor.js.map +1 -1
- package/dist/components/composites/appearance-editor/appearance-types.d.ts +8 -0
- package/dist/components/composites/appearance-editor/appearance-types.d.ts.map +1 -1
- package/dist/components/composites/update-dialog/update-dialog.d.ts +15 -2
- package/dist/components/composites/update-dialog/update-dialog.d.ts.map +1 -1
- package/dist/components/composites/update-dialog/update-dialog.js +80 -16
- package/dist/components/composites/update-dialog/update-dialog.js.map +1 -1
- package/dist/components/theme/appearance-panel.d.ts +2 -1
- package/dist/components/theme/appearance-panel.d.ts.map +1 -1
- package/dist/components/theme/appearance-panel.js +62 -22
- package/dist/components/theme/appearance-panel.js.map +1 -1
- package/dist/components/theme/theme-provider.d.ts +13 -2
- package/dist/components/theme/theme-provider.d.ts.map +1 -1
- package/dist/components/theme/theme-provider.js +7 -4
- package/dist/components/theme/theme-provider.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/lib/theme-accents.d.ts +9 -0
- package/dist/lib/theme-accents.d.ts.map +1 -0
- package/dist/lib/theme-accents.js +41 -0
- package/dist/lib/theme-accents.js.map +1 -0
- package/dist/lib/themes.d.ts +69 -5
- package/dist/lib/themes.d.ts.map +1 -1
- package/dist/lib/themes.js +100 -7
- package/dist/lib/themes.js.map +1 -1
- package/dist/r/appearance-editor.json +1 -1
- package/dist/r/lib.json +6 -1
- package/dist/r/registry.json +8 -2
- package/dist/r/src/components/composites/appearance-editor/appearance-choices.tsx +41 -4
- package/dist/r/src/components/composites/appearance-editor/appearance-editor.tsx +12 -6
- package/dist/r/src/components/composites/appearance-editor/appearance-types.ts +8 -0
- package/dist/r/src/components/composites/update-dialog/update-dialog.tsx +90 -10
- package/dist/r/src/components/theme/appearance-panel.tsx +60 -29
- package/dist/r/src/components/theme/theme-provider.tsx +31 -7
- package/dist/r/src/lib/theme-accents.ts +44 -0
- package/dist/r/src/lib/themes.ts +116 -5
- package/dist/r/theming.json +1 -0
- package/package.json +2 -2
- package/registry.json +8 -2
- package/src/components/composites/appearance-editor/appearance-choices.tsx +41 -4
- package/src/components/composites/appearance-editor/appearance-editor.tsx +12 -6
- package/src/components/composites/appearance-editor/appearance-types.ts +8 -0
- package/src/components/composites/update-dialog/update-dialog.tsx +90 -10
- package/src/components/theme/appearance-panel.tsx +60 -29
- package/src/components/theme/theme-provider.tsx +31 -7
- package/src/index.ts +7 -0
- package/src/lib/theme-accents.ts +44 -0
- package/src/lib/themes.ts +116 -5
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
Show,
|
|
8
8
|
splitProps,
|
|
9
9
|
} from 'solid-js'
|
|
10
|
-
import type { Accessor } from 'solid-js'
|
|
10
|
+
import type { Accessor, JSX } from 'solid-js'
|
|
11
11
|
import {
|
|
12
12
|
Check,
|
|
13
13
|
CircleStop,
|
|
@@ -148,6 +148,13 @@ export type UpdateAdapter = Readonly<{
|
|
|
148
148
|
isDesktopRuntime(): boolean
|
|
149
149
|
}>
|
|
150
150
|
|
|
151
|
+
type ChannelControlActions = Readonly<{
|
|
152
|
+
/** Disabled while the app is checking, downloading, installing, or saving a channel. */
|
|
153
|
+
disabled: Accessor<boolean>
|
|
154
|
+
/** Persist a channel change, then check that channel without retaining a stale offer. */
|
|
155
|
+
recheck(beforeCheck?: () => Promise<void>): Promise<void>
|
|
156
|
+
}>
|
|
157
|
+
|
|
151
158
|
export type UpdateDialogProps = {
|
|
152
159
|
adapter: UpdateAdapter
|
|
153
160
|
/** The application's name, used in the title and the copy. */
|
|
@@ -160,6 +167,12 @@ export type UpdateDialogProps = {
|
|
|
160
167
|
fallbackVersion?: string
|
|
161
168
|
/** Copy for the adapter's non-desktop case. */
|
|
162
169
|
desktopOnlyMessage?: string
|
|
170
|
+
/**
|
|
171
|
+
* Optional host-owned release-channel control, rendered in the version status
|
|
172
|
+
* card. The host owns its accessible label and calls `recheck` after a selection;
|
|
173
|
+
* the optional callback persists that selection before the dialog checks it.
|
|
174
|
+
*/
|
|
175
|
+
channelControl?: (actions: ChannelControlActions) => JSX.Element
|
|
163
176
|
/** A controlled open state. Omit for the trigger to manage its own. */
|
|
164
177
|
open?: boolean
|
|
165
178
|
/**
|
|
@@ -220,6 +233,7 @@ export function UpdateDialog(props: UpdateDialogProps) {
|
|
|
220
233
|
'changelog',
|
|
221
234
|
'fallbackVersion',
|
|
222
235
|
'desktopOnlyMessage',
|
|
236
|
+
'channelControl',
|
|
223
237
|
'open',
|
|
224
238
|
'defaultOpen',
|
|
225
239
|
'onOpenChange',
|
|
@@ -248,6 +262,7 @@ export function UpdateDialog(props: UpdateDialogProps) {
|
|
|
248
262
|
const [cancelPending, setCancelPending] = createSignal(false)
|
|
249
263
|
let lifecycleEpoch = 0
|
|
250
264
|
let activeOpenEpoch: number | undefined
|
|
265
|
+
let channelCheckUnconfirmed = false
|
|
251
266
|
let disposed = false
|
|
252
267
|
|
|
253
268
|
onCleanup(() => {
|
|
@@ -263,12 +278,15 @@ export function UpdateDialog(props: UpdateDialogProps) {
|
|
|
263
278
|
const previous = state()
|
|
264
279
|
const hasActionableSnapshot = (candidate: UpdateState | null) =>
|
|
265
280
|
candidate?.phase === 'available' || isBusy(candidate)
|
|
281
|
+
const ignoresUnconfirmedChannelSnapshot =
|
|
282
|
+
channelCheckUnconfirmed && hasActionableSnapshot(status)
|
|
266
283
|
const preservesSnapshot =
|
|
267
284
|
previous?.phase === 'available'
|
|
268
285
|
? status.phase === 'available' || isBusy(status)
|
|
269
286
|
: isBusy(previous) && isBusy(status)
|
|
270
287
|
if (
|
|
271
288
|
isCurrent() &&
|
|
289
|
+
!ignoresUnconfirmedChannelSnapshot &&
|
|
272
290
|
!(checkFailed() && hasActionableSnapshot(previous) && !preservesSnapshot)
|
|
273
291
|
) {
|
|
274
292
|
setState(status)
|
|
@@ -291,10 +309,10 @@ export function UpdateDialog(props: UpdateDialogProps) {
|
|
|
291
309
|
setError(message)
|
|
292
310
|
}
|
|
293
311
|
|
|
294
|
-
const applyCheckResult = (next: UpdateState) => {
|
|
312
|
+
const applyCheckResult = (next: UpdateState, preserveOnFailure = true) => {
|
|
295
313
|
if (next.phase === 'failed') {
|
|
296
314
|
const current = state()
|
|
297
|
-
if (!current) setState(next)
|
|
315
|
+
if (!current || !preserveOnFailure) setState(next)
|
|
298
316
|
failCheck(next.error, 'Could not check for updates')
|
|
299
317
|
return
|
|
300
318
|
}
|
|
@@ -303,7 +321,10 @@ export function UpdateDialog(props: UpdateDialogProps) {
|
|
|
303
321
|
setError('')
|
|
304
322
|
}
|
|
305
323
|
|
|
306
|
-
const check = async (
|
|
324
|
+
const check = async (
|
|
325
|
+
beforeCheck?: () => Promise<void>,
|
|
326
|
+
clearStaleOffer = beforeCheck !== undefined
|
|
327
|
+
) => {
|
|
307
328
|
if (!desktop()) {
|
|
308
329
|
setError(local.desktopOnlyMessage ?? `Update checks are available from the desktop app.`)
|
|
309
330
|
return
|
|
@@ -316,16 +337,43 @@ export function UpdateDialog(props: UpdateDialogProps) {
|
|
|
316
337
|
lifecycleEpoch === epoch &&
|
|
317
338
|
open()
|
|
318
339
|
if (!isCurrent()) return
|
|
340
|
+
if (clearStaleOffer && working()) return
|
|
341
|
+
if (clearStaleOffer) channelCheckUnconfirmed = true
|
|
342
|
+
const discardStaleOffer = clearStaleOffer || channelCheckUnconfirmed
|
|
319
343
|
setBusy(true)
|
|
320
344
|
setError('')
|
|
321
345
|
setCheckFailed(false)
|
|
346
|
+
if (discardStaleOffer) {
|
|
347
|
+
const current = state()
|
|
348
|
+
if (current) setState({ phase: 'checking', currentVersion: current.currentVersion })
|
|
349
|
+
}
|
|
350
|
+
let channelSaved = beforeCheck === undefined
|
|
322
351
|
try {
|
|
352
|
+
await beforeCheck?.()
|
|
353
|
+
channelSaved = true
|
|
354
|
+
if (!isCurrent()) return
|
|
323
355
|
const checked = await local.adapter.check()
|
|
324
|
-
if (isCurrent())
|
|
356
|
+
if (isCurrent()) {
|
|
357
|
+
applyCheckResult(checked, !discardStaleOffer)
|
|
358
|
+
if (discardStaleOffer && checked.phase !== 'failed') channelCheckUnconfirmed = false
|
|
359
|
+
}
|
|
325
360
|
} catch (caught) {
|
|
326
361
|
if (isCurrent()) {
|
|
327
|
-
|
|
328
|
-
|
|
362
|
+
if (discardStaleOffer) {
|
|
363
|
+
const fallback = channelSaved
|
|
364
|
+
? 'Could not check for updates'
|
|
365
|
+
: 'Could not save the update channel'
|
|
366
|
+
const message = errorMessage(caught, fallback)
|
|
367
|
+
setState({
|
|
368
|
+
phase: 'failed',
|
|
369
|
+
currentVersion: state()?.currentVersion ?? '',
|
|
370
|
+
error: message,
|
|
371
|
+
})
|
|
372
|
+
failCheck(message, fallback)
|
|
373
|
+
} else {
|
|
374
|
+
failCheck(caught, 'Could not check for updates')
|
|
375
|
+
await loadStatus(isCurrent)
|
|
376
|
+
}
|
|
329
377
|
}
|
|
330
378
|
} finally {
|
|
331
379
|
if (isCurrent()) setBusy(false)
|
|
@@ -366,11 +414,31 @@ export function UpdateDialog(props: UpdateDialogProps) {
|
|
|
366
414
|
try {
|
|
367
415
|
const current = await local.adapter.getStatus()
|
|
368
416
|
if (!isCurrent()) return
|
|
369
|
-
|
|
417
|
+
const discardStaleOffer = channelCheckUnconfirmed
|
|
418
|
+
setState(
|
|
419
|
+
discardStaleOffer
|
|
420
|
+
? { phase: 'checking', currentVersion: current.currentVersion }
|
|
421
|
+
: current
|
|
422
|
+
)
|
|
370
423
|
const checked = await local.adapter.check()
|
|
371
|
-
if (isCurrent())
|
|
424
|
+
if (isCurrent()) {
|
|
425
|
+
applyCheckResult(checked, !discardStaleOffer)
|
|
426
|
+
if (discardStaleOffer && checked.phase !== 'failed') channelCheckUnconfirmed = false
|
|
427
|
+
}
|
|
372
428
|
} catch (caught) {
|
|
373
|
-
if (isCurrent())
|
|
429
|
+
if (isCurrent()) {
|
|
430
|
+
if (channelCheckUnconfirmed) {
|
|
431
|
+
const message = errorMessage(caught, 'Could not check for updates')
|
|
432
|
+
setState({
|
|
433
|
+
phase: 'failed',
|
|
434
|
+
currentVersion: state()?.currentVersion ?? '',
|
|
435
|
+
error: message,
|
|
436
|
+
})
|
|
437
|
+
failCheck(message, 'Could not check for updates')
|
|
438
|
+
} else {
|
|
439
|
+
failCheck(caught, 'Could not check for updates')
|
|
440
|
+
}
|
|
441
|
+
}
|
|
374
442
|
} finally {
|
|
375
443
|
if (isCurrent()) setBusy(false)
|
|
376
444
|
}
|
|
@@ -497,6 +565,8 @@ export function UpdateDialog(props: UpdateDialogProps) {
|
|
|
497
565
|
return value ? plainTextFromMarkdown(value) : ''
|
|
498
566
|
}
|
|
499
567
|
const working = () => busy() || cancelPending() || isBusy(state())
|
|
568
|
+
const channelControlDisabled = () => !desktop() || working()
|
|
569
|
+
const recheckChannel = (beforeCheck?: () => Promise<void>) => check(beforeCheck, true)
|
|
500
570
|
const isCurrent = () => state()?.phase === 'current' && !error() && !checkFailed() && !working()
|
|
501
571
|
|
|
502
572
|
const triggerLabel = () => {
|
|
@@ -644,6 +714,16 @@ export function UpdateDialog(props: UpdateDialogProps) {
|
|
|
644
714
|
</Show>
|
|
645
715
|
</div>
|
|
646
716
|
</div>
|
|
717
|
+
<Show when={local.channelControl}>
|
|
718
|
+
{(channelControl) => (
|
|
719
|
+
<div class="mt-4 border-t border-border pt-4">
|
|
720
|
+
{channelControl()({
|
|
721
|
+
disabled: channelControlDisabled,
|
|
722
|
+
recheck: recheckChannel,
|
|
723
|
+
})}
|
|
724
|
+
</div>
|
|
725
|
+
)}
|
|
726
|
+
</Show>
|
|
647
727
|
</section>
|
|
648
728
|
|
|
649
729
|
<Show
|
|
@@ -24,7 +24,8 @@ import { Heading } from '../ui/typography'
|
|
|
24
24
|
* 2. **Themes** — one for each appearance. Both are shown at once, because the
|
|
25
25
|
* choice is per-appearance and hiding the other half makes it feel like one
|
|
26
26
|
* setting that keeps changing.
|
|
27
|
-
* 3. **Accent** — adea's presets,
|
|
27
|
+
* 3. **Accent** — adea's presets, then the accents the selected theme pair
|
|
28
|
+
* carries itself, layered on whichever theme is active.
|
|
28
29
|
* 4. **Font** — the interface face.
|
|
29
30
|
*
|
|
30
31
|
* The panel reads and writes the provider's preference and keeps no state of its
|
|
@@ -37,7 +38,7 @@ const DARK_THEMES = themesForAppearance('dark')
|
|
|
37
38
|
|
|
38
39
|
export function AppearancePanel(props: AppearancePanelProps) {
|
|
39
40
|
const [local, rest] = splitProps(props, ['class'])
|
|
40
|
-
const { selection, setSelection, resolvedAppearance } = useTheme()
|
|
41
|
+
const { selection, setSelection, resolvedAppearance, themeAccents } = useTheme()
|
|
41
42
|
|
|
42
43
|
return (
|
|
43
44
|
<div data-slot="appearance-panel" class={cn('flex flex-col gap-8', local.class)} {...rest}>
|
|
@@ -95,38 +96,38 @@ export function AppearancePanel(props: AppearancePanelProps) {
|
|
|
95
96
|
</Heading>
|
|
96
97
|
<p class="max-w-prose text-sm text-muted-foreground">
|
|
97
98
|
Colours the interactive roles — the primary button, the focus ring, a selected row — on
|
|
98
|
-
top of the theme above
|
|
99
|
-
none of them can make a label
|
|
99
|
+
top of the theme above — one of the presets, or a colour the theme carries itself. Every
|
|
100
|
+
option is measured against the surface it lands on, so none of them can make a label
|
|
101
|
+
unreadable.
|
|
100
102
|
</p>
|
|
101
103
|
</header>
|
|
102
104
|
<div role="radiogroup" aria-label="Accent" class="flex flex-wrap gap-2">
|
|
103
105
|
<For each={accentPresets}>
|
|
104
106
|
{(preset) => (
|
|
105
|
-
<
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
</Button>
|
|
107
|
+
<AccentOption
|
|
108
|
+
id={preset.id}
|
|
109
|
+
label={preset.label}
|
|
110
|
+
checked={selection().accent === preset.id}
|
|
111
|
+
swatch={
|
|
112
|
+
preset.id === 'theme'
|
|
113
|
+
? 'var(--primary)'
|
|
114
|
+
: `var(--accent-${preset.id}, ${preset.dark})`
|
|
115
|
+
}
|
|
116
|
+
onSelect={() => setSelection({ accent: preset.id })}
|
|
117
|
+
/>
|
|
118
|
+
)}
|
|
119
|
+
</For>
|
|
120
|
+
{/* The theme's own accents follow the presets. Their values are the
|
|
121
|
+
theme pair's, so the swatch shows the side that is active now. */}
|
|
122
|
+
<For each={themeAccents()}>
|
|
123
|
+
{(accent) => (
|
|
124
|
+
<AccentOption
|
|
125
|
+
id={accent.id}
|
|
126
|
+
label={`Theme ${accent.label.toLowerCase()}`}
|
|
127
|
+
checked={selection().accent === accent.id}
|
|
128
|
+
swatch={resolvedAppearance() === 'dark' ? accent.dark : accent.light}
|
|
129
|
+
onSelect={() => setSelection({ accent: accent.id })}
|
|
130
|
+
/>
|
|
130
131
|
)}
|
|
131
132
|
</For>
|
|
132
133
|
</div>
|
|
@@ -163,6 +164,36 @@ export function AppearancePanel(props: AppearancePanelProps) {
|
|
|
163
164
|
)
|
|
164
165
|
}
|
|
165
166
|
|
|
167
|
+
function AccentOption(props: {
|
|
168
|
+
id: string
|
|
169
|
+
label: string
|
|
170
|
+
checked: boolean
|
|
171
|
+
swatch: string
|
|
172
|
+
onSelect: () => void
|
|
173
|
+
}) {
|
|
174
|
+
return (
|
|
175
|
+
<Button
|
|
176
|
+
as="button"
|
|
177
|
+
type="button"
|
|
178
|
+
variant="outline"
|
|
179
|
+
size="sm"
|
|
180
|
+
role="radio"
|
|
181
|
+
data-accent-option={props.id}
|
|
182
|
+
aria-checked={props.checked}
|
|
183
|
+
aria-label={props.label}
|
|
184
|
+
class={cn({ 'border-ring ring-3 ring-primary-subtle': props.checked })}
|
|
185
|
+
onClick={() => props.onSelect()}
|
|
186
|
+
>
|
|
187
|
+
<span
|
|
188
|
+
aria-hidden="true"
|
|
189
|
+
class="size-3 rounded-full border border-border"
|
|
190
|
+
style={{ 'background-color': props.swatch }}
|
|
191
|
+
/>
|
|
192
|
+
{props.label}
|
|
193
|
+
</Button>
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
|
|
166
197
|
function ThemeSection(props: {
|
|
167
198
|
title: string
|
|
168
199
|
description: string
|
|
@@ -9,11 +9,14 @@ import {
|
|
|
9
9
|
useContext,
|
|
10
10
|
} from 'solid-js'
|
|
11
11
|
import { accentPresets, fontOptions } from '../../lib/tokens'
|
|
12
|
+
import type { AccentPreset } from '@adea-ai/themes'
|
|
12
13
|
import {
|
|
13
14
|
accentVariables,
|
|
14
15
|
builtinThemes,
|
|
15
16
|
defaultDarkThemeId,
|
|
16
17
|
defaultLightThemeId,
|
|
18
|
+
isThemeAccentId,
|
|
19
|
+
themeAccentsFor,
|
|
17
20
|
themeById,
|
|
18
21
|
themeCssVariables,
|
|
19
22
|
type ThemeAppearance,
|
|
@@ -33,7 +36,8 @@ import {
|
|
|
33
36
|
* - **appearance** — light, dark, or follow the system.
|
|
34
37
|
* - **a theme per appearance** — so a user can run Catppuccin Mocha by night and
|
|
35
38
|
* Solarized Light by day without re-choosing when the system flips.
|
|
36
|
-
* - **an accent** — adea's six presets,
|
|
39
|
+
* - **an accent** — adea's six presets, or one of the accents the selected theme
|
|
40
|
+
* pair carries itself (`ansi-blue`…), which re-colour the interactive roles on
|
|
37
41
|
* top of whichever theme is active.
|
|
38
42
|
* - **a font** — the interface face.
|
|
39
43
|
*
|
|
@@ -57,7 +61,11 @@ export type ThemeSelection = {
|
|
|
57
61
|
lightThemeId: string
|
|
58
62
|
/** The variant to use when the resolved appearance is dark. */
|
|
59
63
|
darkThemeId: string
|
|
60
|
-
/**
|
|
64
|
+
/**
|
|
65
|
+
* An `accentPresets` id, a theme accent id from `themeAccents()` (`ansi-blue`…),
|
|
66
|
+
* or `theme` for the variant's own primary. A theme accent the current pair does
|
|
67
|
+
* not offer falls back to the variant's own primary.
|
|
68
|
+
*/
|
|
61
69
|
accent: string
|
|
62
70
|
/** A `fontOptions` id, or `space-grotesk` for the default. */
|
|
63
71
|
font: string
|
|
@@ -111,6 +119,11 @@ export type ThemeContextValue = {
|
|
|
111
119
|
setSelection: (patch: Partial<ThemeSelection>) => void
|
|
112
120
|
/** The catalogue, so a picker does not import it separately. */
|
|
113
121
|
themes: readonly ThemeVariant[]
|
|
122
|
+
/**
|
|
123
|
+
* The accents the selected light/dark pair carries, offered beside the presets.
|
|
124
|
+
* Follows the selection: a different theme pair may offer different slots.
|
|
125
|
+
*/
|
|
126
|
+
themeAccents: () => readonly AccentPreset[]
|
|
114
127
|
}
|
|
115
128
|
|
|
116
129
|
const ThemeContext = createContext<ThemeContextValue>()
|
|
@@ -157,9 +170,15 @@ function readSelection(storageKey: string): ThemeSelection {
|
|
|
157
170
|
themeById(parsed.darkThemeId ?? '')?.appearance === 'dark'
|
|
158
171
|
? (parsed.darkThemeId as string)
|
|
159
172
|
: defaultDarkThemeId,
|
|
160
|
-
accent
|
|
161
|
-
|
|
162
|
-
|
|
173
|
+
// A theme accent is kept whenever it names a slot, even one the stored pair
|
|
174
|
+
// cannot offer: the id is role-shaped so it survives a theme switch, and an
|
|
175
|
+
// unoffered slot already resolves to the theme's own primary when applied.
|
|
176
|
+
accent:
|
|
177
|
+
typeof parsed.accent === 'string' &&
|
|
178
|
+
(accentPresets.some((preset) => preset.id === parsed.accent) ||
|
|
179
|
+
isThemeAccentId(parsed.accent))
|
|
180
|
+
? parsed.accent
|
|
181
|
+
: defaultThemeSelection.accent,
|
|
163
182
|
font: fontOptions.some((option) => option.id === parsed.font)
|
|
164
183
|
? (parsed.font as string)
|
|
165
184
|
: defaultThemeSelection.font,
|
|
@@ -246,7 +265,7 @@ export function ThemeProvider(props: ThemeProviderProps) {
|
|
|
246
265
|
// them meant the attribute was set, the rules were present, and nothing
|
|
247
266
|
// changed on screen.
|
|
248
267
|
const properties = { ...themeCssVariables(active) }
|
|
249
|
-
const accent = accentVariables(selection().accent, resolvedAppearance())
|
|
268
|
+
const accent = accentVariables(selection().accent, resolvedAppearance(), selection())
|
|
250
269
|
if (accent) Object.assign(properties, accent)
|
|
251
270
|
|
|
252
271
|
for (const [name, value] of Object.entries(properties)) {
|
|
@@ -258,7 +277,7 @@ export function ThemeProvider(props: ThemeProviderProps) {
|
|
|
258
277
|
root.dataset['appearance'] = resolvedAppearance()
|
|
259
278
|
root.style.colorScheme = resolvedAppearance()
|
|
260
279
|
|
|
261
|
-
if (
|
|
280
|
+
if (!accent) root.removeAttribute('data-accent')
|
|
262
281
|
else root.dataset['accent'] = selection().accent
|
|
263
282
|
|
|
264
283
|
if (selection().font === 'space-grotesk') root.removeAttribute('data-font')
|
|
@@ -272,11 +291,16 @@ export function ThemeProvider(props: ThemeProviderProps) {
|
|
|
272
291
|
else root.dataset['density'] = resolvedDensity(selection())
|
|
273
292
|
})
|
|
274
293
|
|
|
294
|
+
const themeAccents = createMemo(() =>
|
|
295
|
+
themeAccentsFor(selection().lightThemeId, selection().darkThemeId)
|
|
296
|
+
)
|
|
297
|
+
|
|
275
298
|
const value: ThemeContextValue = {
|
|
276
299
|
selection,
|
|
277
300
|
resolvedAppearance,
|
|
278
301
|
variant,
|
|
279
302
|
themes: builtinThemes,
|
|
303
|
+
themeAccents,
|
|
280
304
|
setSelection(patch) {
|
|
281
305
|
const next = { ...selection(), ...patch }
|
|
282
306
|
setSelectionState(next)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The accents a theme carries, beside the six presets.
|
|
3
|
+
*
|
|
4
|
+
* `@adea-ai/themes` offers two kinds of accent. The presets (`ACCENTS`) are brand
|
|
5
|
+
* colours, the same six on every theme. Since 0.9 the catalogue also offers the
|
|
6
|
+
* accent-shaped colours a theme carries itself — its ANSI blue, magenta, cyan and
|
|
7
|
+
* green, paired across a light and a dark theme and kept only when they clear the
|
|
8
|
+
* catalogue's accent floors — through `themeAccentPresets`. Those read as part of
|
|
9
|
+
* the palette rather than as visitors on it, which is why they are offered here.
|
|
10
|
+
*
|
|
11
|
+
* Their ids are role-shaped (`ansi-blue`), not value-shaped: a stored theme accent
|
|
12
|
+
* survives switching themes and resolves to the *new* theme's blue. That has one
|
|
13
|
+
* consequence a consumer must handle, and this module is where it is handled once:
|
|
14
|
+
* a stored theme accent may name a slot the current pair does not offer, and then
|
|
15
|
+
* it resolves to nothing and the theme's own primary applies — the same fallback a
|
|
16
|
+
* stale preset id gets.
|
|
17
|
+
*
|
|
18
|
+
* This module holds only the id test, and reads only the schema entry: the
|
|
19
|
+
* appearance editor uses it, and the editor's packed renderer gate forbids the
|
|
20
|
+
* catalogue and normalization modules that *computing* a theme accent needs
|
|
21
|
+
* (`themeAccentPresets` measures through `normalize.js`). Resolution lives in
|
|
22
|
+
* `lib/themes.ts`, beside the catalogue it reads.
|
|
23
|
+
*/
|
|
24
|
+
import { ANSI_KEYS } from '@adea-ai/themes/schema'
|
|
25
|
+
|
|
26
|
+
/** The prefix the catalogue gives every theme-derived accent id. */
|
|
27
|
+
const THEME_ACCENT_PREFIX = 'ansi-'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Whether an id names a theme-derived accent slot.
|
|
31
|
+
*
|
|
32
|
+
* Deliberately wider than what any one pair offers: the id is valid to *store*
|
|
33
|
+
* whenever it names a slot, because the next theme may offer it even if this one
|
|
34
|
+
* does not. Whether it resolves is `resolveAccentPreset`'s question.
|
|
35
|
+
*/
|
|
36
|
+
export function isThemeAccentId(id: string): boolean {
|
|
37
|
+
// Computed per call rather than held in a module-level set: a top-level
|
|
38
|
+
// `new Set(...)` is a statement a bundler cannot prove pure, and it would keep
|
|
39
|
+
// this module in every bundle that touches the root entry.
|
|
40
|
+
return (
|
|
41
|
+
id.startsWith(THEME_ACCENT_PREFIX) &&
|
|
42
|
+
(ANSI_KEYS as readonly string[]).includes(id.slice(THEME_ACCENT_PREFIX.length))
|
|
43
|
+
)
|
|
44
|
+
}
|
package/dist/r/src/lib/themes.ts
CHANGED
|
@@ -35,9 +35,11 @@
|
|
|
35
35
|
|
|
36
36
|
import {
|
|
37
37
|
ACCENTS,
|
|
38
|
+
accentForeground,
|
|
38
39
|
accentRoles,
|
|
39
40
|
chartSeries,
|
|
40
41
|
getAccent,
|
|
42
|
+
getTheme,
|
|
41
43
|
hasTheme,
|
|
42
44
|
shadcnDestructiveProjection,
|
|
43
45
|
statusForeground,
|
|
@@ -47,10 +49,16 @@ import {
|
|
|
47
49
|
syntaxRoles,
|
|
48
50
|
themes as catalogue,
|
|
49
51
|
themesByAppearance,
|
|
52
|
+
themeAccentPresets,
|
|
50
53
|
validateCatalogue,
|
|
54
|
+
type AccentPreset,
|
|
55
|
+
type AdeaTheme,
|
|
51
56
|
type AdeaThemeRecord,
|
|
52
57
|
type ContrastFinding,
|
|
53
58
|
} from '@adea-ai/themes'
|
|
59
|
+
import { isThemeAccentId } from './theme-accents'
|
|
60
|
+
|
|
61
|
+
export { isThemeAccentId } from './theme-accents'
|
|
54
62
|
|
|
55
63
|
export type ThemeAppearance = 'light' | 'dark'
|
|
56
64
|
|
|
@@ -320,6 +328,100 @@ export function themeFamilies(): { family: string; label: string; themes: ThemeV
|
|
|
320
328
|
/** The accent presets, in the order a picker should show them. */
|
|
321
329
|
export const accents = ACCENTS
|
|
322
330
|
|
|
331
|
+
/**
|
|
332
|
+
* The canonical catalogue records, in catalogue order.
|
|
333
|
+
*
|
|
334
|
+
* {@link builtinThemes} is this system's projection of the catalogue onto the
|
|
335
|
+
* shadcn vocabulary, which is what a document is painted with. A *preview* of a
|
|
336
|
+
* theme — the miniatures `AppearanceEditor` and `ThemeMiniature` draw — reads the
|
|
337
|
+
* catalogue's own roles instead (`surfaceActive`, `borderMuted`, `textSubtle`…),
|
|
338
|
+
* several of which the projection folds together. These are those records, so a
|
|
339
|
+
* consumer hands the editor exactly what it takes rather than reconstructing it
|
|
340
|
+
* from the projection.
|
|
341
|
+
*/
|
|
342
|
+
export const themeRecords: readonly AdeaThemeRecord[] = catalogue
|
|
343
|
+
|
|
344
|
+
export function themeRecordById(id: string): AdeaThemeRecord | undefined {
|
|
345
|
+
return getTheme(id)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** A light/dark theme pair by id, with the defaults standing in for an unknown id. */
|
|
349
|
+
function recordPair(lightThemeId: string, darkThemeId: string) {
|
|
350
|
+
const light = getTheme(lightThemeId) ?? getTheme(defaultLightThemeId)!
|
|
351
|
+
const dark = getTheme(darkThemeId) ?? getTheme(defaultDarkThemeId)!
|
|
352
|
+
return { light, dark }
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* The accents the selected light/dark pair carries — offered beside the presets.
|
|
357
|
+
*
|
|
358
|
+
* Computed per pair rather than per theme because a theme accent is a *pair* of
|
|
359
|
+
* values, like a preset: a slot is offered only when both themes can offer it.
|
|
360
|
+
*/
|
|
361
|
+
export function themeAccentsFor(
|
|
362
|
+
lightThemeId: string,
|
|
363
|
+
darkThemeId: string
|
|
364
|
+
): readonly AccentPreset[] {
|
|
365
|
+
const { light, dark } = recordPair(lightThemeId, darkThemeId)
|
|
366
|
+
return themeAccentPresets(light, dark)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* An accent id resolved against a theme pair: a preset, a theme accent the pair
|
|
371
|
+
* offers, or `undefined` for `theme`, an unknown id, or a slot this pair cannot
|
|
372
|
+
* offer — each of which means "use the theme's own primary".
|
|
373
|
+
*
|
|
374
|
+
* Theme accent ids are role-shaped (`ansi-blue`), so a stored one survives a theme
|
|
375
|
+
* switch and resolves to the new theme's blue; when the new pair cannot offer the
|
|
376
|
+
* slot it resolves to nothing, the same fallback a stale preset id gets.
|
|
377
|
+
*/
|
|
378
|
+
export function resolveAccentPreset(
|
|
379
|
+
accentId: string,
|
|
380
|
+
light: AdeaTheme,
|
|
381
|
+
dark: AdeaTheme
|
|
382
|
+
): AccentPreset | undefined {
|
|
383
|
+
if (accentId === 'theme') return undefined
|
|
384
|
+
const preset = getAccent(accentId)
|
|
385
|
+
if (preset) return preset
|
|
386
|
+
if (!isThemeAccentId(accentId)) return undefined
|
|
387
|
+
return themeAccentPresets(light, dark).find((option) => option.id === accentId)
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* A theme with an accent laid over it, in the catalogue's own shape.
|
|
392
|
+
*
|
|
393
|
+
* The accent replaces `accent` and its measured label, which is all an accent
|
|
394
|
+
* changes about a theme; `undefined` returns the theme untouched. This is the
|
|
395
|
+
* overlay a host needs for `AppearanceEditor`'s `lightTheme`/`darkTheme`.
|
|
396
|
+
*/
|
|
397
|
+
export function withAccent<T extends AdeaTheme>(theme: T, accent: AccentPreset | undefined): T {
|
|
398
|
+
if (!accent) return theme
|
|
399
|
+
const value = theme.appearance === 'dark' ? accent.dark : accent.light
|
|
400
|
+
return {
|
|
401
|
+
...theme,
|
|
402
|
+
colors: { ...theme.colors, accent: value, accentForeground: accentForeground(value) },
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* The two preview themes an appearance selection resolves to, accent applied.
|
|
408
|
+
*
|
|
409
|
+
* The adapter between a stored selection and `AppearanceEditor`: it takes ids —
|
|
410
|
+
* what a preference holds — and returns the canonical records the editor's
|
|
411
|
+
* `lightTheme` and `darkTheme` props take, so a host does not map roles by hand.
|
|
412
|
+
* Unknown ids fall back to the defaults and an accent the pair cannot offer falls
|
|
413
|
+
* back to the theme's own, exactly as `ThemeProvider` resolves them.
|
|
414
|
+
*/
|
|
415
|
+
export function appearancePreviewThemes(selection: {
|
|
416
|
+
lightThemeId: string
|
|
417
|
+
darkThemeId: string
|
|
418
|
+
accent: string
|
|
419
|
+
}): { lightTheme: AdeaThemeRecord; darkTheme: AdeaThemeRecord } {
|
|
420
|
+
const { light, dark } = recordPair(selection.lightThemeId, selection.darkThemeId)
|
|
421
|
+
const accent = resolveAccentPreset(selection.accent, light, dark)
|
|
422
|
+
return { lightTheme: withAccent(light, accent), darkTheme: withAccent(dark, accent) }
|
|
423
|
+
}
|
|
424
|
+
|
|
323
425
|
/**
|
|
324
426
|
* The properties an accent selection overrides, as CSS custom properties.
|
|
325
427
|
*
|
|
@@ -339,16 +441,25 @@ export const accents = ACCENTS
|
|
|
339
441
|
* provider and the CSS generator call `accentRoles` out of `@adea-ai/themes`, and
|
|
340
442
|
* `tests/appearance-axes.test.ts` asserts the two agree value for value.
|
|
341
443
|
*
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
444
|
+
* A theme accent (`ansi-blue`) has no block: its value depends on the theme pair,
|
|
445
|
+
* so it is resolved against `pair` — the selection's light and dark theme ids —
|
|
446
|
+
* and only ever applied here. Without a pair it resolves against the defaults.
|
|
447
|
+
*
|
|
448
|
+
* Returns `undefined` for `theme` — the variant's own primary — for an id the
|
|
449
|
+
* catalogue does not have, and for a theme accent the pair does not offer, so a
|
|
450
|
+
* stale stored preference falls back to the theme rather than blanking the primary.
|
|
345
451
|
*/
|
|
346
452
|
export function accentVariables(
|
|
347
453
|
accentId: string,
|
|
348
|
-
appearance: ThemeAppearance
|
|
454
|
+
appearance: ThemeAppearance,
|
|
455
|
+
pair: { lightThemeId: string; darkThemeId: string } = {
|
|
456
|
+
lightThemeId: defaultLightThemeId,
|
|
457
|
+
darkThemeId: defaultDarkThemeId,
|
|
458
|
+
}
|
|
349
459
|
): Record<string, string> | undefined {
|
|
350
460
|
if (accentId === 'theme') return undefined
|
|
351
|
-
const
|
|
461
|
+
const { light, dark } = recordPair(pair.lightThemeId, pair.darkThemeId)
|
|
462
|
+
const preset = resolveAccentPreset(accentId, light, dark)
|
|
352
463
|
if (!preset) return undefined
|
|
353
464
|
|
|
354
465
|
const roles = accentRoles(preset, appearance)
|
package/dist/r/theming.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adea-ai/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.103.0",
|
|
4
4
|
"description": "The Adea design system: Kobalte-backed Solid components, semantic OKLCH tokens, and the app-shell layout primitives shared by Adea and Cortana.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"adea",
|
|
@@ -119,7 +119,7 @@
|
|
|
119
119
|
"check:packed-paste-model": "node scripts/check-packed-paste-model.mjs"
|
|
120
120
|
},
|
|
121
121
|
"dependencies": {
|
|
122
|
-
"@adea-ai/themes": "^0.
|
|
122
|
+
"@adea-ai/themes": "^0.9.5",
|
|
123
123
|
"@corvu/calendar": "^0.1.2",
|
|
124
124
|
"@corvu/drawer": "^0.2.4",
|
|
125
125
|
"@corvu/otp-field": "^0.1.4",
|