@adea-ai/ui 0.101.1 → 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.
Files changed (70) hide show
  1. package/README.md +27 -11
  2. package/dist/components/composites/appearance-editor/appearance-choices.d.ts +7 -0
  3. package/dist/components/composites/appearance-editor/appearance-choices.d.ts.map +1 -1
  4. package/dist/components/composites/appearance-editor/appearance-choices.js +44 -3
  5. package/dist/components/composites/appearance-editor/appearance-choices.js.map +1 -1
  6. package/dist/components/composites/appearance-editor/appearance-editor.d.ts.map +1 -1
  7. package/dist/components/composites/appearance-editor/appearance-editor.js +7 -3
  8. package/dist/components/composites/appearance-editor/appearance-editor.js.map +1 -1
  9. package/dist/components/composites/appearance-editor/appearance-types.d.ts +8 -0
  10. package/dist/components/composites/appearance-editor/appearance-types.d.ts.map +1 -1
  11. package/dist/components/composites/update-dialog/update-dialog.d.ts +15 -2
  12. package/dist/components/composites/update-dialog/update-dialog.d.ts.map +1 -1
  13. package/dist/components/composites/update-dialog/update-dialog.js +80 -16
  14. package/dist/components/composites/update-dialog/update-dialog.js.map +1 -1
  15. package/dist/components/theme/appearance-panel.d.ts +2 -1
  16. package/dist/components/theme/appearance-panel.d.ts.map +1 -1
  17. package/dist/components/theme/appearance-panel.js +62 -22
  18. package/dist/components/theme/appearance-panel.js.map +1 -1
  19. package/dist/components/theme/theme-provider.d.ts +13 -2
  20. package/dist/components/theme/theme-provider.d.ts.map +1 -1
  21. package/dist/components/theme/theme-provider.js +7 -4
  22. package/dist/components/theme/theme-provider.js.map +1 -1
  23. package/dist/components/ui/board/board.d.ts +39 -2
  24. package/dist/components/ui/board/board.d.ts.map +1 -1
  25. package/dist/components/ui/board/board.js +230 -92
  26. package/dist/components/ui/board/board.js.map +1 -1
  27. package/dist/components/ui/board/index.d.ts +1 -1
  28. package/dist/components/ui/board/index.d.ts.map +1 -1
  29. package/dist/components/ui/board/index.js +2 -2
  30. package/dist/index.d.ts +1 -1
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +4 -3
  33. package/dist/lib/theme-accents.d.ts +9 -0
  34. package/dist/lib/theme-accents.d.ts.map +1 -0
  35. package/dist/lib/theme-accents.js +41 -0
  36. package/dist/lib/theme-accents.js.map +1 -0
  37. package/dist/lib/themes.d.ts +69 -5
  38. package/dist/lib/themes.d.ts.map +1 -1
  39. package/dist/lib/themes.js +100 -7
  40. package/dist/lib/themes.js.map +1 -1
  41. package/dist/r/appearance-editor.json +1 -1
  42. package/dist/r/board.json +2 -0
  43. package/dist/r/lib.json +6 -1
  44. package/dist/r/registry.json +10 -2
  45. package/dist/r/src/components/composites/appearance-editor/appearance-choices.tsx +41 -4
  46. package/dist/r/src/components/composites/appearance-editor/appearance-editor.tsx +12 -6
  47. package/dist/r/src/components/composites/appearance-editor/appearance-types.ts +8 -0
  48. package/dist/r/src/components/composites/update-dialog/update-dialog.tsx +90 -10
  49. package/dist/r/src/components/theme/appearance-panel.tsx +60 -29
  50. package/dist/r/src/components/theme/theme-provider.tsx +31 -7
  51. package/dist/r/src/components/ui/board/board.tsx +231 -77
  52. package/dist/r/src/components/ui/board/index.ts +2 -0
  53. package/dist/r/src/lib/theme-accents.ts +44 -0
  54. package/dist/r/src/lib/themes.ts +116 -5
  55. package/dist/r/src/styles/base.css +11 -0
  56. package/dist/r/theming.json +1 -0
  57. package/package.json +2 -2
  58. package/registry.json +10 -2
  59. package/src/components/composites/appearance-editor/appearance-choices.tsx +41 -4
  60. package/src/components/composites/appearance-editor/appearance-editor.tsx +12 -6
  61. package/src/components/composites/appearance-editor/appearance-types.ts +8 -0
  62. package/src/components/composites/update-dialog/update-dialog.tsx +90 -10
  63. package/src/components/theme/appearance-panel.tsx +60 -29
  64. package/src/components/theme/theme-provider.tsx +31 -7
  65. package/src/components/ui/board/board.tsx +231 -77
  66. package/src/components/ui/board/index.ts +2 -0
  67. package/src/index.ts +7 -0
  68. package/src/lib/theme-accents.ts +44 -0
  69. package/src/lib/themes.ts +116 -5
  70. package/src/styles/base.css +11 -0
@@ -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
- const custom = () =>
92
- props.draft.accent !== 'theme' &&
93
- !props.accentOptions.some((option) => option.id === props.draft.accent)
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 name = props.accentOptions.find((option) => option.id === props.draft.accent)?.label
69
- return `${name ?? 'Custom'} · Controls, glyphs, selections, code, and activity.`
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()) applyCheckResult(checked)
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
- failCheck(caught, 'Could not check for updates')
328
- await loadStatus(isCurrent)
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
- setState(current)
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()) applyCheckResult(checked)
424
+ if (isCurrent()) {
425
+ applyCheckResult(checked, !discardStaleOffer)
426
+ if (discardStaleOffer && checked.phase !== 'failed') channelCheckUnconfirmed = false
427
+ }
372
428
  } catch (caught) {
373
- if (isCurrent()) failCheck(caught, 'Could not check for updates')
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, layered on whichever theme is active.
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. Every preset is measured against the surface it lands on, so
99
- none of them can make a label unreadable.
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
- <Button
106
- as="button"
107
- type="button"
108
- variant="outline"
109
- size="sm"
110
- role="radio"
111
- aria-checked={selection().accent === preset.id}
112
- aria-label={preset.label}
113
- class={cn({
114
- 'border-ring ring-3 ring-primary-subtle': selection().accent === preset.id,
115
- })}
116
- onClick={() => setSelection({ accent: preset.id })}
117
- >
118
- <span
119
- aria-hidden="true"
120
- class="size-3 rounded-full border border-border"
121
- style={{
122
- 'background-color':
123
- preset.id === 'theme'
124
- ? 'var(--primary)'
125
- : `var(--accent-${preset.id}, ${preset.dark})`,
126
- }}
127
- />
128
- {preset.label}
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, which re-colour the interactive roles on
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
- /** An `accentPresets` id, or `theme` for the variant's own primary. */
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: accentPresets.some((preset) => preset.id === parsed.accent)
161
- ? (parsed.accent as string)
162
- : defaultThemeSelection.accent,
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 (selection().accent === 'theme') root.removeAttribute('data-accent')
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)