@adea-ai/ui 0.97.1 → 0.97.3

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 (37) hide show
  1. package/dist/components/composites/about-dialog/about-dialog.d.ts.map +1 -1
  2. package/dist/components/composites/about-dialog/about-dialog.js +2 -1
  3. package/dist/components/composites/about-dialog/about-dialog.js.map +1 -1
  4. package/dist/components/composites/account-menu/account-menu.d.ts.map +1 -1
  5. package/dist/components/composites/account-menu/account-menu.js +95 -99
  6. package/dist/components/composites/account-menu/account-menu.js.map +1 -1
  7. package/dist/components/composites/appearance-editor/appearance-choices.d.ts.map +1 -1
  8. package/dist/components/composites/appearance-editor/appearance-choices.js +44 -42
  9. package/dist/components/composites/appearance-editor/appearance-choices.js.map +1 -1
  10. package/dist/components/composites/appearance-editor/appearance-editor.d.ts.map +1 -1
  11. package/dist/components/composites/appearance-editor/appearance-editor.js +23 -1
  12. package/dist/components/composites/appearance-editor/appearance-editor.js.map +1 -1
  13. package/dist/components/composites/appearance-editor/appearance-rows.d.ts +22 -5
  14. package/dist/components/composites/appearance-editor/appearance-rows.d.ts.map +1 -1
  15. package/dist/components/composites/appearance-editor/appearance-rows.js +249 -152
  16. package/dist/components/composites/appearance-editor/appearance-rows.js.map +1 -1
  17. package/dist/components/composites/appearance-editor/appearance-types.d.ts +7 -0
  18. package/dist/components/composites/appearance-editor/appearance-types.d.ts.map +1 -1
  19. package/dist/components/composites/appearance-editor/theme-preview.d.ts +10 -0
  20. package/dist/components/composites/appearance-editor/theme-preview.d.ts.map +1 -1
  21. package/dist/components/composites/appearance-editor/theme-preview.js +61 -12
  22. package/dist/components/composites/appearance-editor/theme-preview.js.map +1 -1
  23. package/dist/r/src/components/composites/about-dialog/about-dialog.tsx +6 -1
  24. package/dist/r/src/components/composites/account-menu/account-menu.tsx +2 -1
  25. package/dist/r/src/components/composites/appearance-editor/appearance-choices.tsx +19 -14
  26. package/dist/r/src/components/composites/appearance-editor/appearance-editor.tsx +13 -1
  27. package/dist/r/src/components/composites/appearance-editor/appearance-rows.tsx +270 -160
  28. package/dist/r/src/components/composites/appearance-editor/appearance-types.ts +7 -0
  29. package/dist/r/src/components/composites/appearance-editor/theme-preview.tsx +58 -0
  30. package/package.json +1 -1
  31. package/src/components/composites/about-dialog/about-dialog.tsx +6 -1
  32. package/src/components/composites/account-menu/account-menu.tsx +2 -1
  33. package/src/components/composites/appearance-editor/appearance-choices.tsx +19 -14
  34. package/src/components/composites/appearance-editor/appearance-editor.tsx +13 -1
  35. package/src/components/composites/appearance-editor/appearance-rows.tsx +270 -160
  36. package/src/components/composites/appearance-editor/appearance-types.ts +7 -0
  37. package/src/components/composites/appearance-editor/theme-preview.tsx +58 -0
@@ -93,20 +93,31 @@ export function AccentChoices(props: AppearanceEditorProps) {
93
93
  disabled={props.saving}
94
94
  orientation="horizontal"
95
95
  aria-label="Accent"
96
- class="flex flex-wrap items-center gap-2"
96
+ // Two chips in a column beside a 3x2 swatch grid: one line of six
97
+ // swatches plus chips overflows the settings sidebar's control column,
98
+ // and the column keeps both rows of the pair balanced (2 x 28px + gap).
99
+ class="flex items-center gap-3"
97
100
  onChange={(accent) =>
98
101
  props.onChange({
99
102
  accent: accent === 'custom' ? (props.customAccentValue ?? '') : accent,
100
103
  })
101
104
  }
102
105
  >
103
- <Radio.Item value="theme">
104
- <Radio.ItemInput />
105
- <Radio.ItemLabel class="flex h-7 cursor-pointer items-center rounded-md border px-2 text-xs whitespace-nowrap data-[checked]:border-primary data-[checked]:ring-1 data-[checked]:ring-primary">
106
- Theme default
107
- </Radio.ItemLabel>
108
- </Radio.Item>
109
- <div class="flex max-w-full flex-wrap gap-2">
106
+ <div class="flex flex-col gap-2">
107
+ <Radio.Item value="theme">
108
+ <Radio.ItemInput />
109
+ <Radio.ItemLabel class="flex h-7 cursor-pointer items-center rounded-md border px-2 text-xs whitespace-nowrap data-[checked]:border-primary data-[checked]:ring-1 data-[checked]:ring-primary">
110
+ Theme default
111
+ </Radio.ItemLabel>
112
+ </Radio.Item>
113
+ <Radio.Item value="custom">
114
+ <Radio.ItemInput />
115
+ <Radio.ItemLabel class="flex h-7 cursor-pointer items-center rounded-md border px-2 text-xs whitespace-nowrap data-[checked]:border-primary data-[checked]:ring-1 data-[checked]:ring-primary">
116
+ Custom
117
+ </Radio.ItemLabel>
118
+ </Radio.Item>
119
+ </div>
120
+ <div class="grid grid-cols-3 gap-2">
110
121
  <For each={props.accentOptions}>
111
122
  {(option) => (
112
123
  <Radio.Item
@@ -133,12 +144,6 @@ export function AccentChoices(props: AppearanceEditorProps) {
133
144
  )}
134
145
  </For>
135
146
  </div>
136
- <Radio.Item value="custom">
137
- <Radio.ItemInput />
138
- <Radio.ItemLabel class="flex h-7 cursor-pointer items-center rounded-md border px-2 text-xs data-[checked]:border-primary data-[checked]:ring-1 data-[checked]:ring-primary">
139
- Custom
140
- </Radio.ItemLabel>
141
- </Radio.Item>
142
147
  </Radio>
143
148
  )
144
149
  }
@@ -47,7 +47,7 @@ import {
47
47
  } from '../../ui/popover/popover'
48
48
  import type { AppearanceEditorProps, AppearancePopoverProps } from './appearance-types'
49
49
  import { ModeChoices, AccentChoices, GlassChoices } from './appearance-choices'
50
- import { SettingsRow, ThemeRow } from './appearance-rows'
50
+ import { SettingsRow, TerminalRow, ThemeRow } from './appearance-rows'
51
51
 
52
52
  /**
53
53
  * A live editor whose persistence and preview authority belong to its host.
@@ -99,6 +99,18 @@ export function AppearanceEditor(props: AppearanceEditorProps) {
99
99
  disabled={props.saving}
100
100
  onSelect={(darkThemeId) => props.onChange({ darkThemeId })}
101
101
  />
102
+ {/* The row exists only when the host carries a terminal preference:
103
+ the draft field is the show/hide switch, so the editor can never
104
+ render a terminal row that writes nothing. */}
105
+ <Show when={props.draft.terminalThemeId !== undefined}>
106
+ <TerminalRow
107
+ selectedId={props.draft.terminalThemeId ?? 'theme'}
108
+ preview={props.resolvedAppearance === 'dark' ? props.darkTheme : props.lightTheme}
109
+ themes={props.themes}
110
+ disabled={props.saving}
111
+ onSelect={(terminalThemeId) => props.onChange({ terminalThemeId })}
112
+ />
113
+ </Show>
102
114
  <SettingsRow title="Accent" icon={<SlidersHorizontal />} description={accentDescription()}>
103
115
  <AccentChoices {...props} />
104
116
  </SettingsRow>
@@ -23,9 +23,12 @@
23
23
  */
24
24
  // Retains the compact divided row and palette-preview selector from the pinned
25
25
  // Zeron/Adea composition; Kobalte owns option navigation and nested dismissal.
26
+ // One shared menu serves the light, dark, and terminal rows: each option is a
27
+ // compact theme preview with its name and one-line description beside it, and
28
+ // the menu sizes to that content instead of clipping it at the trigger width.
26
29
  import type { AdeaTheme, AdeaThemeRecord } from '@adea-ai/themes'
27
30
  import { For, Show, createMemo, createSignal, onCleanup, type JSX } from 'solid-js'
28
- import { ChevronDown, Palette } from 'lucide-solid'
31
+ import { ChevronDown, Palette, SquareTerminal } from 'lucide-solid'
29
32
  import { cn } from '#lib/utils'
30
33
  import { Button } from '../../ui/button/button'
31
34
  import {
@@ -35,7 +38,7 @@ import {
35
38
  DropdownMenuRadioItem,
36
39
  DropdownMenuTrigger,
37
40
  } from '../../ui/dropdown-menu/dropdown-menu'
38
- import { PalettePreview } from './theme-preview'
41
+ import { PalettePreview, ThemeMenuPreview } from './theme-preview'
39
42
 
40
43
  const POINTER_FOCUS_TARGET_SELECTOR =
41
44
  'a[href], area[href], button, input, select, textarea, iframe, [tabindex], [contenteditable], details > summary:first-of-type'
@@ -87,18 +90,30 @@ export function SettingsRow(props: {
87
90
  )
88
91
  }
89
92
 
93
+ /** One selectable palette in a theme menu. */
94
+ export type ThemeOption = {
95
+ id: string
96
+ name: string
97
+ description?: string
98
+ preview: AdeaTheme
99
+ }
100
+
90
101
  /**
91
- * A compact persistent theme choice. Kobalte handles menu navigation and
92
- * dismissal; this row moves Tab to the adjacent browser-reported stop because
102
+ * The shared theme picker. Kobalte handles menu navigation and dismissal;
103
+ * this component moves Tab to the adjacent browser-reported stop because
93
104
  * menus consume Tab by default. Enumerating elements and checking their native
94
105
  * tabIndex keeps details summaries in the order while preserving explicit
95
- * tabindex overrides.
106
+ * tabindex overrides. The menu width follows its content (bounded by the
107
+ * popper's available width) so a long theme name and its description render
108
+ * fully instead of truncating at the trigger's width.
96
109
  */
97
- export function ThemeRow(props: {
98
- appearance: 'light' | 'dark'
110
+ function ThemeSelectMenu(props: {
111
+ /** Row title; also the trigger's accessible name and the group's label. */
112
+ label: string
99
113
  selectedId: string
100
- preview: AdeaTheme
101
- themes: readonly AdeaThemeRecord[]
114
+ /** Palette shown on the trigger; hosts pass the accent-applied preview. */
115
+ triggerPreview: AdeaTheme
116
+ options: readonly ThemeOption[]
102
117
  disabled?: boolean
103
118
  onSelect: (id: string) => void
104
119
  }) {
@@ -112,15 +127,11 @@ export function ThemeRow(props: {
112
127
  onCleanup(() => {
113
128
  focusLifecycleVersion += 1
114
129
  })
115
- const options = createMemo(() =>
116
- props.themes.filter((theme) => theme.appearance === props.appearance)
117
- )
118
130
  const selected = createMemo(
119
131
  () =>
120
- options().find((theme) => theme.id === props.selectedId) ??
121
- options().find((theme) => theme.id === props.preview.id)
132
+ props.options.find((option) => option.id === props.selectedId) ??
133
+ props.options.find((option) => option.id === props.triggerPreview.id)
122
134
  )
123
- const label = () => (props.appearance === 'light' ? 'Light theme' : 'Dark theme')
124
135
  const closeMenu = () => {
125
136
  if (menuOpen()) focusLifecycleVersion += 1
126
137
  setMenuOpen(false)
@@ -166,160 +177,259 @@ export function ThemeRow(props: {
166
177
  }
167
178
  }
168
179
  return (
169
- <SettingsRow title={label()} icon={<Palette />}>
170
- <div ref={setPortalMount}>
171
- <DropdownMenu
172
- modal={false}
173
- open={menuOpen()}
174
- onOpenChange={(open) => {
175
- if (open !== menuOpen()) focusLifecycleVersion += 1
176
- setMenuOpen(open)
177
- if (open) {
178
- focusTargetAfterTab = undefined
179
- setFocusOutsideTarget(undefined)
180
- setRestoreTriggerFocus(false)
181
- }
182
- }}
180
+ <div ref={setPortalMount}>
181
+ <DropdownMenu
182
+ modal={false}
183
+ open={menuOpen()}
184
+ onOpenChange={(open) => {
185
+ if (open !== menuOpen()) focusLifecycleVersion += 1
186
+ setMenuOpen(open)
187
+ if (open) {
188
+ focusTargetAfterTab = undefined
189
+ setFocusOutsideTarget(undefined)
190
+ setRestoreTriggerFocus(false)
191
+ }
192
+ }}
193
+ >
194
+ <DropdownMenuTrigger
195
+ ref={setTriggerElement}
196
+ as={Button}
197
+ variant="outline"
198
+ size="sm"
199
+ aria-label={props.label}
200
+ disabled={props.disabled || props.options.length === 0}
201
+ class="w-full justify-between sm:w-52"
183
202
  >
184
- <DropdownMenuTrigger
185
- ref={setTriggerElement}
186
- as={Button}
187
- variant="outline"
188
- size="sm"
189
- aria-label={label()}
190
- disabled={props.disabled || options().length === 0}
191
- class="w-full justify-between sm:w-52"
192
- >
193
- <span class="flex min-w-0 flex-1 items-center gap-2">
194
- <PalettePreview theme={props.preview} />
195
- <span class="min-w-0 flex-1 truncate">{selected()?.name ?? props.preview.name}</span>
203
+ <span class="flex min-w-0 flex-1 items-center gap-2">
204
+ <PalettePreview theme={props.triggerPreview} />
205
+ <span class="min-w-0 flex-1 truncate">
206
+ {selected()?.name ?? props.triggerPreview.name}
196
207
  </span>
197
- <ChevronDown aria-hidden="true" class="shrink-0 text-muted-foreground" />
198
- </DropdownMenuTrigger>
199
- <DropdownMenuContent
200
- portalMount={portalMount()}
201
- class="max-h-(--kb-popper-content-available-height) w-(--kb-popper-anchor-width) min-w-32 overflow-x-hidden overflow-y-auto"
202
- onInteractOutside={(event) => {
203
- if (event.detail.originalEvent.type !== 'pointerdown') return
204
- const parentDialog = portalMount()?.closest<HTMLElement>('[role="dialog"]')
205
- const focusScope = parentDialog ?? portalMount()?.ownerDocument.body
206
- const target = event.detail.originalEvent.target
207
- if (!(target instanceof Element) || !focusScope?.contains(target)) return
208
-
209
- const focusTarget = target.closest<HTMLElement>(POINTER_FOCUS_TARGET_SELECTOR)
210
- if (focusTarget && isTabStop(focusTarget)) {
211
- setRestoreTriggerFocus(false)
212
- setFocusOutsideTarget(focusTarget)
213
- } else {
214
- setRestoreTriggerFocus(true)
215
- }
216
- }}
217
- onFocusOutside={(event) => {
218
- // The containing dialog or document body can briefly reclaim
219
- // focus when the menu opens. Keep that handoff from dismissing
220
- // it; focus moving to another descendant still closes the menu.
221
- const parentDialog = portalMount()?.closest<HTMLElement>('[role="dialog"]')
222
- const focusScope = parentDialog ?? portalMount()?.ownerDocument.body
223
- const target = event.detail.originalEvent.target
224
- if (target === focusScope) {
225
- event.preventDefault()
226
- } else if (target instanceof HTMLElement && focusScope?.contains(target)) {
227
- setRestoreTriggerFocus(false)
228
- setFocusOutsideTarget(target)
229
- }
230
- }}
231
- onEscapeKeyDown={() => setRestoreTriggerFocus(true)}
232
- onCloseAutoFocus={(event) => {
233
- // A prior menu instance can finish its focus cleanup after this
234
- // row has already reopened. Do not let that cleanup steal focus.
235
- if (menuOpen()) {
236
- event.preventDefault()
237
- return
238
- }
208
+ </span>
209
+ <ChevronDown aria-hidden="true" class="shrink-0 text-muted-foreground" />
210
+ </DropdownMenuTrigger>
211
+ <DropdownMenuContent
212
+ portalMount={portalMount()}
213
+ // Sized to content (at least the trigger width, never wider than the
214
+ // popper allows) so long theme names render in full with padding for
215
+ // the selection indicator instead of truncating at the anchor width.
216
+ class="max-h-(--kb-popper-content-available-height) w-max min-w-(--kb-popper-anchor-width) max-w-(--kb-popper-content-available-width) overflow-x-hidden overflow-y-auto"
217
+ onInteractOutside={(event) => {
218
+ if (event.detail.originalEvent.type !== 'pointerdown') return
219
+ const parentDialog = portalMount()?.closest<HTMLElement>('[role="dialog"]')
220
+ const focusScope = parentDialog ?? portalMount()?.ownerDocument.body
221
+ const target = event.detail.originalEvent.target
222
+ if (!(target instanceof Element) || !focusScope?.contains(target)) return
239
223
 
240
- const lifecycleVersion = focusLifecycleVersion
241
- const shouldRestore = restoreTriggerFocus()
242
- const focusTarget = focusTargetAfterTab ?? focusOutsideTarget()
243
- focusTargetAfterTab = undefined
244
- setFocusOutsideTarget(undefined)
224
+ const focusTarget = target.closest<HTMLElement>(POINTER_FOCUS_TARGET_SELECTOR)
225
+ if (focusTarget && isTabStop(focusTarget)) {
226
+ setRestoreTriggerFocus(false)
227
+ setFocusOutsideTarget(focusTarget)
228
+ } else {
229
+ setRestoreTriggerFocus(true)
230
+ }
231
+ }}
232
+ onFocusOutside={(event) => {
233
+ // The containing dialog or document body can briefly reclaim
234
+ // focus when the menu opens. Keep that handoff from dismissing
235
+ // it; focus moving to another descendant still closes the menu.
236
+ const parentDialog = portalMount()?.closest<HTMLElement>('[role="dialog"]')
237
+ const focusScope = parentDialog ?? portalMount()?.ownerDocument.body
238
+ const target = event.detail.originalEvent.target
239
+ if (target === focusScope) {
240
+ event.preventDefault()
241
+ } else if (target instanceof HTMLElement && focusScope?.contains(target)) {
245
242
  setRestoreTriggerFocus(false)
246
- if (shouldRestore) {
247
- // The saved focus may be the containing dialog rather than
248
- // the trigger when WebKit opens this menu from a pointer click.
249
- event.preventDefault()
250
- triggerElement()?.focus({ preventScroll: true })
251
- return
252
- }
243
+ setFocusOutsideTarget(target)
244
+ }
245
+ }}
246
+ onEscapeKeyDown={() => setRestoreTriggerFocus(true)}
247
+ onCloseAutoFocus={(event) => {
248
+ // A prior menu instance can finish its focus cleanup after this
249
+ // row has already reopened. Do not let that cleanup steal focus.
250
+ if (menuOpen()) {
251
+ event.preventDefault()
252
+ return
253
+ }
253
254
 
254
- const parentDialog = portalMount()?.closest<HTMLElement>('[role="dialog"]')
255
- const focusScope = parentDialog ?? portalMount()?.ownerDocument.body
256
- if (focusTarget?.isConnected && focusScope?.contains(focusTarget)) {
257
- // Preserve the intended in-scope destination across
258
- // Kobalte's deferred stale-focus restore.
259
- event.preventDefault()
260
- queueMicrotask(() => {
261
- if (lifecycleVersion !== focusLifecycleVersion || menuOpen()) return
255
+ const lifecycleVersion = focusLifecycleVersion
256
+ const shouldRestore = restoreTriggerFocus()
257
+ const focusTarget = focusTargetAfterTab ?? focusOutsideTarget()
258
+ focusTargetAfterTab = undefined
259
+ setFocusOutsideTarget(undefined)
260
+ setRestoreTriggerFocus(false)
261
+ if (shouldRestore) {
262
+ // The saved focus may be the containing dialog rather than
263
+ // the trigger when WebKit opens this menu from a pointer click.
264
+ event.preventDefault()
265
+ triggerElement()?.focus({ preventScroll: true })
266
+ return
267
+ }
268
+
269
+ const parentDialog = portalMount()?.closest<HTMLElement>('[role="dialog"]')
270
+ const focusScope = parentDialog ?? portalMount()?.ownerDocument.body
271
+ if (focusTarget?.isConnected && focusScope?.contains(focusTarget)) {
272
+ // Preserve the intended in-scope destination across
273
+ // Kobalte's deferred stale-focus restore.
274
+ event.preventDefault()
275
+ queueMicrotask(() => {
276
+ if (lifecycleVersion !== focusLifecycleVersion || menuOpen()) return
262
277
 
263
- const activeElement = document.activeElement
264
- const isMenuFocus =
265
- activeElement instanceof HTMLElement &&
266
- activeElement.closest('[role="menu"]') !== null
267
- const isTriggerFocus = activeElement === triggerElement()
268
- if (
269
- activeElement !== focusTarget &&
270
- (activeElement === document.body ||
271
- activeElement === focusScope ||
272
- isMenuFocus ||
273
- isTriggerFocus)
274
- ) {
275
- focusTarget.focus({ preventScroll: true })
276
- }
277
- })
278
- return
279
- }
278
+ const activeElement = document.activeElement
279
+ const isMenuFocus =
280
+ activeElement instanceof HTMLElement &&
281
+ activeElement.closest('[role="menu"]') !== null
282
+ const isTriggerFocus = activeElement === triggerElement()
283
+ if (
284
+ activeElement !== focusTarget &&
285
+ (activeElement === document.body ||
286
+ activeElement === focusScope ||
287
+ isMenuFocus ||
288
+ isTriggerFocus)
289
+ ) {
290
+ focusTarget.focus({ preventScroll: true })
291
+ }
292
+ })
293
+ return
294
+ }
280
295
 
281
- const activeElement = document.activeElement
282
- if (
283
- focusScope &&
284
- activeElement instanceof HTMLElement &&
285
- activeElement !== focusScope &&
286
- focusScope.contains(activeElement) &&
287
- activeElement.closest('[role="menu"]') === null
288
- ) {
289
- // Preserve a real focus target in this scope instead of
290
- // restoring the stale element captured when the menu mounted. A
291
- // focused menu item is about to unmount, so Kobalte must restore
292
- // focus to the trigger instead.
293
- event.preventDefault()
294
- }
296
+ const activeElement = document.activeElement
297
+ if (
298
+ focusScope &&
299
+ activeElement instanceof HTMLElement &&
300
+ activeElement !== focusScope &&
301
+ focusScope.contains(activeElement) &&
302
+ activeElement.closest('[role="menu"]') === null
303
+ ) {
304
+ // Preserve a real focus target in this scope instead of
305
+ // restoring the stale element captured when the menu mounted. A
306
+ // focused menu item is about to unmount, so Kobalte must restore
307
+ // focus to the trigger instead.
308
+ event.preventDefault()
309
+ }
310
+ }}
311
+ >
312
+ <DropdownMenuRadioGroup
313
+ value={selected()?.id ?? ''}
314
+ aria-label={`${props.label} options`}
315
+ on:keydown={moveFocusOnTab}
316
+ onChange={(id) => {
317
+ if (!props.disabled && typeof id === 'string') props.onSelect(id)
295
318
  }}
296
319
  >
297
- <DropdownMenuRadioGroup
298
- value={selected()?.id ?? ''}
299
- aria-label={`${label()} options`}
300
- on:keydown={moveFocusOnTab}
301
- onChange={(id) => {
302
- if (!props.disabled && typeof id === 'string') props.onSelect(id)
303
- }}
304
- >
305
- <For each={options()}>
306
- {(theme) => (
307
- <DropdownMenuRadioItem
308
- value={theme.id}
309
- textValue={theme.name}
310
- closeOnSelect={true}
311
- disabled={props.disabled}
312
- onSelect={() => setRestoreTriggerFocus(true)}
313
- >
314
- <PalettePreview theme={theme} />
315
- <span class="min-w-0 flex-1 truncate">{theme.name}</span>
316
- </DropdownMenuRadioItem>
317
- )}
318
- </For>
319
- </DropdownMenuRadioGroup>
320
- </DropdownMenuContent>
321
- </DropdownMenu>
322
- </div>
320
+ <For each={props.options}>
321
+ {(option) => (
322
+ <DropdownMenuRadioItem
323
+ value={option.id}
324
+ textValue={option.name}
325
+ closeOnSelect={true}
326
+ disabled={props.disabled}
327
+ onSelect={() => setRestoreTriggerFocus(true)}
328
+ >
329
+ <ThemeMenuPreview theme={option.preview} />
330
+ <span class="flex min-w-0 flex-1 flex-col gap-0.5">
331
+ <span class="truncate">{option.name}</span>
332
+ {/* Visual affordance only: the accessible name stays the
333
+ theme name, which is what keyboard and test callers
334
+ match on. */}
335
+ <Show when={option.description}>
336
+ <span
337
+ aria-hidden="true"
338
+ class="line-clamp-2 max-w-44 text-2xs text-muted-foreground"
339
+ >
340
+ {option.description}
341
+ </span>
342
+ </Show>
343
+ </span>
344
+ </DropdownMenuRadioItem>
345
+ )}
346
+ </For>
347
+ </DropdownMenuRadioGroup>
348
+ </DropdownMenuContent>
349
+ </DropdownMenu>
350
+ </div>
351
+ )
352
+ }
353
+
354
+ /**
355
+ * A compact persistent theme choice for one appearance.
356
+ */
357
+ export function ThemeRow(props: {
358
+ appearance: 'light' | 'dark'
359
+ selectedId: string
360
+ preview: AdeaTheme
361
+ themes: readonly AdeaThemeRecord[]
362
+ disabled?: boolean
363
+ onSelect: (id: string) => void
364
+ }) {
365
+ const label = () => (props.appearance === 'light' ? 'Light theme' : 'Dark theme')
366
+ const options = createMemo<ThemeOption[]>(() =>
367
+ props.themes
368
+ .filter((theme) => theme.appearance === props.appearance)
369
+ .map((theme) => ({
370
+ id: theme.id,
371
+ name: theme.name,
372
+ description: theme.description,
373
+ preview: theme,
374
+ }))
375
+ )
376
+ return (
377
+ <SettingsRow title={label()} icon={<Palette />}>
378
+ <ThemeSelectMenu
379
+ label={label()}
380
+ selectedId={props.selectedId}
381
+ triggerPreview={props.preview}
382
+ options={options()}
383
+ disabled={props.disabled}
384
+ onSelect={props.onSelect}
385
+ />
386
+ </SettingsRow>
387
+ )
388
+ }
389
+
390
+ /**
391
+ * A terminal palette choice independent of the light/dark rows. The first
392
+ * option (`'theme'`) follows the interface theme, painted with the resolved
393
+ * preview; every catalogue record follows, both appearances, because the
394
+ * terminal paints one fixed ANSI palette rather than an appearance axis.
395
+ */
396
+ export function TerminalRow(props: {
397
+ selectedId: string
398
+ /** Resolved interface theme: the `theme` option's palette and the fallback. */
399
+ preview: AdeaTheme
400
+ themes: readonly AdeaThemeRecord[]
401
+ disabled?: boolean
402
+ onSelect: (id: string) => void
403
+ }) {
404
+ const options = createMemo<ThemeOption[]>(() => [
405
+ {
406
+ id: 'theme',
407
+ name: 'UI theme',
408
+ description: 'Uses the interface theme.',
409
+ preview: props.preview,
410
+ },
411
+ ...props.themes.map((theme) => ({
412
+ id: theme.id,
413
+ name: theme.name,
414
+ description: theme.description,
415
+ preview: theme,
416
+ })),
417
+ ])
418
+ const selectedOption = () => options().find((option) => option.id === props.selectedId)
419
+ const description = () =>
420
+ selectedOption() && selectedOption()?.id !== 'theme'
421
+ ? `The terminal follows ${selectedOption()?.name}.`
422
+ : 'The terminal follows the interface theme.'
423
+ return (
424
+ <SettingsRow title="Terminal" icon={<SquareTerminal />} description={description()}>
425
+ <ThemeSelectMenu
426
+ label="Terminal"
427
+ selectedId={props.selectedId}
428
+ triggerPreview={selectedOption()?.preview ?? props.preview}
429
+ options={options()}
430
+ disabled={props.disabled}
431
+ onSelect={props.onSelect}
432
+ />
323
433
  </SettingsRow>
324
434
  )
325
435
  }
@@ -4,6 +4,13 @@ export type AppearanceDraft = Readonly<{
4
4
  mode: 'system' | 'light' | 'dark'
5
5
  lightThemeId: string
6
6
  darkThemeId: string
7
+ /**
8
+ * The terminal palette: `'theme'` follows the interface theme, a theme id
9
+ * pins that theme's terminal colours. Optional so a host without a terminal
10
+ * preference renders no terminal row — the row's presence is this field's
11
+ * presence, not a second prop that could disagree with the draft.
12
+ */
13
+ terminalThemeId?: string
7
14
  /** Theme default, a canonical preset id, or the host's custom accent draft. */
8
15
  accent: string
9
16
  surface: 'theme' | 'frosted' | 'opaque'
@@ -105,6 +105,64 @@ export function ThemeMiniatureSplit(props: { light: AdeaTheme; dark: AdeaTheme;
105
105
  )
106
106
  }
107
107
 
108
+ /**
109
+ * The dropdown-item preview: the ThemePreview composition (rail + panel +
110
+ * text bars + action row) at menu scale, so a menu item carries the theme's
111
+ * actual chrome rather than the three-band trigger swatch. `aria-hidden` —
112
+ * the item's accessible name stays the theme name.
113
+ */
114
+ export function ThemeMenuPreview(props: { theme: AdeaTheme; class?: string }) {
115
+ const colors = () => props.theme.colors
116
+ return (
117
+ <span
118
+ data-theme-menu-preview
119
+ aria-hidden="true"
120
+ class={cn(
121
+ 'flex h-16 w-28 shrink-0 overflow-hidden rounded-md border border-(--appearance-preview-border)',
122
+ props.class
123
+ )}
124
+ style={{
125
+ 'background-color': colors().surface,
126
+ '--appearance-preview-border': colors().border,
127
+ }}
128
+ >
129
+ <span
130
+ class="flex w-5 shrink-0 flex-col items-center gap-1.5 border-e border-(--appearance-preview-border) pt-2"
131
+ style={{ 'background-color': colors().surfaceElevated }}
132
+ >
133
+ <span class="size-1.5 rounded-[3px]" style={{ 'background-color': colors().accent }} />
134
+ <span
135
+ class="size-1.5 rounded-[3px]"
136
+ style={{ 'background-color': colors().surfaceActive }}
137
+ />
138
+ <span
139
+ class="size-1.5 rounded-[3px]"
140
+ style={{ 'background-color': colors().surfaceActive }}
141
+ />
142
+ </span>
143
+ <span
144
+ class="m-1 flex min-w-0 flex-1 flex-col gap-1 rounded-md border border-(--appearance-preview-border) p-1.5"
145
+ style={{
146
+ 'background-color': colors().background,
147
+ '--appearance-preview-border': colors().border,
148
+ }}
149
+ >
150
+ <span class="h-1.5 w-8 rounded-full" style={{ 'background-color': colors().text }} />
151
+ <span class="h-1 w-full rounded-full" style={{ 'background-color': colors().textMuted }} />
152
+ <span class="h-1 w-3/4 rounded-full" style={{ 'background-color': colors().textMuted }} />
153
+ <span class="mt-auto flex items-center gap-1">
154
+ <span class="h-2.5 w-7 rounded-[3px]" style={{ 'background-color': colors().accent }} />
155
+ <span class="h-2.5 w-4 rounded-[3px]" style={{ 'background-color': colors().error }} />
156
+ <span
157
+ class="h-2.5 w-4 rounded-[3px]"
158
+ style={{ 'background-color': colors().surfaceActive }}
159
+ />
160
+ </span>
161
+ </span>
162
+ </span>
163
+ )
164
+ }
165
+
108
166
  export function PalettePreview(props: { theme: AdeaTheme; class?: string }) {
109
167
  const bands = () => [
110
168
  props.theme.colors.surface,