@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
package/registry.json
CHANGED
|
@@ -1905,7 +1905,7 @@
|
|
|
1905
1905
|
"title": "Appearance Editor",
|
|
1906
1906
|
"description": "",
|
|
1907
1907
|
"dependencies": [
|
|
1908
|
-
"@adea-ai/themes@^0.
|
|
1908
|
+
"@adea-ai/themes@^0.9.5",
|
|
1909
1909
|
"@kobalte/core@^0.13.14",
|
|
1910
1910
|
"lucide-solid@^1.48.0"
|
|
1911
1911
|
],
|
|
@@ -2351,6 +2351,7 @@
|
|
|
2351
2351
|
"title": "Theming",
|
|
2352
2352
|
"description": "",
|
|
2353
2353
|
"dependencies": [
|
|
2354
|
+
"@adea-ai/themes@^0.9.5",
|
|
2354
2355
|
"lucide-solid@^1.48.0"
|
|
2355
2356
|
],
|
|
2356
2357
|
"registryDependencies": [
|
|
@@ -2458,7 +2459,7 @@
|
|
|
2458
2459
|
"title": "Class-name and token helpers",
|
|
2459
2460
|
"description": "The private helpers the components import: the class-name merge, the variant recipes, the token manifest and the theme bridge. Installed automatically as a peer of any component that needs it.",
|
|
2460
2461
|
"dependencies": [
|
|
2461
|
-
"@adea-ai/themes@^0.
|
|
2462
|
+
"@adea-ai/themes@^0.9.5",
|
|
2462
2463
|
"class-variance-authority@^0.7.1",
|
|
2463
2464
|
"cn@^0.4.0"
|
|
2464
2465
|
],
|
|
@@ -2494,6 +2495,11 @@
|
|
|
2494
2495
|
"type": "registry:lib",
|
|
2495
2496
|
"target": "lib/overlay.ts"
|
|
2496
2497
|
},
|
|
2498
|
+
{
|
|
2499
|
+
"path": "src/lib/theme-accents.ts",
|
|
2500
|
+
"type": "registry:lib",
|
|
2501
|
+
"target": "lib/theme-accents.ts"
|
|
2502
|
+
},
|
|
2497
2503
|
{
|
|
2498
2504
|
"path": "src/lib/themes.ts",
|
|
2499
2505
|
"type": "registry:lib",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
// the app port's manual roving focus; it does not replace the donor hierarchy.
|
|
26
26
|
import { RadioGroup as Radio } from '@kobalte/core/radio-group'
|
|
27
27
|
import { For, Show } from 'solid-js'
|
|
28
|
+
import { isThemeAccentId } from '#lib/theme-accents'
|
|
28
29
|
import type { AppearanceEditorProps } from './appearance-types'
|
|
29
30
|
import { ThemeMiniature, ThemeMiniatureSplit } from './theme-preview'
|
|
30
31
|
|
|
@@ -82,16 +83,30 @@ export function ModeChoices(props: AppearanceEditorProps) {
|
|
|
82
83
|
)
|
|
83
84
|
}
|
|
84
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Whether the draft accent is the host's custom colour: not the theme default,
|
|
88
|
+
* not a preset, and not a theme accent id. A theme accent id counts as known even
|
|
89
|
+
* when the current pair cannot offer it — it is a stored role, not a colour the
|
|
90
|
+
* user typed, and showing it in the custom field would invite editing `ansi-blue`.
|
|
91
|
+
*/
|
|
92
|
+
export function isCustomAccent(props: AppearanceEditorProps): boolean {
|
|
93
|
+
const accent = props.draft.accent
|
|
94
|
+
return (
|
|
95
|
+
accent !== 'theme' &&
|
|
96
|
+
!props.accentOptions.some((option) => option.id === accent) &&
|
|
97
|
+
!isThemeAccentId(accent)
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
85
101
|
export function AccentChoices(props: AppearanceEditorProps) {
|
|
86
102
|
// The resolved appearance decides which side of a preset pair — and which
|
|
87
103
|
// theme accent — the swatches paint, so the row previews each choice as the
|
|
88
104
|
// user will see it.
|
|
89
105
|
const resolvedAppearance = () =>
|
|
90
106
|
props.draft.mode === 'system' ? props.resolvedAppearance : props.draft.mode
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const accentSelection = () => (custom() ? 'custom' : props.draft.accent)
|
|
107
|
+
// The accents the previewed theme pair carries, offered after the presets.
|
|
108
|
+
const themeAccents = () => props.themeAccentOptions ?? []
|
|
109
|
+
const accentSelection = () => (isCustomAccent(props) ? 'custom' : props.draft.accent)
|
|
95
110
|
return (
|
|
96
111
|
<Radio
|
|
97
112
|
value={accentSelection()}
|
|
@@ -152,6 +167,28 @@ export function AccentChoices(props: AppearanceEditorProps) {
|
|
|
152
167
|
</Radio.Item>
|
|
153
168
|
)}
|
|
154
169
|
</For>
|
|
170
|
+
<For each={themeAccents()}>
|
|
171
|
+
{(option) => (
|
|
172
|
+
<Radio.Item
|
|
173
|
+
value={option.id}
|
|
174
|
+
data-theme-accent={option.id}
|
|
175
|
+
class="rounded-full focus-within:ring-3 focus-within:ring-ring/50"
|
|
176
|
+
>
|
|
177
|
+
<Radio.ItemInput />
|
|
178
|
+
<Radio.ItemLabel class="block size-7 cursor-pointer rounded-full border border-dashed p-0.5 data-[checked]:border-solid data-[checked]:border-primary data-[checked]:ring-1 data-[checked]:ring-primary">
|
|
179
|
+
<span
|
|
180
|
+
class="block size-full rounded-full"
|
|
181
|
+
aria-hidden="true"
|
|
182
|
+
style={{
|
|
183
|
+
'background-color':
|
|
184
|
+
resolvedAppearance() === 'light' ? option.light : option.dark,
|
|
185
|
+
}}
|
|
186
|
+
/>
|
|
187
|
+
<span class="sr-only">{`Theme ${option.label.toLowerCase()}`}</span>
|
|
188
|
+
</Radio.ItemLabel>
|
|
189
|
+
</Radio.Item>
|
|
190
|
+
)}
|
|
191
|
+
</For>
|
|
155
192
|
</div>
|
|
156
193
|
<Radio.Item value="custom">
|
|
157
194
|
<Radio.ItemInput />
|
|
@@ -46,7 +46,7 @@ import {
|
|
|
46
46
|
PopoverTrigger,
|
|
47
47
|
} from '../../ui/popover/popover'
|
|
48
48
|
import type { AppearanceEditorProps, AppearancePopoverProps } from './appearance-types'
|
|
49
|
-
import { ModeChoices, AccentChoices, GlassChoices } from './appearance-choices'
|
|
49
|
+
import { ModeChoices, AccentChoices, GlassChoices, isCustomAccent } from './appearance-choices'
|
|
50
50
|
import { SettingsRow, TerminalRow, ThemeRow } from './appearance-rows'
|
|
51
51
|
|
|
52
52
|
/**
|
|
@@ -60,13 +60,19 @@ import { SettingsRow, TerminalRow, ThemeRow } from './appearance-rows'
|
|
|
60
60
|
export function AppearanceEditor(props: AppearanceEditorProps) {
|
|
61
61
|
const errorId = createUniqueId()
|
|
62
62
|
const saveReasonId = createUniqueId()
|
|
63
|
-
const custom = () =>
|
|
64
|
-
props.draft.accent !== 'theme' &&
|
|
65
|
-
!props.accentOptions.some((option) => option.id === props.draft.accent)
|
|
63
|
+
const custom = () => isCustomAccent(props)
|
|
66
64
|
const accentDescription = () => {
|
|
67
65
|
if (props.draft.accent === 'theme') return "Theme default · Uses the palette's intended color."
|
|
68
|
-
const
|
|
69
|
-
return `${
|
|
66
|
+
const preset = props.accentOptions.find((option) => option.id === props.draft.accent)
|
|
67
|
+
if (preset) return `${preset.label} · Controls, glyphs, selections, code, and activity.`
|
|
68
|
+
if (custom()) return 'Custom · Controls, glyphs, selections, code, and activity.'
|
|
69
|
+
const offered = props.themeAccentOptions?.find((option) => option.id === props.draft.accent)
|
|
70
|
+
// A stored theme accent survives a theme switch by id; when the new pair
|
|
71
|
+
// cannot offer that slot the host falls back to the theme's own primary,
|
|
72
|
+
// and the description says so rather than naming a colour not on screen.
|
|
73
|
+
return offered
|
|
74
|
+
? `Theme ${offered.label.toLowerCase()} · The theme's own color, on controls and activity.`
|
|
75
|
+
: "Theme default · This theme does not carry that accent, so it uses the palette's intended color."
|
|
70
76
|
}
|
|
71
77
|
const glassDescription = () =>
|
|
72
78
|
props.draft.surface === 'opaque'
|
|
@@ -25,6 +25,14 @@ export type AppearanceEditorProps = {
|
|
|
25
25
|
resolvedAppearance: 'light' | 'dark'
|
|
26
26
|
themes: readonly AdeaThemeRecord[]
|
|
27
27
|
accentOptions: readonly AccentPreset[]
|
|
28
|
+
/**
|
|
29
|
+
* The accents the previewed light/dark pair carries itself, offered after
|
|
30
|
+
* `accentOptions` — `themeAccentsFor(lightThemeId, darkThemeId)` from
|
|
31
|
+
* `@adea-ai/ui`. Supplied by the host because computing them measures through
|
|
32
|
+
* the catalogue's normalization, which the editor does not load. Omitted, the
|
|
33
|
+
* row offers the presets only.
|
|
34
|
+
*/
|
|
35
|
+
themeAccentOptions?: readonly AccentPreset[]
|
|
28
36
|
onChange: (patch: Partial<AppearanceDraft>) => void
|
|
29
37
|
onSave: () => void
|
|
30
38
|
onCancel: () => void
|
|
@@ -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)
|
package/src/index.ts
CHANGED
|
@@ -126,16 +126,23 @@ export {
|
|
|
126
126
|
type VariantProps,
|
|
127
127
|
} from './lib/variants'
|
|
128
128
|
export {
|
|
129
|
+
appearancePreviewThemes,
|
|
129
130
|
builtinThemes,
|
|
130
131
|
contrastRatio,
|
|
131
132
|
defaultDarkThemeId,
|
|
132
133
|
defaultLightThemeId,
|
|
134
|
+
isThemeAccentId,
|
|
135
|
+
resolveAccentPreset,
|
|
136
|
+
themeAccentsFor,
|
|
133
137
|
themeById,
|
|
134
138
|
themeCssVariables,
|
|
135
139
|
themeFamilies,
|
|
140
|
+
themeRecordById,
|
|
141
|
+
themeRecords,
|
|
136
142
|
themesForAppearance,
|
|
137
143
|
validateTheme,
|
|
138
144
|
validateThemeRegistry,
|
|
145
|
+
withAccent,
|
|
139
146
|
type ThemeAppearance,
|
|
140
147
|
type ThemeColors,
|
|
141
148
|
type ThemeEditorPalette,
|
|
@@ -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
|
+
}
|