@a4ui/core 0.32.0 → 0.34.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 (63) hide show
  1. package/dist/charts/BarList.d.ts +33 -0
  2. package/dist/charts/CategoryBar.d.ts +23 -0
  3. package/dist/charts/StatusTracker.d.ts +27 -0
  4. package/dist/charts/index.d.ts +3 -0
  5. package/dist/charts.js +446 -316
  6. package/dist/elements.css +278 -0
  7. package/dist/full.css +278 -0
  8. package/dist/index.d.ts +25 -1
  9. package/dist/index.js +6423 -4195
  10. package/dist/ui/AudioWaveform.d.ts +22 -0
  11. package/dist/ui/Callout.d.ts +27 -0
  12. package/dist/ui/Confetti.d.ts +46 -0
  13. package/dist/ui/CouponField.d.ts +33 -0
  14. package/dist/ui/CursorTrail.d.ts +25 -0
  15. package/dist/ui/DiffViewer.d.ts +33 -0
  16. package/dist/ui/FollowingPointer.d.ts +24 -0
  17. package/dist/ui/GanttChart.d.ts +37 -0
  18. package/dist/ui/Globe.d.ts +44 -0
  19. package/dist/ui/Kanban.d.ts +49 -0
  20. package/dist/ui/Lamp.d.ts +22 -0
  21. package/dist/ui/Lightbox.d.ts +41 -0
  22. package/dist/ui/ModelPicker.d.ts +38 -0
  23. package/dist/ui/OnboardingChecklist.d.ts +51 -0
  24. package/dist/ui/PivotTable.d.ts +37 -0
  25. package/dist/ui/ReasoningTrace.d.ts +28 -0
  26. package/dist/ui/SheetSnap.d.ts +28 -0
  27. package/dist/ui/Snippet.d.ts +19 -0
  28. package/dist/ui/SuggestionChips.d.ts +25 -0
  29. package/dist/ui/ToolCallTimeline.d.ts +34 -0
  30. package/dist/ui/TreeTable.d.ts +52 -0
  31. package/dist/ui/UsageMeter.d.ts +26 -0
  32. package/dist/ui/VideoPlayerShell.d.ts +19 -0
  33. package/dist/ui/WorldMap.d.ts +36 -0
  34. package/package.json +1 -1
  35. package/src/charts/BarList.tsx +83 -0
  36. package/src/charts/CategoryBar.tsx +95 -0
  37. package/src/charts/StatusTracker.tsx +81 -0
  38. package/src/charts/index.ts +3 -0
  39. package/src/index.ts +48 -1
  40. package/src/ui/AudioWaveform.tsx +190 -0
  41. package/src/ui/Callout.tsx +66 -0
  42. package/src/ui/Confetti.tsx +131 -0
  43. package/src/ui/CouponField.tsx +120 -0
  44. package/src/ui/CursorTrail.tsx +88 -0
  45. package/src/ui/DiffViewer.tsx +166 -0
  46. package/src/ui/FollowingPointer.tsx +112 -0
  47. package/src/ui/GanttChart.tsx +283 -0
  48. package/src/ui/Globe.tsx +317 -0
  49. package/src/ui/Kanban.tsx +250 -0
  50. package/src/ui/Lamp.tsx +88 -0
  51. package/src/ui/Lightbox.tsx +261 -0
  52. package/src/ui/ModelPicker.tsx +119 -0
  53. package/src/ui/OnboardingChecklist.tsx +163 -0
  54. package/src/ui/PivotTable.tsx +0 -0
  55. package/src/ui/ReasoningTrace.tsx +83 -0
  56. package/src/ui/SheetSnap.tsx +264 -0
  57. package/src/ui/Snippet.tsx +64 -0
  58. package/src/ui/SuggestionChips.tsx +65 -0
  59. package/src/ui/ToolCallTimeline.tsx +137 -0
  60. package/src/ui/TreeTable.tsx +172 -0
  61. package/src/ui/UsageMeter.tsx +71 -0
  62. package/src/ui/VideoPlayerShell.tsx +282 -0
  63. package/src/ui/WorldMap.tsx +191 -0
@@ -0,0 +1,261 @@
1
+ // Full-screen image viewer: a responsive thumbnail grid that opens (via
2
+ // Portal, dark backdrop) into an overlay with the active image, prev/next
3
+ // navigation, a close button, an alt-text caption, and a thumbnail strip —
4
+ // same overlay idiom as Modal (backdrop click / Escape closes, body scroll
5
+ // locks while open), just built directly on Portal instead of Kobalte's
6
+ // Dialog so index/open state can be driven by the caller.
7
+ import { ChevronLeft, ChevronRight, X, ZoomIn } from 'lucide-solid'
8
+ import type { JSX } from 'solid-js'
9
+ import { createEffect, createMemo, createSignal, For, onCleanup, Show } from 'solid-js'
10
+
11
+ import { cn } from '../lib/cn'
12
+ import { motionReduced } from '../lib/motion'
13
+ import { Portal } from './Portal'
14
+
15
+ /** One image in a {@link Lightbox}. */
16
+ export interface LightboxImage {
17
+ src: string
18
+ alt?: string
19
+ /** Smaller image used in the thumbnail grid/strip; falls back to `src`. */
20
+ thumb?: string
21
+ }
22
+
23
+ export interface LightboxProps {
24
+ images: LightboxImage[]
25
+ /** Controlled overlay open state. When set, wins over internal state — pair with `onOpenChange`. */
26
+ open?: boolean
27
+ /** Controlled active image index (clamped to the images array). When set, wins over internal state — pair with `onIndexChange`. */
28
+ index?: number
29
+ onOpenChange?: (open: boolean) => void
30
+ onIndexChange?: (index: number) => void
31
+ /** Show the thumbnail strip at the bottom of the overlay. @default true */
32
+ showThumbnails?: boolean
33
+ class?: string
34
+ }
35
+
36
+ /**
37
+ * Responsive thumbnail grid (`thumb || src`) that opens into a full-screen
38
+ * viewer on click: the active image centered, prev/next chevrons, a close
39
+ * button, an alt-text caption, a zoom toggle, and (by default) a thumbnail
40
+ * strip with the active image highlighted. Navigate with the arrow keys or
41
+ * Escape, click the backdrop to close. Works controlled
42
+ * (`open`/`index` + `onOpenChange`/`onIndexChange`) or uncontrolled. Body
43
+ * scroll locks while open, like `Modal`; the zoom transition is skipped
44
+ * under `prefers-reduced-motion` but stays fully functional.
45
+ *
46
+ * @example
47
+ * ```tsx
48
+ * <Lightbox
49
+ * images={[
50
+ * { src: '/photos/1-full.jpg', thumb: '/photos/1-thumb.jpg', alt: 'Sunset over the bay' },
51
+ * { src: '/photos/2-full.jpg', thumb: '/photos/2-thumb.jpg', alt: 'Mountain trail' },
52
+ * ]}
53
+ * />
54
+ * ```
55
+ */
56
+ export function Lightbox(props: LightboxProps): JSX.Element {
57
+ const [internalOpen, setInternalOpen] = createSignal(false)
58
+ const open = createMemo(() => props.open ?? internalOpen())
59
+
60
+ const [internalIndex, setInternalIndex] = createSignal(0)
61
+ const rawIndex = createMemo(() => props.index ?? internalIndex())
62
+
63
+ const lastIndex = createMemo(() => Math.max(0, props.images.length - 1))
64
+ const clampIndex = (i: number): number => Math.min(Math.max(i, 0), lastIndex())
65
+ const index = createMemo(() => clampIndex(rawIndex()))
66
+ const current = createMemo(() => props.images[index()])
67
+
68
+ const setOpen = (next: boolean): void => {
69
+ setInternalOpen(next)
70
+ props.onOpenChange?.(next)
71
+ }
72
+ const setIndex = (next: number): void => {
73
+ const clamped = clampIndex(next)
74
+ setInternalIndex(clamped)
75
+ props.onIndexChange?.(clamped)
76
+ }
77
+
78
+ const openAt = (i: number): void => {
79
+ setIndex(i)
80
+ setOpen(true)
81
+ }
82
+ const close = (): void => setOpen(false)
83
+ const prev = (): void => setIndex(index() - 1)
84
+ const next = (): void => setIndex(index() + 1)
85
+ const showThumbnails = (): boolean => props.showThumbnails !== false
86
+
87
+ // Zoom toggle for the active image; reset whenever the image or open state changes.
88
+ const [zoomed, setZoomed] = createSignal(false)
89
+ createEffect(() => {
90
+ index()
91
+ open()
92
+ setZoomed(false)
93
+ })
94
+
95
+ // Lock body scroll while the overlay is open — same idiom as Modal's Dialog.
96
+ createEffect(() => {
97
+ if (!open() || typeof document === 'undefined') return
98
+ const previousOverflow = document.body.style.overflow
99
+ document.body.style.overflow = 'hidden'
100
+ onCleanup(() => {
101
+ document.body.style.overflow = previousOverflow
102
+ })
103
+ })
104
+
105
+ // Escape closes, ArrowLeft/ArrowRight navigate, while the overlay is open.
106
+ createEffect(() => {
107
+ if (!open() || typeof window === 'undefined') return
108
+ const onKeyDown = (e: KeyboardEvent): void => {
109
+ if (e.key === 'Escape') {
110
+ e.preventDefault()
111
+ close()
112
+ } else if (e.key === 'ArrowLeft') {
113
+ e.preventDefault()
114
+ prev()
115
+ } else if (e.key === 'ArrowRight') {
116
+ e.preventDefault()
117
+ next()
118
+ }
119
+ }
120
+ window.addEventListener('keydown', onKeyDown)
121
+ onCleanup(() => window.removeEventListener('keydown', onKeyDown))
122
+ })
123
+
124
+ return (
125
+ <div class={cn('grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4', props.class)}>
126
+ <For each={props.images}>
127
+ {(image, i) => (
128
+ <button
129
+ type="button"
130
+ class="group relative aspect-square overflow-hidden rounded-lg border border-border bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
131
+ aria-label={image.alt ?? `Open image ${i() + 1}`}
132
+ onClick={() => openAt(i())}
133
+ >
134
+ <img
135
+ src={image.thumb ?? image.src}
136
+ alt={image.alt ?? ''}
137
+ loading="lazy"
138
+ class="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
139
+ />
140
+ </button>
141
+ )}
142
+ </For>
143
+
144
+ <Show when={open() && current()}>
145
+ <Portal>
146
+ <div
147
+ role="dialog"
148
+ aria-modal="true"
149
+ aria-label={current()?.alt ?? 'Image viewer'}
150
+ tabindex="-1"
151
+ class="fixed inset-0 z-50 flex flex-col bg-background/95 backdrop-blur-sm outline-none"
152
+ onClick={close}
153
+ ref={(el) => {
154
+ queueMicrotask(() => el.focus())
155
+ }}
156
+ >
157
+ <div class="flex items-center justify-end p-4" onClick={(e) => e.stopPropagation()}>
158
+ <button
159
+ type="button"
160
+ aria-label="Close"
161
+ onClick={close}
162
+ class="grid h-10 w-10 place-items-center rounded-lg bg-card/80 text-foreground transition hover:bg-muted"
163
+ >
164
+ <X class="h-5 w-5" />
165
+ </button>
166
+ </div>
167
+
168
+ <div class="relative flex flex-1 items-center justify-center px-4">
169
+ <Show when={props.images.length > 1}>
170
+ <button
171
+ type="button"
172
+ aria-label="Previous image"
173
+ disabled={index() === 0}
174
+ onClick={(e) => {
175
+ e.stopPropagation()
176
+ prev()
177
+ }}
178
+ class="absolute left-2 z-10 grid h-10 w-10 place-items-center rounded-full bg-card/80 text-foreground transition hover:bg-muted disabled:pointer-events-none disabled:opacity-40 sm:left-4"
179
+ >
180
+ <ChevronLeft class="h-6 w-6" />
181
+ </button>
182
+ </Show>
183
+
184
+ <img
185
+ src={current()?.src}
186
+ alt={current()?.alt ?? ''}
187
+ onClick={(e) => {
188
+ e.stopPropagation()
189
+ setZoomed((z) => !z)
190
+ }}
191
+ class={cn(
192
+ 'max-h-[calc(100vh-10rem)] max-w-[92vw] rounded-lg object-contain',
193
+ !motionReduced() && 'transition-transform duration-300 ease-out',
194
+ zoomed() ? 'scale-150 cursor-zoom-out' : 'cursor-zoom-in',
195
+ )}
196
+ />
197
+
198
+ <Show when={props.images.length > 1}>
199
+ <button
200
+ type="button"
201
+ aria-label="Next image"
202
+ disabled={index() === lastIndex()}
203
+ onClick={(e) => {
204
+ e.stopPropagation()
205
+ next()
206
+ }}
207
+ class="absolute right-2 z-10 grid h-10 w-10 place-items-center rounded-full bg-card/80 text-foreground transition hover:bg-muted disabled:pointer-events-none disabled:opacity-40 sm:right-4"
208
+ >
209
+ <ChevronRight class="h-6 w-6" />
210
+ </button>
211
+ </Show>
212
+ </div>
213
+
214
+ <div class="flex flex-col items-center gap-3 p-4" onClick={(e) => e.stopPropagation()}>
215
+ <div class="flex items-center gap-3">
216
+ <Show when={current()?.alt}>
217
+ <p class="text-center text-sm text-muted-foreground">{current()?.alt}</p>
218
+ </Show>
219
+ <button
220
+ type="button"
221
+ aria-label={zoomed() ? 'Zoom out' : 'Zoom in'}
222
+ aria-pressed={zoomed()}
223
+ onClick={() => setZoomed((z) => !z)}
224
+ class={cn(
225
+ 'grid h-8 w-8 shrink-0 place-items-center rounded-lg text-muted-foreground transition hover:bg-muted hover:text-foreground',
226
+ zoomed() && 'bg-muted text-foreground',
227
+ )}
228
+ >
229
+ <ZoomIn class="h-4 w-4" />
230
+ </button>
231
+ </div>
232
+
233
+ <Show when={showThumbnails() && props.images.length > 1}>
234
+ <div class="flex max-w-full gap-2 overflow-x-auto py-1">
235
+ <For each={props.images}>
236
+ {(image, i) => (
237
+ <button
238
+ type="button"
239
+ aria-label={image.alt ?? `Go to image ${i() + 1}`}
240
+ aria-current={index() === i() ? 'true' : undefined}
241
+ onClick={() => setIndex(i())}
242
+ class={cn(
243
+ 'h-14 w-14 shrink-0 overflow-hidden rounded-md border-2 transition',
244
+ index() === i()
245
+ ? 'border-primary'
246
+ : 'border-transparent opacity-60 hover:opacity-100',
247
+ )}
248
+ >
249
+ <img src={image.thumb ?? image.src} alt="" class="h-full w-full object-cover" />
250
+ </button>
251
+ )}
252
+ </For>
253
+ </div>
254
+ </Show>
255
+ </div>
256
+ </div>
257
+ </Portal>
258
+ </Show>
259
+ </div>
260
+ )
261
+ }
@@ -0,0 +1,119 @@
1
+ // AI model picker — dropdown trigger (icon + name + chevron) over a menu of
2
+ // models (icon + name + description + badge), selected one checkmarked.
3
+ // Built on Kobalte's DropdownMenu (same primitive as Dropdown.tsx), using its
4
+ // RadioGroup/RadioItem for single-select keyboard navigation and a11y
5
+ // (role="menuitemradio", aria-checked) — Dropdown.tsx's own item API only
6
+ // supports a plain string label, not this richer per-option content.
7
+ import { DropdownMenu } from '@kobalte/core/dropdown-menu'
8
+ import { Check, ChevronDown } from 'lucide-solid'
9
+ import type { JSX } from 'solid-js'
10
+ import { For, Show, createSignal, untrack } from 'solid-js'
11
+
12
+ import { cn } from '../lib/cn'
13
+
14
+ /** A single selectable model in a {@link ModelPicker} menu. */
15
+ export interface ModelOption {
16
+ id: string
17
+ name: string
18
+ icon?: JSX.Element
19
+ description?: string
20
+ badge?: JSX.Element
21
+ }
22
+
23
+ export interface ModelPickerProps {
24
+ models: ModelOption[]
25
+ /** Controlled selected model id. Omit and use `defaultValue` for uncontrolled use. */
26
+ value?: string
27
+ /** Initial selected model id when uncontrolled. Ignored if `value` is passed. */
28
+ defaultValue?: string
29
+ onChange?: (id: string) => void
30
+ class?: string
31
+ }
32
+
33
+ /**
34
+ * Dropdown trigger showing the selected model's icon + name + a chevron; the
35
+ * menu lists every model with icon, name, description, and optional badge,
36
+ * checkmarking the current selection. Controlled via `value`/`onChange` or
37
+ * uncontrolled via `defaultValue`, and fully keyboard-navigable (arrow keys,
38
+ * typeahead, Escape) via Kobalte's `DropdownMenu`.
39
+ *
40
+ * @example
41
+ * ```tsx
42
+ * <ModelPicker
43
+ * models={[
44
+ * { id: 'sonnet', name: 'Claude Sonnet', description: 'Balanced speed and quality' },
45
+ * { id: 'opus', name: 'Claude Opus', description: 'Most capable', badge: <Badge tone="info">New</Badge> },
46
+ * ]}
47
+ * defaultValue="sonnet"
48
+ * onChange={(id) => console.log('selected', id)}
49
+ * />
50
+ * ```
51
+ */
52
+ export function ModelPicker(props: ModelPickerProps): JSX.Element {
53
+ // Initial value only: read once (untracked), by design — this seeds the
54
+ // uncontrolled fallback and must not re-run when `models`/`defaultValue` change later.
55
+ const [internal, setInternal] = createSignal(untrack(() => props.defaultValue ?? props.models[0]?.id))
56
+ const selectedId = () => props.value ?? internal()
57
+ const selectedModel = () => props.models.find((m) => m.id === selectedId())
58
+
59
+ const handleChange = (id: string) => {
60
+ if (props.value === undefined) setInternal(id)
61
+ props.onChange?.(id)
62
+ }
63
+
64
+ return (
65
+ <DropdownMenu preventScroll={false}>
66
+ <DropdownMenu.Trigger
67
+ class={cn(
68
+ 'inline-flex items-center gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground outline-none transition-colors a4-field',
69
+ props.class,
70
+ )}
71
+ aria-label="Select model"
72
+ >
73
+ <Show when={selectedModel()?.icon}>
74
+ <span class="flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground">
75
+ {selectedModel()?.icon}
76
+ </span>
77
+ </Show>
78
+ <span class="truncate font-medium">{selectedModel()?.name}</span>
79
+ <ChevronDown class="h-4 w-4 shrink-0 text-muted-foreground" />
80
+ </DropdownMenu.Trigger>
81
+ <DropdownMenu.Portal>
82
+ <DropdownMenu.Content class="z-50 min-w-64 overflow-hidden rounded-md border border-border bg-card p-1 text-card-foreground shadow-sm">
83
+ <DropdownMenu.RadioGroup value={selectedId()} onChange={handleChange}>
84
+ <For each={props.models}>
85
+ {(model) => (
86
+ <DropdownMenu.RadioItem
87
+ value={model.id}
88
+ class="flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 outline-none hover:bg-muted data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
89
+ >
90
+ <Show when={model.icon}>
91
+ <span class="flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground">
92
+ {model.icon}
93
+ </span>
94
+ </Show>
95
+ <span class="flex min-w-0 flex-1 flex-col">
96
+ <DropdownMenu.ItemLabel class="truncate text-sm text-foreground">
97
+ {model.name}
98
+ </DropdownMenu.ItemLabel>
99
+ <Show when={model.description}>
100
+ <DropdownMenu.ItemDescription class="truncate text-xs text-muted-foreground">
101
+ {model.description}
102
+ </DropdownMenu.ItemDescription>
103
+ </Show>
104
+ </span>
105
+ <Show when={model.badge}>{model.badge}</Show>
106
+ <span class="flex h-4 w-4 shrink-0 items-center justify-center text-primary">
107
+ <Show when={model.id === selectedId()}>
108
+ <Check class="h-4 w-4" />
109
+ </Show>
110
+ </span>
111
+ </DropdownMenu.RadioItem>
112
+ )}
113
+ </For>
114
+ </DropdownMenu.RadioGroup>
115
+ </DropdownMenu.Content>
116
+ </DropdownMenu.Portal>
117
+ </DropdownMenu>
118
+ )
119
+ }
@@ -0,0 +1,163 @@
1
+ // Card-shaped step list for onboarding/setup flows. Completion state is
2
+ // controlled by the caller (`steps[].done`) but each step also tracks its own
3
+ // expanded/collapsed description locally, following the Collapse idiom.
4
+ import { Check } from 'lucide-solid'
5
+ import type { JSX } from 'solid-js'
6
+ import { createSignal, For, Show } from 'solid-js'
7
+
8
+ import { cn } from '../lib/cn'
9
+ import { Button } from './Button'
10
+ import { RingProgress } from './RingProgress'
11
+
12
+ /** A single step in an {@link OnboardingChecklist}. */
13
+ export interface OnboardingStep {
14
+ /** Stable identifier, used as the toggle key and `For` item key. */
15
+ id: string
16
+ title: JSX.Element
17
+ /** Optional detail shown when the step is expanded. */
18
+ description?: JSX.Element
19
+ /** Whether the step is complete. */
20
+ done?: boolean
21
+ /** Optional call-to-action rendered alongside the description. */
22
+ action?: { label: string; onClick: () => void }
23
+ }
24
+
25
+ export interface OnboardingChecklistProps {
26
+ steps: OnboardingStep[]
27
+ /** Called with the step id and its next `done` state when the indicator is toggled. */
28
+ onToggle?: (id: string, done: boolean) => void
29
+ /** Heading shown above the progress summary. Defaults to `'Getting started'`. */
30
+ title?: JSX.Element
31
+ class?: string
32
+ }
33
+
34
+ /**
35
+ * Card-shaped onboarding/setup checklist: a header with a completion ring and
36
+ * "{done} of {total} complete" summary, followed by expandable steps. Each
37
+ * step has a clickable check indicator (toggles `done` via `onToggle`), a
38
+ * title, and an optional description + CTA revealed on expand. Completion is
39
+ * controlled by the caller; expansion is local UI state (one step open at a time).
40
+ *
41
+ * @example
42
+ * ```tsx
43
+ * const [steps, setSteps] = createSignal<OnboardingStep[]>([
44
+ * { id: 'profile', title: 'Complete your profile', done: true },
45
+ * {
46
+ * id: 'invite',
47
+ * title: 'Invite a teammate',
48
+ * description: 'Collaborate faster by adding at least one teammate.',
49
+ * action: { label: 'Invite', onClick: () => openInviteDialog() },
50
+ * },
51
+ * ])
52
+ * <OnboardingChecklist
53
+ * steps={steps()}
54
+ * onToggle={(id, done) =>
55
+ * setSteps((prev) => prev.map((s) => (s.id === id ? { ...s, done } : s)))
56
+ * }
57
+ * />
58
+ * ```
59
+ */
60
+ export function OnboardingChecklist(props: OnboardingChecklistProps): JSX.Element {
61
+ const [expanded, setExpanded] = createSignal<string | null>(null)
62
+
63
+ const total = () => props.steps.length
64
+ const done = () => props.steps.filter((step) => step.done).length
65
+ const percent = () => (total() === 0 ? 0 : (done() / total()) * 100)
66
+
67
+ const toggleDone = (step: OnboardingStep): void => {
68
+ props.onToggle?.(step.id, !step.done)
69
+ }
70
+
71
+ const toggleExpanded = (id: string): void => {
72
+ setExpanded((prev) => (prev === id ? null : id))
73
+ }
74
+
75
+ return (
76
+ <div class={cn('rounded-xl border border-border bg-card text-card-foreground', props.class)}>
77
+ <div class="flex items-center gap-4 border-b border-border p-4">
78
+ <RingProgress
79
+ value={percent()}
80
+ size={56}
81
+ thickness={6}
82
+ label={
83
+ <span class="text-xs">
84
+ {done()}/{total()}
85
+ </span>
86
+ }
87
+ />
88
+ <div>
89
+ <p class="font-semibold text-foreground">{props.title ?? 'Getting started'}</p>
90
+ <p class="text-sm text-muted-foreground">
91
+ {done()} of {total()} complete
92
+ </p>
93
+ </div>
94
+ </div>
95
+ <ul role="list" class="divide-y divide-border">
96
+ <For each={props.steps}>
97
+ {(step) => {
98
+ const isExpanded = () => expanded() === step.id
99
+ const hasDetail = () => step.description !== undefined || step.action !== undefined
100
+ return (
101
+ <li class="p-4">
102
+ <div class="flex items-start gap-3">
103
+ <button
104
+ type="button"
105
+ role="checkbox"
106
+ aria-checked={!!step.done}
107
+ aria-label={step.done ? 'Mark step as not done' : 'Mark step as done'}
108
+ onClick={() => toggleDone(step)}
109
+ class={cn(
110
+ 'mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border transition-colors',
111
+ step.done
112
+ ? 'border-emerald-500 bg-emerald-500 text-white'
113
+ : 'border-input bg-background text-transparent hover:border-emerald-500/60',
114
+ )}
115
+ >
116
+ <Check class="h-3.5 w-3.5" />
117
+ </button>
118
+ <button
119
+ type="button"
120
+ class="min-w-0 flex-1 text-left"
121
+ aria-expanded={hasDetail() ? isExpanded() : undefined}
122
+ disabled={!hasDetail()}
123
+ onClick={() => hasDetail() && toggleExpanded(step.id)}
124
+ >
125
+ <span
126
+ class={cn(
127
+ 'block text-sm font-medium',
128
+ step.done ? 'text-muted-foreground line-through' : 'text-foreground',
129
+ )}
130
+ >
131
+ {step.title}
132
+ </span>
133
+ </button>
134
+ </div>
135
+ <Show when={hasDetail()}>
136
+ <div
137
+ class={cn(
138
+ 'grid transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none',
139
+ isExpanded() ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
140
+ )}
141
+ >
142
+ <div class="overflow-hidden">
143
+ <div class="mt-2 pl-8 text-sm text-muted-foreground">
144
+ <Show when={step.description}>{step.description}</Show>
145
+ <Show when={step.action}>
146
+ {(action) => (
147
+ <Button variant="outline" class="mt-2" onClick={() => action().onClick()}>
148
+ {action().label}
149
+ </Button>
150
+ )}
151
+ </Show>
152
+ </div>
153
+ </div>
154
+ </div>
155
+ </Show>
156
+ </li>
157
+ )
158
+ }}
159
+ </For>
160
+ </ul>
161
+ </div>
162
+ )
163
+ }
Binary file
@@ -0,0 +1,83 @@
1
+ // ReasoningTrace — collapsible "thinking" panel for AI surfaces. Mirrors
2
+ // Collapse.tsx's header/panel idiom (grid-rows transition, chevron rotate)
3
+ // but owns its own open state (seeded from `defaultOpen`) and adds a
4
+ // streaming pulse indicator for in-flight reasoning.
5
+ import { Brain, ChevronDown } from 'lucide-solid'
6
+ import { type JSX, Show, createSignal } from 'solid-js'
7
+
8
+ import { cn } from '../lib/cn'
9
+
10
+ export interface ReasoningTraceProps {
11
+ /** Reasoning body content; takes precedence over `text` when both are given. */
12
+ children?: JSX.Element
13
+ /** Plain-text reasoning body, rendered with preserved whitespace. */
14
+ text?: string
15
+ /** Header label. Defaults to `'Reasoning'`. */
16
+ label?: JSX.Element
17
+ /** Whether the panel starts expanded. Defaults to `false`. */
18
+ defaultOpen?: boolean
19
+ /** Show a pulsing dot in the header to indicate reasoning is still streaming. */
20
+ streaming?: boolean
21
+ class?: string
22
+ }
23
+
24
+ /**
25
+ * Collapsible panel for surfacing a model's intermediate "thinking"/reasoning
26
+ * trace. The header is a toggle button (brain icon + label + rotating
27
+ * chevron, plus a pulsing dot while `streaming`); the body is a muted,
28
+ * smaller-text block showing `text` or `children` with whitespace preserved.
29
+ * Collapsed by default unless `defaultOpen`. Height/opacity transition,
30
+ * instant under reduced motion.
31
+ *
32
+ * @example
33
+ * ```tsx
34
+ * <ReasoningTrace streaming text={reasoningSoFar()} />
35
+ * ```
36
+ */
37
+ export function ReasoningTrace(props: ReasoningTraceProps): JSX.Element {
38
+ const [open, setOpen] = createSignal(props.defaultOpen ?? false)
39
+
40
+ return (
41
+ <div class={cn('rounded-xl border border-border bg-card', props.class)}>
42
+ <button
43
+ type="button"
44
+ aria-expanded={open()}
45
+ onClick={() => setOpen(!open())}
46
+ class="flex w-full items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted"
47
+ >
48
+ <Brain class="h-4 w-4 shrink-0" aria-hidden="true" />
49
+ <span class="flex-1 text-left">{props.label ?? 'Reasoning'}</span>
50
+ <Show when={props.streaming}>
51
+ <span class="relative flex h-1.5 w-1.5 shrink-0" aria-hidden="true">
52
+ <span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-75 motion-reduce:animate-none" />
53
+ <span class="relative inline-flex h-1.5 w-1.5 rounded-full bg-current" />
54
+ </span>
55
+ </Show>
56
+ <ChevronDown
57
+ class={cn(
58
+ 'h-4 w-4 shrink-0 transition-transform duration-200 motion-reduce:transition-none',
59
+ open() && 'rotate-180',
60
+ )}
61
+ aria-hidden="true"
62
+ />
63
+ </button>
64
+ <div
65
+ class={cn(
66
+ 'grid transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none',
67
+ open() ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
68
+ )}
69
+ >
70
+ <div class="overflow-hidden">
71
+ <div
72
+ class={cn(
73
+ 'whitespace-pre-wrap px-3 pb-3 text-xs leading-relaxed text-muted-foreground transition-opacity duration-200 motion-reduce:transition-none',
74
+ open() ? 'opacity-100' : 'opacity-0',
75
+ )}
76
+ >
77
+ {props.children ?? props.text}
78
+ </div>
79
+ </div>
80
+ </div>
81
+ </div>
82
+ )
83
+ }