@a4ui/core 0.24.2 → 0.25.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,17 @@
1
+ /**
2
+ * Holds an optimistic value that's updated immediately on `run`, then
3
+ * reconciled with the resolved (or rolled back on rejected) async result.
4
+ *
5
+ * @example
6
+ * ```ts
7
+ * const list = createOptimistic<Todo[]>([])
8
+ * async function addTodo(todo: Todo) {
9
+ * await list.run([...list.value(), todo], () => api.addTodo(todo))
10
+ * }
11
+ * ```
12
+ */
13
+ export declare function createOptimistic<T>(initial: T): {
14
+ value: () => T;
15
+ pending: () => boolean;
16
+ run: (optimistic: T, commit: () => Promise<T>) => Promise<T>;
17
+ };
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Run `update` inside a View Transition when the browser supports it, so DOM
3
+ * changes cross-fade automatically; otherwise just run `update` synchronously.
4
+ * Respects reduced-motion (skips the transition).
5
+ * @example
6
+ * startViewTransition(() => setRoute('details'))
7
+ */
8
+ export declare function startViewTransition(update: () => void): void;
@@ -0,0 +1,29 @@
1
+ import { JSX } from 'solid-js';
2
+ export interface ArtifactPanelProps {
3
+ open: boolean;
4
+ onClose?: () => void;
5
+ title?: string;
6
+ /** Panel width in px when open. @default 420 */
7
+ width?: number;
8
+ children: JSX.Element;
9
+ class?: string;
10
+ }
11
+ /**
12
+ * Right-side split panel that holds generated output (code, preview, doc) next
13
+ * to a chat — for AI UIs that keep the artifact and the conversation visible at
14
+ * once. Inline, not an overlay: place it as the last child of a flex row next
15
+ * to the main content. Slides open/closed by animating width (and a matching
16
+ * fade/slide on its content), reduced-motion aware via `motionReduced()`.
17
+ *
18
+ * @example
19
+ * ```tsx
20
+ * const [open, setOpen] = createSignal(false)
21
+ * <div class="flex h-full">
22
+ * <main class="min-w-0 flex-1">…chat…</main>
23
+ * <ArtifactPanel open={open()} onClose={() => setOpen(false)} title="script.py">
24
+ * <pre class="p-4 text-sm">…generated code…</pre>
25
+ * </ArtifactPanel>
26
+ * </div>
27
+ * ```
28
+ */
29
+ export declare function ArtifactPanel(props: ArtifactPanelProps): JSX.Element;
@@ -0,0 +1,24 @@
1
+ import { JSX } from 'solid-js';
2
+ export interface ChatThreadProps {
3
+ children: JSX.Element;
4
+ /** Max width of the reading column, in px. Defaults to `768`. */
5
+ maxWidth?: number;
6
+ /** Auto-scroll to the newest message on mount and whenever children change. Defaults to `true`. */
7
+ stickToBottom?: boolean;
8
+ class?: string;
9
+ }
10
+ /**
11
+ * Scrollable, centered reading column that stacks chat messages. Layout-only:
12
+ * it renders whatever is passed as `children`, so pair it with your own
13
+ * message bubbles/components.
14
+ *
15
+ * @example
16
+ * ```tsx
17
+ * <ChatThread maxWidth={640}>
18
+ * <div>Hi! Ask me anything about your itinerary.</div>
19
+ * <div>What's the weather like in Rivertown this weekend?</div>
20
+ * <div>Sunny and mild, 18–22°C, light breeze on Saturday.</div>
21
+ * </ChatThread>
22
+ * ```
23
+ */
24
+ export declare function ChatThread(props: ChatThreadProps): JSX.Element;
@@ -0,0 +1,44 @@
1
+ import { JSX } from 'solid-js';
2
+ export interface CitationProps {
3
+ /** The source number shown in the chip, e.g. `1`. */
4
+ index: number;
5
+ /** Links out to the source when present (opens in a new tab). */
6
+ href?: string;
7
+ /** Shown as a native tooltip and used as the accessible name. */
8
+ title?: string;
9
+ class?: string;
10
+ }
11
+ /**
12
+ * Small inline chip citing a numbered source, meant to sit inside a sentence
13
+ * right after the claim it backs up.
14
+ *
15
+ * @example
16
+ * ```tsx
17
+ * <p>
18
+ * Glass surfaces use a translucent backdrop blur
19
+ * <Citation index={1} href="https://example.com/spec" title="Design spec" />.
20
+ * </p>
21
+ * ```
22
+ */
23
+ export declare function Citation(props: CitationProps): JSX.Element;
24
+ export interface SourceListProps {
25
+ sources: {
26
+ title: string;
27
+ href?: string;
28
+ }[];
29
+ class?: string;
30
+ }
31
+ /**
32
+ * Compact numbered "Sources" block matching the indices used by {@link Citation}.
33
+ *
34
+ * @example
35
+ * ```tsx
36
+ * <SourceList
37
+ * sources={[
38
+ * { title: 'Design spec', href: 'https://example.com/spec' },
39
+ * { title: 'Internal style guide' },
40
+ * ]}
41
+ * />
42
+ * ```
43
+ */
44
+ export declare function SourceList(props: SourceListProps): JSX.Element;
@@ -0,0 +1,26 @@
1
+ import { JSX } from 'solid-js';
2
+ export interface FloatingToolbarProps {
3
+ children: JSX.Element;
4
+ /** Which edge of the viewport the bar is anchored to. Defaults to `'bottom'`. */
5
+ position?: 'top' | 'bottom';
6
+ /** Shrink padding/scale and intensify the glass blur once scrolled past ~24px. Defaults to `true`. */
7
+ condenseOnScroll?: boolean;
8
+ class?: string;
9
+ }
10
+ /**
11
+ * Fixed, horizontally-centered glass toolbar that floats above the page
12
+ * content. When `condenseOnScroll` is on (the default), it tightens its
13
+ * padding and scales down slightly once the user scrolls past a small
14
+ * threshold, mimicking a "Liquid Glass" detached bar. Reduced-motion aware:
15
+ * the condense change applies instantly, without a transition, when
16
+ * {@link motionReduced} is true.
17
+ *
18
+ * @example
19
+ * ```tsx
20
+ * <FloatingToolbar position="bottom">
21
+ * <IconButton label="Undo"><UndoIcon /></IconButton>
22
+ * <IconButton label="Redo"><RedoIcon /></IconButton>
23
+ * </FloatingToolbar>
24
+ * ```
25
+ */
26
+ export declare function FloatingToolbar(props: FloatingToolbarProps): JSX.Element;
@@ -0,0 +1,33 @@
1
+ import { JSX } from 'solid-js';
2
+ export interface InlineSelectOption {
3
+ value: string;
4
+ label: string;
5
+ }
6
+ export interface InlineSelectProps {
7
+ value: string;
8
+ options: InlineSelectOption[];
9
+ onChange: (value: string) => void;
10
+ placeholder?: string;
11
+ class?: string;
12
+ }
13
+ /**
14
+ * Edit-in-place select: renders the current value as a clickable chip until
15
+ * clicked, then swaps to a `Select` for picking a new value. Committing a
16
+ * choice returns it to text; blurring away or pressing Escape cancels back
17
+ * to the previous value. Built for status/priority-style editing inline in
18
+ * rows/tables.
19
+ *
20
+ * @example
21
+ * ```tsx
22
+ * <InlineSelect
23
+ * value={status()}
24
+ * options={[
25
+ * { value: 'todo', label: 'To do' },
26
+ * { value: 'doing', label: 'In progress' },
27
+ * { value: 'done', label: 'Done' },
28
+ * ]}
29
+ * onChange={setStatus}
30
+ * />
31
+ * ```
32
+ */
33
+ export declare function InlineSelect(props: InlineSelectProps): JSX.Element;
@@ -0,0 +1,28 @@
1
+ import { JSX } from 'solid-js';
2
+ /** Who sent a {@link Message}; drives its subtle visual treatment. */
3
+ export type ChatRole = 'user' | 'assistant' | 'system';
4
+ export interface MessageProps {
5
+ role: ChatRole;
6
+ children: JSX.Element;
7
+ /** Display name shown in the header line. Omitted for `system` messages. */
8
+ author?: string;
9
+ /** Small avatar node, e.g. an icon or `<Avatar/>`. Omitted for `system` messages. */
10
+ avatar?: JSX.Element;
11
+ /** Preformatted timestamp string, e.g. `'2:45 PM'`. */
12
+ timestamp?: string;
13
+ class?: string;
14
+ }
15
+ /**
16
+ * Full-width chat message row with no bubble: an optional avatar/author
17
+ * header line followed by a prose content block. Role is differentiated with
18
+ * subtle, token-based treatment only — `assistant` gets a faint muted panel,
19
+ * `user` stays plain, `system` is a small centered note.
20
+ *
21
+ * @example
22
+ * ```tsx
23
+ * <Message role="assistant" author="Nova" timestamp="2:45 PM">
24
+ * <p>Here's the summary you asked for.</p>
25
+ * </Message>
26
+ * ```
27
+ */
28
+ export declare function Message(props: MessageProps): JSX.Element;
@@ -0,0 +1,25 @@
1
+ import { JSX } from 'solid-js';
2
+ /** Transition style for {@link PageTransition}. `slide` adds a small vertical shift. */
3
+ export type PageTransitionMode = 'fade' | 'slide';
4
+ export interface PageTransitionProps {
5
+ /** Change this value (e.g. a route or view id) to trigger a transition. */
6
+ key: string | number;
7
+ children: JSX.Element;
8
+ /** `fade` = opacity only; `slide` = opacity + small vertical shift. @default 'fade' */
9
+ mode?: PageTransitionMode;
10
+ class?: string;
11
+ }
12
+ /**
13
+ * Cross-fades (or slides) its `children` out and in whenever `key` changes —
14
+ * for route/view transitions. Driven purely by the `key` prop; pair it with a
15
+ * router's current path or any view-switch signal. Swaps instantly under
16
+ * reduced motion.
17
+ *
18
+ * @example
19
+ * ```tsx
20
+ * <PageTransition key={activeTab()} mode="slide">
21
+ * <TabContent id={activeTab()} />
22
+ * </PageTransition>
23
+ * ```
24
+ */
25
+ export declare function PageTransition(props: PageTransitionProps): JSX.Element;
@@ -0,0 +1,34 @@
1
+ import { JSX } from 'solid-js';
2
+ export interface PromptComposerProps {
3
+ value: string;
4
+ onInput: (value: string) => void;
5
+ onSubmit: (value: string) => void;
6
+ placeholder?: string;
7
+ disabled?: boolean;
8
+ /** Small helper text under the field, e.g. model name or a character count. */
9
+ hint?: JSX.Element;
10
+ /** Attachment labels/filenames shown as removable chips above the input. */
11
+ attachments?: string[];
12
+ onRemoveAttachment?: (index: number) => void;
13
+ /** When set, shows a paperclip button that calls this on click. */
14
+ onAttach?: () => void;
15
+ class?: string;
16
+ }
17
+ /**
18
+ * The input area for a chat/AI UI: an auto-growing textarea inside a glass
19
+ * card, removable attachment chips, and a bottom bar with an optional attach
20
+ * button and a Send button. Fully controlled — the parent owns `value`.
21
+ * Submits on **Cmd/Ctrl+Enter** or the Send button click.
22
+ *
23
+ * @example
24
+ * ```tsx
25
+ * const [value, setValue] = createSignal('')
26
+ * <PromptComposer
27
+ * value={value()}
28
+ * onInput={setValue}
29
+ * onSubmit={(v) => { sendMessage(v); setValue('') }}
30
+ * placeholder="Ask anything…"
31
+ * />
32
+ * ```
33
+ */
34
+ export declare function PromptComposer(props: PromptComposerProps): JSX.Element;
@@ -0,0 +1,26 @@
1
+ import { JSX } from 'solid-js';
2
+ export interface StreamingTextProps {
3
+ /** The (possibly growing) full text so far. */
4
+ text: string;
5
+ /** Whether more text is still coming; shows the blinking caret while `true`. @default true */
6
+ streaming?: boolean;
7
+ /** Reveal speed, in characters per second, for newly appended text. @default 120 */
8
+ speed?: number;
9
+ class?: string;
10
+ }
11
+ /**
12
+ * Reveals `text` as it "streams" in: when `text` grows, the newly appended
13
+ * characters ease in at `speed` chars/sec instead of snapping into place,
14
+ * with a blinking caret while `streaming`. Already-revealed characters never
15
+ * re-animate — only the new tail is eased in, so appending to `text` doesn't
16
+ * replay the whole reveal. Skips the reveal (shows the full text at once)
17
+ * under reduced motion — the caret still blinks while `streaming` in that
18
+ * case. `streaming={false}` shows the full text immediately with no caret,
19
+ * for the final/settled answer.
20
+ *
21
+ * @example
22
+ * ```tsx
23
+ * <StreamingText text={answer()} streaming={!done()} />
24
+ * ```
25
+ */
26
+ export declare function StreamingText(props: StreamingTextProps): JSX.Element;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a4ui/core",
3
- "version": "0.24.2",
3
+ "version": "0.25.0",
4
4
  "description": "A4ui — Spatial Glass design system & component library for SolidJS (Kobalte behavior + Tailwind glass tokens + motion).",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/preset.js CHANGED
@@ -21,7 +21,12 @@ import plugin from 'tailwindcss/plugin.js'
21
21
  // Frosted "space glass" surfaces. addComponents so they tree-shake like any
22
22
  // utility (emitted only when the class is found in scanned content — a4ui's own
23
23
  // components use them, so scanning ./node_modules/@a4ui/core/dist covers it).
24
- const glass = plugin(({ addComponents }) => {
24
+ const glass = plugin(({ addComponents, addVariant }) => {
25
+ // `light:` applies when an ancestor is in the light theme (`[data-theme='light']`),
26
+ // mirroring how dark is the base and light is the override throughout A4ui. Used
27
+ // for theme-aware tints (e.g. Badge tones) that need a lighter foreground on dark
28
+ // and a darker one on light to keep WCAG AA in both.
29
+ addVariant('light', "[data-theme='light'] &")
25
30
  addComponents({
26
31
  // ---- Primary glass surface ----
27
32
  '.card': {
package/src/index.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  // import '@a4ui/core/styles.css'
9
9
  // import { Button, Card, Modal } from '@a4ui/core'
10
10
 
11
- export const A4UI_VERSION = '0.24.2'
11
+ export const A4UI_VERSION = '0.25.0'
12
12
 
13
13
  // Helpers (src/lib) — generic, framework-level utilities.
14
14
  export { cn } from './lib/cn'
@@ -33,6 +33,8 @@ export {
33
33
  } from './lib/motion'
34
34
  export { useMediaQuery } from './lib/media'
35
35
  export { remeasureAfterLayout } from './lib/virtual'
36
+ export { createOptimistic } from './lib/createOptimistic'
37
+ export { startViewTransition } from './lib/viewTransition'
36
38
 
37
39
  // UI components (src/ui) — all 18 extracted. See CLAUDE.md.
38
40
  export { Accordion, type AccordionItem } from './ui/Accordion'
@@ -145,6 +147,17 @@ export { Portal, type PortalProps } from './ui/Portal'
145
147
  export { Sortable, type SortableProps } from './ui/Sortable'
146
148
  export { Clock, type ClockProps } from './ui/Clock'
147
149
 
150
+ // Conversation / AI, transitions, and depth surfaces (roadmap phase 1).
151
+ export { ChatThread, type ChatThreadProps } from './ui/ChatThread'
152
+ export { Message, type MessageProps, type ChatRole } from './ui/Message'
153
+ export { StreamingText, type StreamingTextProps } from './ui/StreamingText'
154
+ export { Citation, type CitationProps, SourceList, type SourceListProps } from './ui/Citation'
155
+ export { PromptComposer, type PromptComposerProps } from './ui/PromptComposer'
156
+ export { ArtifactPanel, type ArtifactPanelProps } from './ui/ArtifactPanel'
157
+ export { FloatingToolbar, type FloatingToolbarProps } from './ui/FloatingToolbar'
158
+ export { PageTransition, type PageTransitionProps } from './ui/PageTransition'
159
+ export { InlineSelect, type InlineSelectProps, type InlineSelectOption } from './ui/InlineSelect'
160
+
148
161
  // Motion components (src/ui) — animation primitives built on the `motion` engine
149
162
  // (adapted from motion.dev examples). All tree-shakeable and reduced-motion aware;
150
163
  // `motion` is external, so importing one of these is the only thing that pulls it.
@@ -0,0 +1,43 @@
1
+ // Optimistic-update helper: show a value immediately, then reconcile with the
2
+ // real async result. On failure, rolls back to the previous settled value and
3
+ // rethrows so callers still see the error (e.g. to show a toast).
4
+ import { createSignal } from 'solid-js'
5
+
6
+ /**
7
+ * Holds an optimistic value that's updated immediately on `run`, then
8
+ * reconciled with the resolved (or rolled back on rejected) async result.
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * const list = createOptimistic<Todo[]>([])
13
+ * async function addTodo(todo: Todo) {
14
+ * await list.run([...list.value(), todo], () => api.addTodo(todo))
15
+ * }
16
+ * ```
17
+ */
18
+ export function createOptimistic<T>(initial: T): {
19
+ value: () => T
20
+ pending: () => boolean
21
+ run: (optimistic: T, commit: () => Promise<T>) => Promise<T>
22
+ } {
23
+ const [value, setValue] = createSignal(initial)
24
+ const [pending, setPending] = createSignal(false)
25
+
26
+ async function run(optimistic: T, commit: () => Promise<T>): Promise<T> {
27
+ const previous = value()
28
+ setValue(() => optimistic)
29
+ setPending(true)
30
+ try {
31
+ const resolved = await commit()
32
+ setValue(() => resolved)
33
+ return resolved
34
+ } catch (err) {
35
+ setValue(() => previous)
36
+ throw err
37
+ } finally {
38
+ setPending(false)
39
+ }
40
+ }
41
+
42
+ return { value, pending, run }
43
+ }
@@ -0,0 +1,32 @@
1
+ // Wrapper around the browser View Transitions API. Not every target browser
2
+ // implements `document.startViewTransition` yet, and even where it exists we
3
+ // want to skip it under reduced motion — this module centralizes both checks
4
+ // so callers don't have to feature-detect or re-import motionReduced themselves.
5
+ import { motionReduced } from './motion'
6
+
7
+ /** Minimal shape of `Document` augmented with the View Transitions API. */
8
+ interface DocumentWithViewTransitions {
9
+ startViewTransition: (callback: () => void) => unknown
10
+ }
11
+
12
+ function supportsViewTransitions(doc: Document): doc is Document & DocumentWithViewTransitions {
13
+ return (
14
+ 'startViewTransition' in doc &&
15
+ typeof (doc as unknown as DocumentWithViewTransitions).startViewTransition === 'function'
16
+ )
17
+ }
18
+
19
+ /**
20
+ * Run `update` inside a View Transition when the browser supports it, so DOM
21
+ * changes cross-fade automatically; otherwise just run `update` synchronously.
22
+ * Respects reduced-motion (skips the transition).
23
+ * @example
24
+ * startViewTransition(() => setRoute('details'))
25
+ */
26
+ export function startViewTransition(update: () => void): void {
27
+ if (typeof document === 'undefined' || motionReduced() || !supportsViewTransitions(document)) {
28
+ update()
29
+ return
30
+ }
31
+ document.startViewTransition(update)
32
+ }
@@ -0,0 +1,88 @@
1
+ // Inline (non-portalled) right-side panel for AI-generated output — code,
2
+ // preview, or doc — shown alongside a chat. Unlike `Drawer` (a portalled
3
+ // overlay on Kobalte's Dialog), the caller places this directly in a flex row
4
+ // next to the main content: it reserves no space when closed (width collapses
5
+ // to 0) and reveals by animating its own width, so the chat pane reflows next
6
+ // to it instead of being covered. We collapse to width 0 rather than toggling
7
+ // `display: none` because a `display` toggle jumps instantly — animating width
8
+ // is what makes it slide.
9
+ import { X } from 'lucide-solid'
10
+ import type { JSX } from 'solid-js'
11
+
12
+ import { cn } from '../lib/cn'
13
+ import { motionReduced } from '../lib/motion'
14
+
15
+ export interface ArtifactPanelProps {
16
+ open: boolean
17
+ onClose?: () => void
18
+ title?: string
19
+ /** Panel width in px when open. @default 420 */
20
+ width?: number
21
+ children: JSX.Element
22
+ class?: string
23
+ }
24
+
25
+ /**
26
+ * Right-side split panel that holds generated output (code, preview, doc) next
27
+ * to a chat — for AI UIs that keep the artifact and the conversation visible at
28
+ * once. Inline, not an overlay: place it as the last child of a flex row next
29
+ * to the main content. Slides open/closed by animating width (and a matching
30
+ * fade/slide on its content), reduced-motion aware via `motionReduced()`.
31
+ *
32
+ * @example
33
+ * ```tsx
34
+ * const [open, setOpen] = createSignal(false)
35
+ * <div class="flex h-full">
36
+ * <main class="min-w-0 flex-1">…chat…</main>
37
+ * <ArtifactPanel open={open()} onClose={() => setOpen(false)} title="script.py">
38
+ * <pre class="p-4 text-sm">…generated code…</pre>
39
+ * </ArtifactPanel>
40
+ * </div>
41
+ * ```
42
+ */
43
+ export function ArtifactPanel(props: ArtifactPanelProps): JSX.Element {
44
+ const width = () => props.width ?? 420
45
+ const reduced = () => motionReduced()
46
+
47
+ return (
48
+ <div
49
+ role="complementary"
50
+ aria-label={props.title ?? 'Artifact'}
51
+ aria-hidden={!props.open}
52
+ class={cn(
53
+ 'card h-full shrink-0 overflow-hidden border-border bg-glass',
54
+ props.open ? 'border-l' : 'border-0',
55
+ reduced() ? undefined : 'transition-[width] duration-300 ease-in-out',
56
+ props.class,
57
+ )}
58
+ style={{ width: `${props.open ? width() : 0}px` }}
59
+ >
60
+ {/* Fixed-width inner wrapper: the content never reflows/wraps while the
61
+ outer element's width animates between 0 and `width()`. */}
62
+ <div
63
+ class={cn(
64
+ 'flex h-full flex-col',
65
+ reduced() ? undefined : 'transition-[opacity,transform] duration-300 ease-in-out',
66
+ )}
67
+ style={{
68
+ width: `${width()}px`,
69
+ opacity: props.open ? 1 : 0,
70
+ transform: props.open ? 'translateX(0)' : 'translateX(16px)',
71
+ }}
72
+ >
73
+ <div class="sticky top-0 z-10 flex items-center justify-between border-b border-border bg-background/90 px-5 py-4 backdrop-blur">
74
+ <span class="truncate text-[16px] font-bold leading-tight">{props.title ?? 'Artifact'}</span>
75
+ <button
76
+ type="button"
77
+ onClick={() => props.onClose?.()}
78
+ class="grid h-9 w-9 shrink-0 place-items-center rounded-lg text-muted-foreground transition hover:bg-muted hover:text-foreground"
79
+ aria-label="Close"
80
+ >
81
+ <X class="h-5 w-5" />
82
+ </button>
83
+ </div>
84
+ <div class="min-h-0 flex-1 overflow-y-auto">{props.children}</div>
85
+ </div>
86
+ </div>
87
+ )
88
+ }
package/src/ui/Badge.tsx CHANGED
@@ -9,12 +9,15 @@ import { cn } from '../lib/cn'
9
9
  /** Semantic tone of a {@link Badge}; drives its background/text/ring color. */
10
10
  export type BadgeTone = 'neutral' | 'success' | 'warning' | 'danger' | 'info'
11
11
 
12
+ // Tinted tones carry a lighter foreground for the dark (base) theme and a darker
13
+ // one under `[data-theme='light']`, so the text meets WCAG AA over the translucent
14
+ // tint in both — a single fixed shade can't clear 4.5:1 on both backgrounds.
12
15
  const TONE_CLASSES: Record<BadgeTone, string> = {
13
16
  neutral: 'bg-muted text-muted-foreground ring-border',
14
- success: 'bg-emerald-500/15 text-emerald-600 ring-emerald-500/30',
15
- warning: 'bg-amber-500/15 text-amber-600 ring-amber-500/30',
16
- danger: 'bg-rose-500/15 text-rose-600 ring-rose-500/30',
17
- info: 'bg-sky-500/15 text-sky-600 ring-sky-500/30',
17
+ success: 'bg-emerald-500/15 text-emerald-300 ring-emerald-500/30 light:text-emerald-700',
18
+ warning: 'bg-amber-500/15 text-amber-300 ring-amber-500/30 light:text-amber-700',
19
+ danger: 'bg-rose-500/15 text-rose-300 ring-rose-500/30 light:text-rose-700',
20
+ info: 'bg-sky-500/15 text-sky-300 ring-sky-500/30 light:text-sky-700',
18
21
  }
19
22
 
20
23
  const BADGE_BASE =
@@ -0,0 +1,59 @@
1
+ // ChatThread — layout shell for AI/conversation UIs: a centered, scrollable
2
+ // reading column that stacks chat messages. Purely a container: it has no
3
+ // idea what a "message" looks like, so any element can be stacked inside it.
4
+ import { createEffect, onMount, type JSX } from 'solid-js'
5
+
6
+ import { cn } from '../lib/cn'
7
+ import { motionReduced } from '../lib/motion'
8
+
9
+ export interface ChatThreadProps {
10
+ children: JSX.Element
11
+ /** Max width of the reading column, in px. Defaults to `768`. */
12
+ maxWidth?: number
13
+ /** Auto-scroll to the newest message on mount and whenever children change. Defaults to `true`. */
14
+ stickToBottom?: boolean
15
+ class?: string
16
+ }
17
+
18
+ /**
19
+ * Scrollable, centered reading column that stacks chat messages. Layout-only:
20
+ * it renders whatever is passed as `children`, so pair it with your own
21
+ * message bubbles/components.
22
+ *
23
+ * @example
24
+ * ```tsx
25
+ * <ChatThread maxWidth={640}>
26
+ * <div>Hi! Ask me anything about your itinerary.</div>
27
+ * <div>What's the weather like in Rivertown this weekend?</div>
28
+ * <div>Sunny and mild, 18–22°C, light breeze on Saturday.</div>
29
+ * </ChatThread>
30
+ * ```
31
+ */
32
+ export function ChatThread(props: ChatThreadProps): JSX.Element {
33
+ let scrollEl: HTMLDivElement | undefined
34
+
35
+ const scrollToBottom = () => {
36
+ if (!scrollEl) return
37
+ scrollEl.scrollTo({ top: scrollEl.scrollHeight, behavior: motionReduced() ? 'auto' : 'smooth' })
38
+ }
39
+
40
+ onMount(() => {
41
+ if (props.stickToBottom ?? true) scrollToBottom()
42
+ })
43
+
44
+ createEffect(() => {
45
+ // Track children so newly appended messages pull the thread down.
46
+ void props.children
47
+ if (props.stickToBottom ?? true) queueMicrotask(scrollToBottom)
48
+ })
49
+
50
+ return (
51
+ <div
52
+ ref={scrollEl}
53
+ class={cn('mx-auto flex w-full flex-col gap-4 overflow-y-auto', props.class)}
54
+ style={{ 'max-width': `${props.maxWidth ?? 768}px` }}
55
+ >
56
+ {props.children}
57
+ </div>
58
+ )
59
+ }