@a4ui/core 0.32.0 → 0.33.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.
@@ -0,0 +1,317 @@
1
+ // A dotted 3D globe rendered on a single <canvas> with plain Canvas 2D — no
2
+ // three.js, no WebGL. The sphere's dots come from a Fibonacci sphere
3
+ // (deterministic, evenly distributed — no Math.random so the dot field is
4
+ // stable across renders/SSR hydration). Each frame: rotate every point around
5
+ // the Y axis, project orthographically (drop z), keep only front-facing
6
+ // points (z > 0), and scale alpha/size by depth for a cheap "3D" read.
7
+ // Markers/arcs project through the same pipeline so they stay glued to the
8
+ // sphere as it spins. Colors are read from the live theme tokens via
9
+ // getComputedStyle (canvas can't consume `hsl(var(--x))` directly). Follows
10
+ // SpaceBackground's onMount/onCleanup idiom: bind listeners into a cleanups
11
+ // array, cancel the rAF, tear everything down on unmount.
12
+ import { createEffect, onCleanup, onMount, type JSX } from 'solid-js'
13
+
14
+ import { cn } from '../lib/cn'
15
+ import { motionReduced } from '../lib/motion'
16
+
17
+ export interface GlobeMarker {
18
+ lat: number
19
+ lng: number
20
+ label?: string
21
+ }
22
+
23
+ export interface GlobeArc {
24
+ /** [lat, lng] */
25
+ from: [number, number]
26
+ /** [lat, lng] */
27
+ to: [number, number]
28
+ }
29
+
30
+ export interface GlobeProps {
31
+ markers?: GlobeMarker[]
32
+ arcs?: GlobeArc[]
33
+ /** Canvas size in CSS px (square). @default 400 */
34
+ size?: number
35
+ /** Spin automatically. @default true (ignored under reduced motion). */
36
+ autoRotate?: boolean
37
+ class?: string
38
+ }
39
+
40
+ interface Vec3 {
41
+ x: number
42
+ y: number
43
+ z: number
44
+ }
45
+
46
+ interface Projected {
47
+ x: number
48
+ y: number
49
+ z: number
50
+ front: boolean
51
+ }
52
+
53
+ const DOT_COUNT = 900
54
+ const ROTATE_SPEED = 0.12 // rad/s
55
+ const DRAG_SPEED = 0.012 // rad per px of pointer movement
56
+
57
+ // Fibonacci sphere: evenly-spaced points on a unit sphere, fully deterministic
58
+ // (no RNG) so the dot field is stable across renders and reproducible in tests.
59
+ function fibonacciSphere(count: number): Vec3[] {
60
+ const points: Vec3[] = []
61
+ const goldenAngle = Math.PI * (3 - Math.sqrt(5))
62
+ for (let i = 0; i < count; i++) {
63
+ const y = 1 - (i / (count - 1)) * 2 // 1..-1
64
+ const radius = Math.sqrt(Math.max(0, 1 - y * y))
65
+ const theta = goldenAngle * i
66
+ points.push({ x: Math.cos(theta) * radius, y, z: Math.sin(theta) * radius })
67
+ }
68
+ return points
69
+ }
70
+
71
+ const SPHERE_POINTS = fibonacciSphere(DOT_COUNT)
72
+
73
+ function latLngToVec3(lat: number, lng: number): Vec3 {
74
+ const latRad = (lat * Math.PI) / 180
75
+ const lngRad = (lng * Math.PI) / 180
76
+ return {
77
+ x: Math.cos(latRad) * Math.sin(lngRad),
78
+ y: Math.sin(latRad),
79
+ z: Math.cos(latRad) * Math.cos(lngRad),
80
+ }
81
+ }
82
+
83
+ // Rotate around the Y axis and project orthographically to a `radius`-sized
84
+ // disc centered at (cx, cy). z stays in [-1, 1] (front-facing when > 0).
85
+ function project(p: Vec3, angle: number, radius: number, cx: number, cy: number): Projected {
86
+ const cos = Math.cos(angle)
87
+ const sin = Math.sin(angle)
88
+ const x = p.x * cos + p.z * sin
89
+ const z = -p.x * sin + p.z * cos
90
+ const y = p.y
91
+ return { x: cx + x * radius, y: cy - y * radius, z, front: z > 0 }
92
+ }
93
+
94
+ function resolveColor(canvas: HTMLCanvasElement, token: string, fallback: string): string {
95
+ const raw = getComputedStyle(document.documentElement).getPropertyValue(token).trim()
96
+ return raw ? `hsl(${raw})` : fallback
97
+ }
98
+
99
+ /**
100
+ * A dotted 3D globe drawn on `<canvas>` with plain Canvas 2D (no three.js,
101
+ * no WebGL): dots come from a deterministic Fibonacci-sphere distribution,
102
+ * rotate around the Y axis, and project orthographically with depth-scaled
103
+ * alpha/size. `markers` render as brighter glowing dots (title = label);
104
+ * `arcs` draw as outward-bulging curves between two front-facing markers,
105
+ * with a small traveling highlight. Auto-rotates unless `autoRotate={false}`;
106
+ * drag with the pointer to spin manually. Renders a single static frame
107
+ * under `motionReduced()` (no auto-rotation; drag still works).
108
+ *
109
+ * @example
110
+ * ```tsx
111
+ * <Globe
112
+ * size={360}
113
+ * markers={[
114
+ * { lat: 40.7, lng: -74.0, label: 'New York' },
115
+ * { lat: 35.7, lng: 139.7, label: 'Tokyo' },
116
+ * ]}
117
+ * arcs={[{ from: [40.7, -74.0], to: [35.7, 139.7] }]}
118
+ * />
119
+ * ```
120
+ */
121
+ export function Globe(props: GlobeProps): JSX.Element {
122
+ let canvas!: HTMLCanvasElement
123
+ const size = () => props.size ?? 400
124
+
125
+ onMount(() => {
126
+ const ctx = canvas.getContext('2d')
127
+ if (!ctx) return
128
+
129
+ const cleanups: Array<() => void> = []
130
+ const bind = (
131
+ target: EventTarget,
132
+ type: string,
133
+ handler: EventListener,
134
+ opts?: AddEventListenerOptions,
135
+ ) => {
136
+ target.addEventListener(type, handler, opts)
137
+ cleanups.push(() => target.removeEventListener(type, handler, opts))
138
+ }
139
+
140
+ let angle = 0
141
+ let dragging = false
142
+ let lastX = 0
143
+ let rafId = 0
144
+ let lastTs = 0
145
+ // Screen-space marker positions from the last draw, for hover hit-testing
146
+ // (cheap: only a handful of markers, refreshed every frame anyway).
147
+ let markerHits: Array<{ x: number; y: number; label: string }> = []
148
+
149
+ const draw = (): void => {
150
+ const dpr = window.devicePixelRatio || 1
151
+ const cssSize = size()
152
+ const radius = cssSize * 0.42
153
+ const cx = cssSize / 2
154
+ const cy = cssSize / 2
155
+
156
+ if (canvas.width !== cssSize * dpr || canvas.height !== cssSize * dpr) {
157
+ canvas.width = cssSize * dpr
158
+ canvas.height = cssSize * dpr
159
+ canvas.style.width = `${cssSize}px`
160
+ canvas.style.height = `${cssSize}px`
161
+ }
162
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
163
+ ctx.clearRect(0, 0, cssSize, cssSize)
164
+
165
+ const dotColor = resolveColor(canvas, '--foreground', '210 20% 90%')
166
+ const markerColor = resolveColor(canvas, '--accent', '200 90% 60%')
167
+ const arcColor = resolveColor(canvas, '--primary', '250 80% 65%')
168
+
169
+ // Base sphere dots, back-to-front isn't needed (no overlap testing) —
170
+ // just skip anything facing away from the viewer.
171
+ for (const p of SPHERE_POINTS) {
172
+ const proj = project(p, angle, radius, cx, cy)
173
+ if (!proj.front) continue
174
+ const depth = proj.z // 0..1
175
+ ctx.beginPath()
176
+ ctx.fillStyle = dotColor
177
+ ctx.globalAlpha = 0.15 + depth * 0.55
178
+ ctx.arc(proj.x, proj.y, 0.6 + depth * 1.2, 0, Math.PI * 2)
179
+ ctx.fill()
180
+ }
181
+ ctx.globalAlpha = 1
182
+
183
+ // Arcs: only drawn when both endpoints are currently front-facing.
184
+ const arcs = props.arcs ?? []
185
+ for (const arc of arcs) {
186
+ const from = project(latLngToVec3(arc.from[0], arc.from[1]), angle, radius, cx, cy)
187
+ const to = project(latLngToVec3(arc.to[0], arc.to[1]), angle, radius, cx, cy)
188
+ if (!from.front || !to.front) continue
189
+
190
+ const mx = (from.x + to.x) / 2
191
+ const my = (from.y + to.y) / 2
192
+ // Bulge outward from the sphere center, proportional to the chord length.
193
+ const dx = mx - cx
194
+ const dy = my - cy
195
+ const dist = Math.hypot(dx, dy) || 1
196
+ const chord = Math.hypot(to.x - from.x, to.y - from.y)
197
+ const bulge = chord * 0.35
198
+ const ctrlX = mx + (dx / dist) * bulge
199
+ const ctrlY = my + (dy / dist) * bulge
200
+
201
+ ctx.beginPath()
202
+ ctx.strokeStyle = arcColor
203
+ ctx.globalAlpha = 0.55
204
+ ctx.lineWidth = 1.4
205
+ ctx.moveTo(from.x, from.y)
206
+ ctx.quadraticCurveTo(ctrlX, ctrlY, to.x, to.y)
207
+ ctx.stroke()
208
+ ctx.globalAlpha = 1
209
+
210
+ // Traveling highlight: a bright dot eased along the same quadratic,
211
+ // looping with the wall clock so it doesn't need its own rAF state.
212
+ if (!motionReduced()) {
213
+ const t = (performance.now() / 1600) % 1
214
+ const it = 1 - t
215
+ const hx = it * it * from.x + 2 * it * t * ctrlX + t * t * to.x
216
+ const hy = it * it * from.y + 2 * it * t * ctrlY + t * t * to.y
217
+ ctx.beginPath()
218
+ ctx.fillStyle = markerColor
219
+ ctx.arc(hx, hy, 2.4, 0, Math.PI * 2)
220
+ ctx.fill()
221
+ }
222
+ }
223
+
224
+ // Markers: brighter glowing dots, only when front-facing.
225
+ const markers = props.markers ?? []
226
+ const hits: typeof markerHits = []
227
+ for (const marker of markers) {
228
+ const proj = project(latLngToVec3(marker.lat, marker.lng), angle, radius, cx, cy)
229
+ if (!proj.front) continue
230
+ ctx.beginPath()
231
+ ctx.fillStyle = markerColor
232
+ ctx.shadowColor = markerColor
233
+ ctx.shadowBlur = 8
234
+ ctx.arc(proj.x, proj.y, 3, 0, Math.PI * 2)
235
+ ctx.fill()
236
+ ctx.shadowBlur = 0
237
+ if (marker.label) hits.push({ x: proj.x, y: proj.y, label: marker.label })
238
+ }
239
+ markerHits = hits
240
+ }
241
+
242
+ const tick = (ts: number): void => {
243
+ const dt = lastTs ? (ts - lastTs) / 1000 : 0
244
+ lastTs = ts
245
+ if (!dragging && !motionReduced() && props.autoRotate !== false) {
246
+ angle += ROTATE_SPEED * dt
247
+ }
248
+ draw()
249
+ // Keep animating while auto-rotating, or to refresh the traveling arc
250
+ // highlight — both are no-ops under reduced motion (frozen frame).
251
+ if (!motionReduced()) rafId = requestAnimationFrame(tick)
252
+ }
253
+
254
+ if (motionReduced()) {
255
+ draw() // single static frame
256
+ } else {
257
+ rafId = requestAnimationFrame(tick)
258
+ }
259
+
260
+ // Pointer drag to spin manually — works even under reduced motion.
261
+ const onPointerDown = (e: PointerEvent): void => {
262
+ dragging = true
263
+ lastX = e.clientX
264
+ canvas.setPointerCapture(e.pointerId)
265
+ }
266
+ const onPointerMove = (e: PointerEvent): void => {
267
+ if (!dragging) {
268
+ // Hover hit-test against the last drawn marker positions → native tooltip.
269
+ const rect = canvas.getBoundingClientRect()
270
+ const px = e.clientX - rect.left
271
+ const py = e.clientY - rect.top
272
+ const hit = markerHits.find((m) => Math.hypot(m.x - px, m.y - py) <= 6)
273
+ canvas.title = hit?.label ?? ''
274
+ return
275
+ }
276
+ const dx = e.clientX - lastX
277
+ lastX = e.clientX
278
+ angle += dx * DRAG_SPEED
279
+ if (motionReduced()) draw() // rAF loop is off; redraw explicitly per move
280
+ }
281
+ const onPointerUp = (e: PointerEvent): void => {
282
+ dragging = false
283
+ if (canvas.hasPointerCapture(e.pointerId)) canvas.releasePointerCapture(e.pointerId)
284
+ }
285
+ bind(canvas, 'pointerdown', onPointerDown as EventListener)
286
+ bind(canvas, 'pointermove', onPointerMove as EventListener)
287
+ bind(canvas, 'pointerup', onPointerUp as EventListener)
288
+ bind(canvas, 'pointercancel', onPointerUp as EventListener)
289
+
290
+ // Re-render (static frame) if props change while reduced motion holds the
291
+ // loop frozen — otherwise the rAF loop already picks up prop changes.
292
+ createEffect(() => {
293
+ // Track reactive deps: size + reference identity of markers/arcs.
294
+ void size()
295
+ void props.markers
296
+ void props.arcs
297
+ void props.autoRotate
298
+ if (motionReduced()) draw()
299
+ })
300
+
301
+ onCleanup(() => {
302
+ if (rafId) cancelAnimationFrame(rafId)
303
+ cleanups.forEach((fn) => fn())
304
+ })
305
+ })
306
+
307
+ return (
308
+ <canvas
309
+ ref={canvas}
310
+ class={cn('touch-none rounded-full select-none', props.class)}
311
+ width={size()}
312
+ height={size()}
313
+ role="img"
314
+ aria-label="Interactive globe"
315
+ />
316
+ )
317
+ }
@@ -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,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
+ }
@@ -0,0 +1,64 @@
1
+ // Single inline code block with a copy button, for a one-liner or short
2
+ // excerpt embedded in prose. Lighter than CodeTabs (which is a card with tabs
3
+ // for several samples) — Snippet is just one block, no chrome besides the
4
+ // copy affordance and an optional language tag.
5
+ import { Check, Copy } from 'lucide-solid'
6
+ import type { JSX } from 'solid-js'
7
+ import { createSignal, Show } from 'solid-js'
8
+
9
+ import { cn } from '../lib/cn'
10
+
11
+ const COPIED_TIMEOUT_MS = 1500
12
+
13
+ export interface SnippetProps {
14
+ /** Source text to display and copy. */
15
+ code: string
16
+ /** Optional language tag shown as a small badge, e.g. `'tsx'`. Informational only, no highlighting. */
17
+ language?: string
18
+ class?: string
19
+ }
20
+
21
+ /**
22
+ * Single inline code block (mono, bordered, scrollable) with a copy button
23
+ * that morphs into a checkmark for 1.5s after copying. For one code sample —
24
+ * use {@link CodeTabs} instead when you need several tabs/languages.
25
+ *
26
+ * @example
27
+ * ```tsx
28
+ * <Snippet language="bash" code="pnpm add @a4ui/core" />
29
+ * ```
30
+ */
31
+ export function Snippet(props: SnippetProps): JSX.Element {
32
+ const [copied, setCopied] = createSignal(false)
33
+
34
+ const copy = () => {
35
+ if (!navigator.clipboard) return
36
+ navigator.clipboard.writeText(props.code).then(() => {
37
+ setCopied(true)
38
+ setTimeout(() => setCopied(false), COPIED_TIMEOUT_MS)
39
+ })
40
+ }
41
+
42
+ return (
43
+ <div class={cn('relative rounded-lg border border-border bg-muted px-3 py-2', props.class)}>
44
+ <Show when={props.language}>
45
+ <span class="absolute right-9 top-2 rounded bg-card px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
46
+ {props.language}
47
+ </span>
48
+ </Show>
49
+ <button
50
+ type="button"
51
+ aria-label={copied() ? 'Copied' : 'Copy to clipboard'}
52
+ class="absolute right-2 top-2 inline-flex h-5 w-5 items-center justify-center rounded text-muted-foreground hover:text-foreground"
53
+ onClick={copy}
54
+ >
55
+ <Show when={copied()} fallback={<Copy class="h-3.5 w-3.5" />}>
56
+ <Check class="h-3.5 w-3.5" />
57
+ </Show>
58
+ </button>
59
+ <pre class="overflow-x-auto whitespace-pre pr-8 font-mono text-sm text-foreground">
60
+ <code>{props.code}</code>
61
+ </pre>
62
+ </div>
63
+ )
64
+ }
@@ -0,0 +1,65 @@
1
+ // Wrapping row of dismissible/selectable pill chips (e.g. AI follow-up
2
+ // suggestions). Plain buttons in a flex-wrap row — no Kobalte primitive
3
+ // covers this shape, so it's hand-rolled like AnnouncementBar's tag chips.
4
+ import { X } from 'lucide-solid'
5
+ import type { JSX } from 'solid-js'
6
+ import { For, Show } from 'solid-js'
7
+
8
+ import { cn } from '../lib/cn'
9
+
10
+ export interface SuggestionChipsProps {
11
+ suggestions: string[]
12
+ onSelect?: (s: string) => void
13
+ onDismiss?: (s: string) => void
14
+ class?: string
15
+ }
16
+
17
+ /**
18
+ * Wrapping row of pill chips, e.g. AI-suggested follow-up prompts. Clicking a
19
+ * chip calls `onSelect`; if `onDismiss` is passed, each chip also shows a
20
+ * small "x" that removes it without triggering `onSelect`. The select and
21
+ * dismiss controls are real sibling `<button>`s (not nested — a `<button>`
22
+ * can't validly contain another interactive element), so both are reachable
23
+ * and operable independently via Tab/Enter/Space with no extra ARIA wiring.
24
+ *
25
+ * @example
26
+ * ```tsx
27
+ * <SuggestionChips
28
+ * suggestions={['Summarize this', 'Explain like I\'m 5', 'Write tests']}
29
+ * onSelect={(s) => sendMessage(s)}
30
+ * onDismiss={(s) => setSuggestions((prev) => prev.filter((x) => x !== s))}
31
+ * />
32
+ * ```
33
+ */
34
+ export function SuggestionChips(props: SuggestionChipsProps): JSX.Element {
35
+ return (
36
+ <div class={cn('flex flex-wrap gap-2', props.class)}>
37
+ <For each={props.suggestions}>
38
+ {(suggestion) => (
39
+ <span class="inline-flex items-center gap-1 rounded-full border border-border pl-3 pr-1.5 py-1.5 text-sm text-foreground transition-colors hover:bg-muted">
40
+ <button
41
+ type="button"
42
+ onClick={() => props.onSelect?.(suggestion)}
43
+ class="outline-none focus-visible:ring-2 focus-visible:ring-ring/30 rounded-sm"
44
+ >
45
+ {suggestion}
46
+ </button>
47
+ <Show when={props.onDismiss}>
48
+ <button
49
+ type="button"
50
+ aria-label={`Dismiss "${suggestion}"`}
51
+ onClick={(ev) => {
52
+ ev.stopPropagation()
53
+ props.onDismiss?.(suggestion)
54
+ }}
55
+ class="rounded-full p-0.5 text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/30"
56
+ >
57
+ <X class="h-3 w-3" />
58
+ </button>
59
+ </Show>
60
+ </span>
61
+ )}
62
+ </For>
63
+ </div>
64
+ )
65
+ }