@adea-ai/ui 0.71.1 → 0.71.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 (99) hide show
  1. package/dist/components/composites/appearance-editor/appearance-editor.js +1 -1
  2. package/dist/components/composites/appearance-editor/appearance-editor.js.map +1 -1
  3. package/dist/components/composites/stat/stat.d.ts.map +1 -1
  4. package/dist/components/composites/stat/stat.js +8 -8
  5. package/dist/components/composites/stat/stat.js.map +1 -1
  6. package/dist/components/composites/update-dialog/update-dialog.d.ts +7 -0
  7. package/dist/components/composites/update-dialog/update-dialog.d.ts.map +1 -1
  8. package/dist/components/composites/update-dialog/update-dialog.js +71 -26
  9. package/dist/components/composites/update-dialog/update-dialog.js.map +1 -1
  10. package/dist/components/conversation/internal/chat-composer-shell.js +1 -1
  11. package/dist/components/conversation/internal/chat-composer-shell.js.map +1 -1
  12. package/dist/components/conversation/message-composer.d.ts.map +1 -1
  13. package/dist/components/conversation/message-composer.js +1 -1
  14. package/dist/components/conversation/message-composer.js.map +1 -1
  15. package/dist/components/ui/alert/alert.d.ts +3 -1
  16. package/dist/components/ui/alert/alert.d.ts.map +1 -1
  17. package/dist/components/ui/alert/alert.js +18 -8
  18. package/dist/components/ui/alert/alert.js.map +1 -1
  19. package/dist/components/ui/badge/badge.js +4 -4
  20. package/dist/components/ui/badge/badge.js.map +1 -1
  21. package/dist/components/ui/context-menu/context-menu.d.ts.map +1 -1
  22. package/dist/components/ui/context-menu/context-menu.js +2 -2
  23. package/dist/components/ui/context-menu/context-menu.js.map +1 -1
  24. package/dist/components/ui/dialog/dialog.d.ts +17 -1
  25. package/dist/components/ui/dialog/dialog.d.ts.map +1 -1
  26. package/dist/components/ui/dialog/dialog.js +120 -2
  27. package/dist/components/ui/dialog/dialog.js.map +1 -1
  28. package/dist/components/ui/diff-block/diff-block.js +2 -2
  29. package/dist/components/ui/diff-block/diff-block.js.map +1 -1
  30. package/dist/components/ui/dropdown-menu/dropdown-menu.d.ts +3 -2
  31. package/dist/components/ui/dropdown-menu/dropdown-menu.d.ts.map +1 -1
  32. package/dist/components/ui/dropdown-menu/dropdown-menu.js +5 -4
  33. package/dist/components/ui/dropdown-menu/dropdown-menu.js.map +1 -1
  34. package/dist/components/ui/entity-icon/entity-icon.d.ts +3 -3
  35. package/dist/components/ui/entity-icon/entity-icon.js +7 -7
  36. package/dist/components/ui/entity-icon/entity-icon.js.map +1 -1
  37. package/dist/components/ui/field/field.js +1 -1
  38. package/dist/components/ui/field/field.js.map +1 -1
  39. package/dist/components/ui/menubar/menubar.d.ts.map +1 -1
  40. package/dist/components/ui/menubar/menubar.js +2 -2
  41. package/dist/components/ui/menubar/menubar.js.map +1 -1
  42. package/dist/components/ui/modal-dialog/modal-dialog.d.ts +12 -2
  43. package/dist/components/ui/modal-dialog/modal-dialog.d.ts.map +1 -1
  44. package/dist/components/ui/modal-dialog/modal-dialog.js +44 -27
  45. package/dist/components/ui/modal-dialog/modal-dialog.js.map +1 -1
  46. package/dist/components/ui/status-chip/status-chip.js +4 -4
  47. package/dist/components/ui/status-chip/status-chip.js.map +1 -1
  48. package/dist/lib/overlay.d.ts +2 -0
  49. package/dist/lib/overlay.d.ts.map +1 -1
  50. package/dist/lib/overlay.js +3 -1
  51. package/dist/lib/overlay.js.map +1 -1
  52. package/dist/lib/themes.d.ts +7 -0
  53. package/dist/lib/themes.d.ts.map +1 -1
  54. package/dist/lib/themes.js +22 -1
  55. package/dist/lib/themes.js.map +1 -1
  56. package/dist/lib/tokens.d.ts.map +1 -1
  57. package/dist/lib/tokens.js +4 -4
  58. package/dist/lib/tokens.js.map +1 -1
  59. package/dist/r/src/components/composites/appearance-editor/appearance-editor.tsx +1 -1
  60. package/dist/r/src/components/composites/stat/stat.tsx +5 -8
  61. package/dist/r/src/components/composites/update-dialog/update-dialog.tsx +98 -26
  62. package/dist/r/src/components/conversation/internal/chat-composer-shell.tsx +1 -1
  63. package/dist/r/src/components/conversation/message-composer.tsx +3 -1
  64. package/dist/r/src/components/ui/alert/alert.tsx +20 -7
  65. package/dist/r/src/components/ui/badge/badge.tsx +4 -4
  66. package/dist/r/src/components/ui/context-menu/context-menu.tsx +2 -2
  67. package/dist/r/src/components/ui/dialog/dialog.tsx +204 -2
  68. package/dist/r/src/components/ui/diff-block/diff-block.tsx +4 -4
  69. package/dist/r/src/components/ui/dropdown-menu/dropdown-menu.tsx +5 -4
  70. package/dist/r/src/components/ui/entity-icon/entity-icon.tsx +7 -7
  71. package/dist/r/src/components/ui/field/field.tsx +1 -1
  72. package/dist/r/src/components/ui/menubar/menubar.tsx +2 -2
  73. package/dist/r/src/components/ui/modal-dialog/modal-dialog.tsx +24 -2
  74. package/dist/r/src/components/ui/status-chip/status-chip.tsx +4 -4
  75. package/dist/r/src/lib/overlay.ts +4 -0
  76. package/dist/r/src/lib/themes.ts +29 -0
  77. package/dist/r/src/lib/tokens.ts +19 -4
  78. package/dist/r/src/styles/theme.css +10 -10
  79. package/package.json +2 -2
  80. package/src/components/composites/appearance-editor/appearance-editor.tsx +1 -1
  81. package/src/components/composites/stat/stat.tsx +5 -8
  82. package/src/components/composites/update-dialog/update-dialog.tsx +98 -26
  83. package/src/components/conversation/internal/chat-composer-shell.tsx +1 -1
  84. package/src/components/conversation/message-composer.tsx +3 -1
  85. package/src/components/ui/alert/alert.tsx +20 -7
  86. package/src/components/ui/badge/badge.tsx +4 -4
  87. package/src/components/ui/context-menu/context-menu.tsx +2 -2
  88. package/src/components/ui/dialog/dialog.tsx +204 -2
  89. package/src/components/ui/diff-block/diff-block.tsx +4 -4
  90. package/src/components/ui/dropdown-menu/dropdown-menu.tsx +5 -4
  91. package/src/components/ui/entity-icon/entity-icon.tsx +7 -7
  92. package/src/components/ui/field/field.tsx +1 -1
  93. package/src/components/ui/menubar/menubar.tsx +2 -2
  94. package/src/components/ui/modal-dialog/modal-dialog.tsx +24 -2
  95. package/src/components/ui/status-chip/status-chip.tsx +4 -4
  96. package/src/lib/overlay.ts +4 -0
  97. package/src/lib/themes.ts +29 -0
  98. package/src/lib/tokens.ts +19 -4
  99. package/src/styles/theme.css +10 -10
@@ -1,4 +1,13 @@
1
- import { createEffect, createMemo, createSignal, onMount, Show, splitProps } from 'solid-js'
1
+ import {
2
+ createEffect,
3
+ createMemo,
4
+ createSignal,
5
+ onCleanup,
6
+ onMount,
7
+ Show,
8
+ splitProps,
9
+ } from 'solid-js'
10
+ import type { Accessor } from 'solid-js'
2
11
  import { Check, Download, ExternalLink, LoaderCircle, RefreshCw, Sparkles } from 'lucide-solid'
3
12
  import { Badge } from '../../ui/badge'
4
13
  import { Button } from '../../ui/button'
@@ -12,6 +21,7 @@ import {
12
21
  DialogTitle,
13
22
  DialogTrigger,
14
23
  } from '../../ui/dialog'
24
+ import { createDialogFocusRestoration } from '../../ui/dialog/dialog'
15
25
  import { Progress } from '../../ui/progress'
16
26
  import { cn } from '#lib/utils'
17
27
  import { formatBytes, formatReleaseDate, plainTextFromMarkdown } from '#lib/version-notes'
@@ -131,6 +141,12 @@ export type UpdateDialogProps = {
131
141
  */
132
142
  defaultOpen?: boolean
133
143
  onOpenChange?: (open: boolean) => void
144
+ /**
145
+ * Supplies a stable external control to focus when a controlled dialog closes.
146
+ * Useful when a menu or other layer closes before this dialog opens; when omitted,
147
+ * the element focused immediately before the dialog opens is used when available.
148
+ */
149
+ restoreFocusRef?: Accessor<HTMLElement | undefined>
134
150
  /** Render only the panel, for a caller that provides its own dialog. */
135
151
  class?: string
136
152
  }
@@ -162,6 +178,7 @@ export function UpdateDialog(props: UpdateDialogProps) {
162
178
  'open',
163
179
  'defaultOpen',
164
180
  'onOpenChange',
181
+ 'restoreFocusRef',
165
182
  'class',
166
183
  ])
167
184
 
@@ -172,23 +189,41 @@ export function UpdateDialog(props: UpdateDialogProps) {
172
189
  local.onOpenChange?.(next)
173
190
  }
174
191
 
192
+ const focusRestoration = createDialogFocusRestoration({
193
+ open,
194
+ restoreFocusRef: local.restoreFocusRef,
195
+ })
196
+
175
197
  const appName = () => local.appName ?? 'the application'
176
198
  const desktop = () => local.adapter.isDesktopRuntime()
177
199
  const [state, setState] = createSignal<UpdateState | null>(null)
178
200
  const [busy, setBusy] = createSignal(false)
179
201
  const [error, setError] = createSignal('')
202
+ let lifecycleEpoch = 0
203
+ let activeOpenEpoch: number | undefined
204
+ let disposed = false
205
+
206
+ onCleanup(() => {
207
+ disposed = true
208
+ activeOpenEpoch = undefined
209
+ lifecycleEpoch += 1
210
+ })
180
211
 
181
- const loadStatus = async () => {
182
- if (!desktop()) return
212
+ const loadStatus = async (isCurrent: () => boolean = () => !disposed) => {
213
+ if (!desktop() || !isCurrent()) return
183
214
  try {
184
- setState(await local.adapter.getStatus())
215
+ const status = await local.adapter.getStatus()
216
+ if (isCurrent()) setState(status)
185
217
  } catch (caught) {
186
- setError(errorMessage(caught, 'Version status is unavailable'))
218
+ if (isCurrent()) setError(errorMessage(caught, 'Version status is unavailable'))
187
219
  }
188
220
  }
189
221
 
190
222
  onMount(() => {
191
- if (desktop()) void loadStatus()
223
+ if (desktop() && !open()) {
224
+ const epoch = lifecycleEpoch
225
+ void loadStatus(() => !disposed && lifecycleEpoch === epoch)
226
+ }
192
227
  })
193
228
 
194
229
  const check = async () => {
@@ -196,15 +231,25 @@ export function UpdateDialog(props: UpdateDialogProps) {
196
231
  setError(local.desktopOnlyMessage ?? `Update checks are available from the desktop app.`)
197
232
  return
198
233
  }
234
+ const epoch = activeOpenEpoch
235
+ const isCurrent = () =>
236
+ !disposed &&
237
+ epoch !== undefined &&
238
+ activeOpenEpoch === epoch &&
239
+ lifecycleEpoch === epoch &&
240
+ open()
241
+ if (!isCurrent()) return
199
242
  setBusy(true)
200
243
  setError('')
201
244
  try {
202
- setState(await local.adapter.check())
245
+ const checked = await local.adapter.check()
246
+ if (isCurrent()) setState(checked)
203
247
  } catch (caught) {
248
+ if (!isCurrent()) return
204
249
  setError(errorMessage(caught, 'Could not check for updates'))
205
- await loadStatus()
250
+ await loadStatus(isCurrent)
206
251
  } finally {
207
- setBusy(false)
252
+ if (isCurrent()) setBusy(false)
208
253
  }
209
254
  }
210
255
 
@@ -214,35 +259,57 @@ export function UpdateDialog(props: UpdateDialogProps) {
214
259
  * disposed scope.
215
260
  */
216
261
  createEffect(() => {
217
- if (!open() || !desktop()) return
262
+ if (!open() || !desktop()) {
263
+ if (activeOpenEpoch !== undefined) {
264
+ activeOpenEpoch = undefined
265
+ lifecycleEpoch += 1
266
+ }
267
+ return
268
+ }
269
+ const epoch = ++lifecycleEpoch
270
+ activeOpenEpoch = epoch
218
271
  let active = true
272
+ const isCurrent = () =>
273
+ active && !disposed && activeOpenEpoch === epoch && lifecycleEpoch === epoch && open()
274
+ onCleanup(() => {
275
+ active = false
276
+ if (activeOpenEpoch === epoch) {
277
+ activeOpenEpoch = undefined
278
+ lifecycleEpoch += 1
279
+ }
280
+ })
219
281
  setError('')
220
282
  setBusy(true)
221
283
  void (async () => {
222
284
  try {
223
285
  const current = await local.adapter.getStatus()
224
- if (!active) return
286
+ if (!isCurrent()) return
225
287
  setState(current)
226
288
  const checked = await local.adapter.check()
227
- if (active) setState(checked)
289
+ if (isCurrent()) setState(checked)
228
290
  } catch (caught) {
229
- if (active) setError(errorMessage(caught, 'Could not check for updates'))
291
+ if (isCurrent()) setError(errorMessage(caught, 'Could not check for updates'))
230
292
  } finally {
231
- if (active) setBusy(false)
293
+ if (isCurrent()) setBusy(false)
232
294
  }
233
295
  })()
234
- return () => {
235
- active = false
236
- }
237
296
  })
238
297
 
239
298
  const install = async () => {
240
299
  const version = state()?.availableVersion
241
- if (!version) return
300
+ const epoch = activeOpenEpoch
301
+ const isCurrent = () =>
302
+ !disposed &&
303
+ epoch !== undefined &&
304
+ activeOpenEpoch === epoch &&
305
+ lifecycleEpoch === epoch &&
306
+ open()
307
+ if (!version || !isCurrent()) return
242
308
  setBusy(true)
243
309
  setError('')
244
310
  try {
245
311
  const next = await local.adapter.install(version)
312
+ if (!isCurrent()) return
246
313
  setState(next)
247
314
  // A refused install answers with a normal payload whose phase is `failed`.
248
315
  // Without this the click looked like nothing happened.
@@ -250,10 +317,11 @@ export function UpdateDialog(props: UpdateDialogProps) {
250
317
  setError(errorMessage(next.error, 'Update installation failed'))
251
318
  }
252
319
  } catch (caught) {
320
+ if (!isCurrent()) return
253
321
  setError(errorMessage(caught, 'Update installation failed'))
254
- await loadStatus()
322
+ await loadStatus(isCurrent)
255
323
  } finally {
256
- setBusy(false)
324
+ if (isCurrent()) setBusy(false)
257
325
  }
258
326
  }
259
327
 
@@ -308,7 +376,11 @@ export function UpdateDialog(props: UpdateDialogProps) {
308
376
  </DialogTrigger>
309
377
  </Show>
310
378
 
311
- <DialogContent class={cn('max-w-3xl', local.class)}>
379
+ <DialogContent
380
+ class={cn('max-w-3xl', local.class)}
381
+ onOpenAutoFocus={focusRestoration.onOpenAutoFocus}
382
+ onCloseAutoFocus={focusRestoration.onCloseAutoFocus}
383
+ >
312
384
  <DialogHeader>
313
385
  <div class="flex items-center gap-3">
314
386
  <span class="flex size-10 items-center justify-center rounded-xl bg-primary text-primary-foreground">
@@ -432,15 +504,15 @@ export function UpdateDialog(props: UpdateDialogProps) {
432
504
  </Show>
433
505
 
434
506
  <Show when={state()?.phase === 'current'}>
435
- <p class="flex items-center gap-2 text-sm text-success" role="status">
436
- <Check class="size-4" aria-hidden="true" />
507
+ <p class="text-foreground flex items-center gap-2 text-sm" role="status">
508
+ <Check class="size-4 text-success" aria-hidden="true" />
437
509
  {appName()} is up to date.
438
510
  </p>
439
511
  </Show>
440
512
 
441
513
  <Show when={state()?.phase === 'installed'}>
442
- <p class="flex items-center gap-2 text-sm text-success" role="status">
443
- <Check class="size-4" aria-hidden="true" />
514
+ <p class="text-foreground flex items-center gap-2 text-sm" role="status">
515
+ <Check class="size-4 text-success" aria-hidden="true" />
444
516
  <Show when={state()?.restartRequired} fallback="The update is applied.">
445
517
  Restart {appName()} to finish the update.
446
518
  </Show>
@@ -449,7 +521,7 @@ export function UpdateDialog(props: UpdateDialogProps) {
449
521
 
450
522
  <Show when={error()}>
451
523
  <p
452
- class="rounded-lg border border-destructive/35 bg-destructive-subtle px-3 py-2 text-sm text-destructive"
524
+ class="text-foreground rounded-lg border border-destructive/35 bg-destructive-subtle px-3 py-2 text-sm"
453
525
  role="alert"
454
526
  >
455
527
  {error()}
@@ -237,7 +237,7 @@ export function ChatComposerShell<Extra extends object>(props: ChatComposerShell
237
237
  </Show>
238
238
  <Show when={error()}>
239
239
  {(message) => (
240
- <p role="alert" class="text-destructive mb-1 text-xs">
240
+ <p role="alert" class="text-foreground mb-1 border-s-2 border-destructive ps-2 text-xs">
241
241
  {message()}
242
242
  </p>
243
243
  )}
@@ -197,7 +197,9 @@ export function MessageComposer(props: MessageComposerProps) {
197
197
  field the user is typing in. */}
198
198
  <div aria-live="polite" class="min-h-0">
199
199
  <Show when={error()}>
200
- {(message) => <p class="text-destructive text-xs">{message()}</p>}
200
+ {(message) => (
201
+ <p class="text-foreground border-s-2 border-destructive ps-2 text-xs">{message()}</p>
202
+ )}
201
203
  </Show>
202
204
  </div>
203
205
  </form>
@@ -14,7 +14,9 @@ import { cn } from '#lib/utils'
14
14
  *
15
15
  * The glyph follows the tone rather than being passed in, so an alert cannot
16
16
  * be given a green checkmark on a destructive fill. A caller that genuinely
17
- * needs a different icon passes `icon`; `icon={null}` removes it.
17
+ * needs a different icon passes `icon`; `icon={null}` removes it. Text stays on
18
+ * the body foreground over the tinted status surface, while the glyph and edge
19
+ * carry the status hue.
18
20
  */
19
21
  const alertVariants = cva(
20
22
  'relative grid w-full grid-cols-[auto_1fr] items-start gap-x-3 gap-y-1 rounded-lg border px-3 py-2.5 text-sm',
@@ -22,10 +24,10 @@ const alertVariants = cva(
22
24
  variants: {
23
25
  variant: {
24
26
  default: 'bg-card text-card-foreground border-border',
25
- destructive: 'bg-destructive-subtle text-destructive border-destructive/30',
26
- success: 'bg-success-subtle text-success border-success/30',
27
- warning: 'bg-warning-subtle text-warning border-warning/30',
28
- info: 'bg-info-subtle text-info border-info/30',
27
+ destructive: 'bg-destructive-subtle text-foreground border-destructive/30',
28
+ success: 'bg-success-subtle text-foreground border-success/30',
29
+ warning: 'bg-warning-subtle text-foreground border-warning/30',
30
+ info: 'bg-info-subtle text-foreground border-info/30',
29
31
  },
30
32
  },
31
33
  defaultVariants: { variant: 'default' },
@@ -40,6 +42,14 @@ const alertIcons = {
40
42
  info: Info,
41
43
  } as const
42
44
 
45
+ const alertIconTones = {
46
+ default: 'text-muted-foreground',
47
+ destructive: 'text-destructive',
48
+ success: 'text-success',
49
+ warning: 'text-warning',
50
+ info: 'text-info',
51
+ } as const
52
+
43
53
  type AlertVariantProps = VariantProps<typeof alertVariants>
44
54
 
45
55
  export type AlertProps = ComponentProps<'div'> &
@@ -61,7 +71,10 @@ export function Alert(props: AlertProps) {
61
71
  {...rest}
62
72
  >
63
73
  {local.icon === null ? null : (
64
- <span data-slot="alert-icon" class="row-span-2 pt-0.5 [&_svg]:size-4">
74
+ <span
75
+ data-slot="alert-icon"
76
+ class={cn('row-span-2 pt-0.5 [&_svg]:size-4', alertIconTones[tone()])}
77
+ >
65
78
  {local.icon ?? <Dynamic component={alertIcons[tone()]} />}
66
79
  </span>
67
80
  )}
@@ -86,7 +99,7 @@ export function AlertDescription(props: ComponentProps<'div'>) {
86
99
  return (
87
100
  <div
88
101
  data-slot="alert-description"
89
- class={cn('col-start-2 text-sm opacity-90 [&_p]:leading-relaxed', local.class)}
102
+ class={cn('col-start-2 text-sm [&_p]:leading-relaxed', local.class)}
90
103
  {...rest}
91
104
  />
92
105
  )
@@ -20,10 +20,10 @@ export const badgeVariants = cva(
20
20
  secondary: 'bg-secondary text-secondary-foreground',
21
21
  outline: 'border-border text-foreground',
22
22
  /** Tinted status fills, for counts and states rather than actions. */
23
- success: 'bg-success-subtle text-success',
24
- warning: 'bg-warning-subtle text-warning',
25
- destructive: 'bg-destructive-subtle text-destructive',
26
- info: 'bg-info-subtle text-info',
23
+ success: 'bg-success-subtle text-foreground',
24
+ warning: 'bg-warning-subtle text-foreground',
25
+ destructive: 'bg-destructive-subtle text-foreground',
26
+ info: 'bg-info-subtle text-foreground',
27
27
  subtle: 'bg-primary-subtle text-primary',
28
28
  },
29
29
  size: {
@@ -4,6 +4,7 @@ import type { ComponentProps } from 'solid-js'
4
4
  import { splitProps } from 'solid-js'
5
5
  import {
6
6
  menuContentPadding,
7
+ destructiveMenuItem,
7
8
  menuItem,
8
9
  menuLabel,
9
10
  menuSeparator,
@@ -65,8 +66,7 @@ export function ContextMenuItem(
65
66
  class={cn(
66
67
  menuItem,
67
68
  {
68
- 'text-destructive data-[highlighted]:bg-destructive-subtle data-[highlighted]:text-destructive':
69
- local.variant === 'destructive',
69
+ [destructiveMenuItem]: local.variant === 'destructive',
70
70
  },
71
71
  local.class
72
72
  )}
@@ -1,10 +1,212 @@
1
1
  import { Dialog as KobalteDialog } from '@kobalte/core/dialog'
2
2
  import { X } from 'lucide-solid'
3
- import type { ComponentProps, JSX } from 'solid-js'
4
- import { Show, splitProps } from 'solid-js'
3
+ import type { Accessor, ComponentProps, JSX } from 'solid-js'
4
+ import { createRenderEffect, onCleanup, Show, splitProps } from 'solid-js'
5
5
  import { overlayMotion, overlayPositioner, overlayScrim, overlaySurface } from '#lib/overlay'
6
6
  import { cn } from '#lib/utils'
7
7
 
8
+ type FocusCycle = {
9
+ content: HTMLElement
10
+ opener: HTMLElement | null
11
+ openerEligible: boolean
12
+ closed: boolean
13
+ focusMovedOutside: boolean
14
+ closeAutoFocusScheduled: boolean
15
+ frame?: number
16
+ fallbackTimer?: number
17
+ observer?: MutationObserver
18
+ onFocusIn: (event: FocusEvent) => void
19
+ }
20
+
21
+ function canRestoreFocus(target: HTMLElement): boolean {
22
+ return (
23
+ target.isConnected &&
24
+ target !== document.body &&
25
+ target !== document.documentElement &&
26
+ !target.closest('[inert], [aria-hidden="true"]') &&
27
+ !target.matches(':disabled') &&
28
+ target.getClientRects().length > 0
29
+ )
30
+ }
31
+
32
+ function hasOtherOverlay(content: HTMLElement, opener: HTMLElement): boolean {
33
+ return [...document.querySelectorAll<HTMLElement>('[role="dialog"], [role="alertdialog"]')].some(
34
+ (overlay) =>
35
+ overlay !== content &&
36
+ !content.contains(overlay) &&
37
+ !overlay.contains(opener) &&
38
+ !overlay.closest('[aria-hidden="true"], [inert]') &&
39
+ overlay.getClientRects().length > 0
40
+ )
41
+ }
42
+
43
+ type DialogFocusRestorationOptions = {
44
+ open: Accessor<boolean>
45
+ restoreFocusRef?: Accessor<HTMLElement | undefined>
46
+ onOpenAutoFocus?: ComponentProps<typeof KobalteDialog.Content>['onOpenAutoFocus']
47
+ onCloseAutoFocus?: ComponentProps<typeof KobalteDialog.Content>['onCloseAutoFocus']
48
+ }
49
+
50
+ /**
51
+ * Adds guarded opener capture and restoration to a Kobalte dialog content.
52
+ * Composed surfaces use this internal helper; it is omitted from the public
53
+ * dialog barrel so the restoration policy stays with the dialog components.
54
+ */
55
+ export function createDialogFocusRestoration({
56
+ open,
57
+ restoreFocusRef,
58
+ onOpenAutoFocus: callerOnOpenAutoFocus,
59
+ onCloseAutoFocus: callerOnCloseAutoFocus,
60
+ }: DialogFocusRestorationOptions) {
61
+ const focusCycles = new WeakMap<HTMLElement, FocusCycle>()
62
+ let activeFocusCycle: FocusCycle | undefined
63
+ let pendingOpener: HTMLElement | null = null
64
+ let pendingOpenerEligible = false
65
+
66
+ createRenderEffect(() => {
67
+ if (!open() || typeof document === 'undefined') {
68
+ pendingOpener = null
69
+ pendingOpenerEligible = false
70
+ return
71
+ }
72
+ const active = restoreFocusRef?.() ?? document.activeElement
73
+ pendingOpener = active instanceof HTMLElement ? active : null
74
+ pendingOpenerEligible = active instanceof HTMLElement && canRestoreFocus(active)
75
+ })
76
+
77
+ const finishFocusCycle = (cycle: FocusCycle) => {
78
+ if (cycle.frame !== undefined) window.cancelAnimationFrame(cycle.frame)
79
+ if (cycle.fallbackTimer !== undefined) window.clearTimeout(cycle.fallbackTimer)
80
+ cycle.observer?.disconnect()
81
+ document.removeEventListener('focusin', cycle.onFocusIn)
82
+ if (activeFocusCycle === cycle) activeFocusCycle = undefined
83
+ }
84
+
85
+ const scheduleFocusRestore = (cycle: FocusCycle) => {
86
+ if (cycle.frame !== undefined) return
87
+ cycle.frame = window.requestAnimationFrame(() => {
88
+ cycle.frame = undefined
89
+ const opener = cycle.opener
90
+ const active = document.activeElement
91
+ const focusIsAvailable =
92
+ active === document.body ||
93
+ active === document.documentElement ||
94
+ active === cycle.content ||
95
+ (active instanceof Node && cycle.content.contains(active))
96
+ if (
97
+ activeFocusCycle !== cycle ||
98
+ !cycle.closed ||
99
+ cycle.focusMovedOutside ||
100
+ !opener ||
101
+ !opener.isConnected ||
102
+ opener.matches(':disabled') ||
103
+ opener.getClientRects().length === 0 ||
104
+ !focusIsAvailable ||
105
+ hasOtherOverlay(cycle.content, opener)
106
+ ) {
107
+ finishFocusCycle(cycle)
108
+ return
109
+ }
110
+
111
+ // Inert and aria-hidden are removed by the dialog's containment cleanup.
112
+ // Wait for that cleanup before returning focus to the original control.
113
+ if (opener.closest('[inert], [aria-hidden="true"]')) return
114
+ if (cycle.openerEligible && canRestoreFocus(opener)) {
115
+ opener.focus({ preventScroll: true })
116
+ }
117
+ finishFocusCycle(cycle)
118
+ })
119
+ }
120
+
121
+ const onOpenAutoFocus = (event: Event) => {
122
+ const element = event.currentTarget
123
+ if (element instanceof HTMLElement) {
124
+ if (activeFocusCycle) finishFocusCycle(activeFocusCycle)
125
+
126
+ const openerWasCaptured = pendingOpener !== null
127
+ const opener = pendingOpener ?? document.activeElement
128
+ const openerEligible = openerWasCaptured
129
+ ? pendingOpenerEligible
130
+ : opener instanceof HTMLElement && canRestoreFocus(opener)
131
+ pendingOpener = null
132
+ pendingOpenerEligible = false
133
+ const cycle: FocusCycle = {
134
+ content: element,
135
+ opener: opener instanceof HTMLElement ? opener : null,
136
+ openerEligible,
137
+ closed: false,
138
+ focusMovedOutside: false,
139
+ closeAutoFocusScheduled: false,
140
+ onFocusIn: (focusEvent) => {
141
+ const target = focusEvent.target
142
+ if (
143
+ !(target instanceof HTMLElement) ||
144
+ target === document.body ||
145
+ target === document.documentElement ||
146
+ target === element ||
147
+ element.contains(target) ||
148
+ target === (opener instanceof HTMLElement ? opener : null)
149
+ ) {
150
+ return
151
+ }
152
+ cycle.focusMovedOutside = true
153
+ },
154
+ }
155
+ focusCycles.set(element, cycle)
156
+ activeFocusCycle = cycle
157
+ document.addEventListener('focusin', cycle.onFocusIn)
158
+ }
159
+ callerOnOpenAutoFocus?.(event)
160
+ }
161
+
162
+ const onCloseAutoFocus = (event: Event) => {
163
+ const element = event.currentTarget
164
+ const cycle = element instanceof HTMLElement ? focusCycles.get(element) : undefined
165
+ callerOnCloseAutoFocus?.(event)
166
+
167
+ if (!cycle) return
168
+ cycle.closed = true
169
+
170
+ // A caller that prevents Kobalte's close autofocus owns the focus decision.
171
+ if (event.defaultPrevented) {
172
+ finishFocusCycle(cycle)
173
+ return
174
+ }
175
+
176
+ if (cycle.fallbackTimer !== undefined) {
177
+ window.clearTimeout(cycle.fallbackTimer)
178
+ cycle.fallbackTimer = undefined
179
+ }
180
+ cycle.closeAutoFocusScheduled = true
181
+ cycle.observer = new MutationObserver(() => scheduleFocusRestore(cycle))
182
+ cycle.observer.observe(document.body, {
183
+ attributes: true,
184
+ attributeFilter: ['aria-hidden', 'inert'],
185
+ childList: true,
186
+ subtree: true,
187
+ })
188
+ scheduleFocusRestore(cycle)
189
+ }
190
+
191
+ onCleanup(() => {
192
+ const cycle = activeFocusCycle
193
+ if (!cycle) return
194
+ cycle.closed = true
195
+
196
+ // Kobalte dispatches unmount autofocus from a timer. This fallback only
197
+ // releases the scoped focus listener if the content did not dispatch it.
198
+ queueMicrotask(() => {
199
+ if (cycle.closeAutoFocusScheduled) return
200
+ cycle.fallbackTimer = window.setTimeout(() => {
201
+ cycle.fallbackTimer = undefined
202
+ if (!cycle.closeAutoFocusScheduled) finishFocusCycle(cycle)
203
+ }, 0)
204
+ })
205
+ })
206
+
207
+ return { onOpenAutoFocus, onCloseAutoFocus }
208
+ }
209
+
8
210
  /**
9
211
  * Dialog.
10
212
  *
@@ -205,8 +205,8 @@ export function DiffBlock(props: DiffBlockProps) {
205
205
  {/* The counts are text, so the summary survives greyscale and a reader
206
206
  who cannot separate the two line tones still gets the shape of it. */}
207
207
  <span class="shrink-0 font-mono text-2xs text-muted-foreground">
208
- <span class="text-success">+{stats().added}</span>{' '}
209
- <span class="text-destructive">−{stats().removed}</span>
208
+ <span class="text-foreground">+{stats().added}</span>{' '}
209
+ <span class="text-foreground">−{stats().removed}</span>
210
210
  </span>
211
211
  </span>
212
212
  {local.actions}
@@ -278,8 +278,8 @@ export function DiffSummary(props: { patch: string; class?: string; onSelect?: (
278
278
  >
279
279
  <span class="truncate text-foreground">{name()}</span>
280
280
  <span class="shrink-0 text-2xs">
281
- <span class="text-success">+{stats().added}</span>{' '}
282
- <span class="text-destructive">−{stats().removed}</span>
281
+ <span class="text-foreground">+{stats().added}</span>{' '}
282
+ <span class="text-foreground">−{stats().removed}</span>
283
283
  </span>
284
284
  </button>
285
285
  )
@@ -4,6 +4,7 @@ import type { ComponentProps } from 'solid-js'
4
4
  import { splitProps } from 'solid-js'
5
5
  import {
6
6
  menuContentPadding,
7
+ destructiveMenuItem,
7
8
  menuItem,
8
9
  menuLabel,
9
10
  menuSeparator,
@@ -25,8 +26,9 @@ import { cn } from '#lib/utils'
25
26
  * highlight, Escape to close and return focus — which is exactly what a
26
27
  * hand-rolled `div` with click handlers gets wrong.
27
28
  *
28
- * The `destructive` variant on an item is a *colour*, not a confirmation.
29
- * Pairing it with an AlertDialog is the caller's job.
29
+ * The `destructive` variant marks the row with a status-coloured edge and
30
+ * highlighted tint while keeping its label on the body foreground. Pairing it
31
+ * with an AlertDialog is the caller's job.
30
32
  */
31
33
  export function DropdownMenu(props: ComponentProps<typeof KobalteDropdownMenu>) {
32
34
  return <KobalteDropdownMenu {...props} />
@@ -83,8 +85,7 @@ export function DropdownMenuItem(
83
85
  class={cn(
84
86
  menuItem,
85
87
  {
86
- 'text-destructive data-[highlighted]:bg-destructive-subtle data-[highlighted]:text-destructive':
87
- local.variant === 'destructive',
88
+ [destructiveMenuItem]: local.variant === 'destructive',
88
89
  },
89
90
  local.class
90
91
  )}
@@ -17,9 +17,9 @@ import { cn } from '#lib/utils'
17
17
  * 1. **A glyph is always present.** A brand with no logo gets a monogram, not an
18
18
  * empty box. `fallback` takes the name and returns the glyph, so a caller can
19
19
  * choose initials, a domain letter, or a kind icon.
20
- * 2. **The tile owns its own contrast.** `tone` tints the background and the
21
- * glyph together, so a caller never picks a colour pair and never ships an
22
- * icon that disappears against its tile.
20
+ * 2. **The tile owns its own contrast.** `tone` tints the background while
21
+ * monogram text keeps the body foreground; vector glyphs retain the status
22
+ * colour. A caller never picks a colour pair or ships a glyph that disappears.
23
23
  * 3. **Size is on the control ladder**, not arbitrary. A tile in a rail and a
24
24
  * tile in a detail header are the same two or three sizes every other
25
25
  * interactive element uses.
@@ -51,10 +51,10 @@ export const entityIconVariants = cva(
51
51
  primary: 'border-transparent bg-primary-subtle text-primary',
52
52
  /** Bare: no fill and no edge, for a tile that already sits on a surface. */
53
53
  bare: 'border-transparent bg-transparent text-foreground',
54
- success: 'border-transparent bg-success-subtle text-success',
55
- warning: 'border-transparent bg-warning-subtle text-warning',
56
- danger: 'border-transparent bg-destructive-subtle text-destructive',
57
- info: 'border-transparent bg-info-subtle text-info',
54
+ success: 'border-transparent bg-success-subtle text-foreground [&_svg]:text-success',
55
+ warning: 'border-transparent bg-warning-subtle text-foreground [&_svg]:text-warning',
56
+ danger: 'border-transparent bg-destructive-subtle text-foreground [&_svg]:text-destructive',
57
+ info: 'border-transparent bg-info-subtle text-foreground [&_svg]:text-info',
58
58
  },
59
59
  },
60
60
  defaultVariants: { shape: 'rounded', size: 'md', tone: 'neutral' },
@@ -44,7 +44,7 @@ export function FieldDescription(props: ComponentProps<typeof KobalteTextField.D
44
44
  export function FieldError(props: ComponentProps<typeof KobalteTextField.ErrorMessage>) {
45
45
  const [local, rest] = splitProps(props, ['class'])
46
46
  return (
47
- <KobalteTextField.ErrorMessage class={cn('text-destructive text-sm', local.class)} {...rest} />
47
+ <KobalteTextField.ErrorMessage class={cn('text-foreground text-sm', local.class)} {...rest} />
48
48
  )
49
49
  }
50
50