cortena-ui 1.1.0 → 1.2.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,1113 @@
1
+ "use client";
2
+
3
+ /**
4
+ * The cortena-ui side of the A2UI catalogue.
5
+ *
6
+ * One React view per catalogue entry. Each is a real component, not a render
7
+ * function, so an interactive entry can hold hooks (draft input state, the
8
+ * debounce timer) without the renderer having to care which entries are
9
+ * stateful.
10
+ *
11
+ * Views receive already-resolved props: the renderer has read the data
12
+ * bindings, validated against the entry's zod schema and normalised actions
13
+ * before any of this runs. A view therefore never parses A2UI, and never sees
14
+ * an unvalidated value.
15
+ *
16
+ * Every visual value is a cortena-design token, as everywhere else in the
17
+ * package. The Tailwind scale maps below exist because Tailwind only emits a
18
+ * class it can see in the source, so `gap-${n}` would silently produce nothing.
19
+ */
20
+
21
+ import { AlertTriangle, CheckCircle2, Info, OctagonAlert, Terminal } from "lucide-react";
22
+ import * as React from "react";
23
+ import { Alert, AlertDescription, AlertIcon, AlertTitle } from "@/components/alert";
24
+ import { Badge } from "@/components/badge";
25
+ import { Button } from "@/components/button";
26
+ import { ButtonLink } from "@/components/button-link";
27
+ import { Card, CardContent } from "@/components/card";
28
+ import { Chart } from "@/components/chart";
29
+ import type { ChartDataFor, ChartType } from "@/components/chart";
30
+ import { Checkbox } from "@/components/checkbox";
31
+ import { DataTable } from "@/components/data-table";
32
+ import type { DataSource, DataTableColumnDef } from "@/components/data-table";
33
+ import { DateField, formatDateISO, parseDateISO } from "@/components/date-field";
34
+ import { EmptyState } from "@/components/empty-state";
35
+ import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/field";
36
+ import { Input } from "@/components/input";
37
+ import { Markdown } from "@/components/markdown";
38
+ import { Progress, ProgressLabel, ProgressValue } from "@/components/progress";
39
+ import { RadioGroup, RadioGroupItem } from "@/components/radio-group";
40
+ import { SectionCard } from "@/components/section-card";
41
+ import {
42
+ Select,
43
+ SelectContent,
44
+ SelectItem,
45
+ SelectTrigger,
46
+ SelectValue,
47
+ } from "@/components/select";
48
+ import { Separator } from "@/components/separator";
49
+ import { StatusDot } from "@/components/status-dot";
50
+ import { Switch } from "@/components/switch";
51
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/tabs";
52
+ import { Textarea } from "@/components/textarea";
53
+ import { cn } from "@/lib/cn";
54
+ import type { A2UIAction } from "./message";
55
+
56
+ /** A row in an agent-drawn table. Every value is opaque until a column reads it. */
57
+ export type A2UIRow = Record<string, unknown>;
58
+
59
+ /** Where a node sits, carried into every action the view emits. */
60
+ export interface A2UINodeInfo {
61
+ surfaceId: string;
62
+ componentId: string;
63
+ /** JSON pointer this node's relative bindings resolve against. */
64
+ dataContextPath: string;
65
+ }
66
+
67
+ /** A normalised action attached to an interactive property. */
68
+ export interface A2UIResolvedAction {
69
+ name: string;
70
+ context?: Record<string, unknown>;
71
+ }
72
+
73
+ /** What every view receives. */
74
+ export interface A2UIViewProps<P> {
75
+ props: P;
76
+ node: A2UINodeInfo;
77
+ /** Already-rendered child nodes, in the order the component listed them. */
78
+ children: React.ReactNode[];
79
+ /** The surface data model, for actions that report the whole form. */
80
+ dataModel: Record<string, unknown>;
81
+ /**
82
+ * Send an action. `action` is what the agent attached to the property; when
83
+ * it is absent the event still fires under `fallbackName` so a host can
84
+ * observe interactions the agent did not ask for.
85
+ */
86
+ emit: (
87
+ action: A2UIResolvedAction | undefined,
88
+ fallbackName: string,
89
+ payload?: Record<string, unknown>,
90
+ ) => void;
91
+ /** Write a value back to the data model where this prop was bound. No-op for a literal. */
92
+ writeBack: (property: string, value: unknown) => void;
93
+ /** Resolve a `{ kind: "server", sourceId }` handle into a real data source. */
94
+ resolveDataSource: (sourceId: string) => DataSource<A2UIRow> | undefined;
95
+ /** Debounce applied to continuous inputs before they become actions. */
96
+ debounceMs: number;
97
+ }
98
+
99
+ export type A2UIView<P> = React.FC<A2UIViewProps<P>>;
100
+
101
+ /* ------------------------------------------------------------------ */
102
+ /* Shared scales */
103
+ /* ------------------------------------------------------------------ */
104
+
105
+ /** Tailwind spacing steps an agent may pick. Written out so Tailwind emits them. */
106
+ const GAP: Record<number, string> = {
107
+ 0: "gap-0",
108
+ 1: "gap-1",
109
+ 2: "gap-2",
110
+ 3: "gap-3",
111
+ 4: "gap-4",
112
+ 6: "gap-6",
113
+ 8: "gap-8",
114
+ };
115
+
116
+ const PADDING: Record<number, string> = {
117
+ 0: "p-0",
118
+ 1: "p-1",
119
+ 2: "p-2",
120
+ 3: "p-3",
121
+ 4: "p-4",
122
+ 6: "p-6",
123
+ 8: "p-8",
124
+ };
125
+
126
+ const HEIGHT: Record<number, string> = {
127
+ 0: "h-0",
128
+ 1: "h-1",
129
+ 2: "h-2",
130
+ 3: "h-3",
131
+ 4: "h-4",
132
+ 6: "h-6",
133
+ 8: "h-8",
134
+ };
135
+
136
+ const ALIGN: Record<string, string> = {
137
+ start: "items-start",
138
+ center: "items-center",
139
+ end: "items-end",
140
+ stretch: "items-stretch",
141
+ baseline: "items-baseline",
142
+ };
143
+
144
+ const JUSTIFY: Record<string, string> = {
145
+ start: "justify-start",
146
+ center: "justify-center",
147
+ end: "justify-end",
148
+ between: "justify-between",
149
+ };
150
+
151
+ const TEXT_ALIGN: Record<string, string> = {
152
+ start: "text-left",
153
+ center: "text-center",
154
+ end: "text-right",
155
+ };
156
+
157
+ const TEXT_ROLE: Record<string, string> = {
158
+ heading:
159
+ "font-display text-[length:var(--ds-text-h3)] leading-[var(--ds-text-h3--line-height)] font-semibold",
160
+ subheading:
161
+ "text-[length:var(--ds-text-body-md)] leading-[var(--ds-text-body-md--line-height)] font-semibold",
162
+ body: "text-[length:var(--ds-text-body-sm)] leading-[var(--ds-text-body-sm--line-height)]",
163
+ caption: "text-[length:var(--ds-text-caption)] leading-[var(--ds-text-caption--line-height)]",
164
+ eyebrow:
165
+ "text-[length:var(--ds-text-eyebrow)] leading-[var(--ds-text-eyebrow--line-height)] font-medium uppercase tracking-[var(--ds-text-eyebrow--letter-spacing)]",
166
+ };
167
+
168
+ const TEXT_TONE: Record<string, string> = {
169
+ default: "text-[color:var(--ds-foreground)]",
170
+ muted: "text-[color:var(--ds-muted-foreground)]",
171
+ success: "text-[color:var(--ds-success)]",
172
+ warning: "text-[color:var(--ds-warning)]",
173
+ danger: "text-[color:var(--ds-destructive)]",
174
+ info: "text-[color:var(--ds-info)]",
175
+ };
176
+
177
+ /**
178
+ * Components that are inline by nature.
179
+ *
180
+ * A Column stretches its children on the cross axis, which is right for a field
181
+ * or a table and wrong for a badge — one would span the whole surface. `w-fit`
182
+ * gives the item an explicit cross size, which is what disables the stretch,
183
+ * without overriding a Row's own alignment.
184
+ */
185
+ const INLINE = "w-fit";
186
+
187
+ const ALERT_ICON: Record<string, React.ReactNode> = {
188
+ default: <Terminal />,
189
+ success: <CheckCircle2 />,
190
+ warn: <AlertTriangle />,
191
+ danger: <OctagonAlert />,
192
+ info: <Info />,
193
+ };
194
+
195
+ /**
196
+ * Debounce a value. Used for continuous inputs so an agent receives one action
197
+ * per pause rather than one per keystroke; the visible input stays immediate.
198
+ */
199
+ function useDebouncedEmit(
200
+ emit: (value: unknown) => void,
201
+ delay: number,
202
+ ): (value: unknown) => void {
203
+ const latest = React.useRef(emit);
204
+ latest.current = emit;
205
+ const timer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
206
+ React.useEffect(
207
+ () => () => {
208
+ if (timer.current !== undefined) {
209
+ clearTimeout(timer.current);
210
+ }
211
+ },
212
+ [],
213
+ );
214
+ return React.useCallback(
215
+ (value: unknown) => {
216
+ if (timer.current !== undefined) {
217
+ clearTimeout(timer.current);
218
+ }
219
+ timer.current = setTimeout(() => latest.current(value), delay);
220
+ },
221
+ [delay],
222
+ );
223
+ }
224
+
225
+ /**
226
+ * A value that follows the data model but can be typed over.
227
+ *
228
+ * The bound value wins whenever the agent changes it; between agent updates the
229
+ * local draft wins, so typing is not swallowed by a re-render.
230
+ *
231
+ * `key` is what identity is judged on, and defaults to the value itself. It
232
+ * matters for a value the renderer rebuilds each pass — a Date parsed from an
233
+ * ISO string is a new object every render, so comparing the objects would reset
234
+ * the draft forever. Compare the string instead.
235
+ */
236
+ function useDraft<T>(bound: T, key: unknown = bound): [T, (next: T) => void] {
237
+ const [draft, setDraft] = React.useState<T>(bound);
238
+ const previous = React.useRef(key);
239
+ if (!Object.is(previous.current, key)) {
240
+ previous.current = key;
241
+ setDraft(bound);
242
+ }
243
+ return [draft, setDraft];
244
+ }
245
+
246
+ /* ------------------------------------------------------------------ */
247
+ /* Layout */
248
+ /* ------------------------------------------------------------------ */
249
+
250
+ export interface ColumnProps {
251
+ gap?: number;
252
+ padding?: number;
253
+ align?: string;
254
+ justify?: string;
255
+ }
256
+
257
+ export const ColumnView: A2UIView<ColumnProps> = ({ props, children }) => (
258
+ <div
259
+ data-slot="a2ui-column"
260
+ className={cn(
261
+ "flex min-w-0 flex-col",
262
+ GAP[props.gap ?? 3],
263
+ props.padding !== undefined ? PADDING[props.padding] : undefined,
264
+ props.align ? ALIGN[props.align] : undefined,
265
+ props.justify ? JUSTIFY[props.justify] : undefined,
266
+ )}
267
+ >
268
+ {children}
269
+ </div>
270
+ );
271
+
272
+ export interface RowProps extends ColumnProps {
273
+ wrap?: boolean;
274
+ }
275
+
276
+ export const RowView: A2UIView<RowProps> = ({ props, children }) => (
277
+ <div
278
+ data-slot="a2ui-row"
279
+ className={cn(
280
+ "flex min-w-0 flex-row",
281
+ props.wrap === false ? "flex-nowrap" : "flex-wrap",
282
+ GAP[props.gap ?? 2],
283
+ props.padding !== undefined ? PADDING[props.padding] : undefined,
284
+ ALIGN[props.align ?? "center"],
285
+ props.justify ? JUSTIFY[props.justify] : undefined,
286
+ )}
287
+ >
288
+ {children}
289
+ </div>
290
+ );
291
+
292
+ export interface CardViewProps {
293
+ accent?: boolean;
294
+ flush?: boolean;
295
+ }
296
+
297
+ export const CardView: A2UIView<CardViewProps> = ({ props, children }) => (
298
+ <Card data-slot="a2ui-card" accent={props.accent}>
299
+ {props.flush ? (
300
+ <div className="flex flex-col gap-3">{children}</div>
301
+ ) : (
302
+ <CardContent className="flex flex-col gap-3 p-5">{children}</CardContent>
303
+ )}
304
+ </Card>
305
+ );
306
+
307
+ export interface SectionCardViewProps {
308
+ title: string;
309
+ description?: string;
310
+ flush?: boolean;
311
+ }
312
+
313
+ export const SectionCardView: A2UIView<SectionCardViewProps> = ({ props, children }) => (
314
+ <SectionCard
315
+ data-slot="a2ui-section-card"
316
+ title={props.title}
317
+ description={props.description}
318
+ flush={props.flush}
319
+ >
320
+ <div className="flex flex-col gap-3">{children}</div>
321
+ </SectionCard>
322
+ );
323
+
324
+ export interface DividerProps {
325
+ orientation?: "horizontal" | "vertical";
326
+ }
327
+
328
+ export const DividerView: A2UIView<DividerProps> = ({ props }) => (
329
+ <Separator data-slot="a2ui-divider" orientation={props.orientation ?? "horizontal"} />
330
+ );
331
+
332
+ export interface SpacerProps {
333
+ size?: number;
334
+ }
335
+
336
+ export const SpacerView: A2UIView<SpacerProps> = ({ props }) => (
337
+ <div data-slot="a2ui-spacer" aria-hidden className={cn("w-full", HEIGHT[props.size ?? 4])} />
338
+ );
339
+
340
+ /* ------------------------------------------------------------------ */
341
+ /* Text */
342
+ /* ------------------------------------------------------------------ */
343
+
344
+ export interface TextProps {
345
+ text: string;
346
+ role?: string;
347
+ tone?: string;
348
+ align?: string;
349
+ mono?: boolean;
350
+ }
351
+
352
+ export const TextView: A2UIView<TextProps> = ({ props }) => (
353
+ <p
354
+ data-slot="a2ui-text"
355
+ data-role={props.role ?? "body"}
356
+ className={cn(
357
+ "min-w-0 break-words",
358
+ TEXT_ROLE[props.role ?? "body"],
359
+ TEXT_TONE[props.tone ?? "default"],
360
+ props.align ? TEXT_ALIGN[props.align] : undefined,
361
+ props.mono ? "font-[family-name:var(--ds-font-mono)]" : undefined,
362
+ )}
363
+ >
364
+ {props.text}
365
+ </p>
366
+ );
367
+
368
+ export interface MarkdownViewProps {
369
+ text: string;
370
+ compact?: boolean;
371
+ }
372
+
373
+ export const MarkdownView: A2UIView<MarkdownViewProps> = ({ props }) => (
374
+ <Markdown data-slot="a2ui-markdown" compact={props.compact}>
375
+ {props.text}
376
+ </Markdown>
377
+ );
378
+
379
+ /* ------------------------------------------------------------------ */
380
+ /* Inputs */
381
+ /* ------------------------------------------------------------------ */
382
+
383
+ interface FieldShellProps {
384
+ label?: string;
385
+ description?: string;
386
+ error?: string;
387
+ children: React.ReactNode;
388
+ slot: string;
389
+ orientation?: "vertical" | "horizontal";
390
+ /**
391
+ * Ties the label to the control. Base UI's Field associates its own
392
+ * `Field.Control`; these views render plain cortena-ui controls, so the pair
393
+ * is wired explicitly rather than left to produce a label with no `for`.
394
+ */
395
+ controlId?: string;
396
+ }
397
+
398
+ function FieldShell({
399
+ label,
400
+ description,
401
+ error,
402
+ children,
403
+ slot,
404
+ orientation,
405
+ controlId,
406
+ }: FieldShellProps) {
407
+ return (
408
+ <Field data-slot={slot} orientation={orientation ?? "vertical"} invalid={Boolean(error)}>
409
+ {label ? <FieldLabel htmlFor={controlId}>{label}</FieldLabel> : null}
410
+ {children}
411
+ {description ? <FieldDescription>{description}</FieldDescription> : null}
412
+ {error ? <FieldError>{error}</FieldError> : null}
413
+ </Field>
414
+ );
415
+ }
416
+
417
+ export interface TextFieldProps {
418
+ label?: string;
419
+ value?: string;
420
+ placeholder?: string;
421
+ description?: string;
422
+ error?: string;
423
+ type?: "text" | "email" | "url" | "tel" | "number" | "password" | "search";
424
+ disabled?: boolean;
425
+ required?: boolean;
426
+ onChange?: A2UIResolvedAction;
427
+ }
428
+
429
+ export const TextFieldView: A2UIView<TextFieldProps> = ({
430
+ props,
431
+ emit,
432
+ writeBack,
433
+ debounceMs,
434
+ }) => {
435
+ const id = React.useId();
436
+ const [draft, setDraft] = useDraft(props.value ?? "");
437
+ const send = useDebouncedEmit(
438
+ (value) => emit(props.onChange, "change", { value }),
439
+ debounceMs,
440
+ );
441
+ return (
442
+ <FieldShell
443
+ slot="a2ui-text-field"
444
+ label={props.label}
445
+ description={props.description}
446
+ error={props.error}
447
+ controlId={id}
448
+ >
449
+ <Input
450
+ id={id}
451
+ type={props.type ?? "text"}
452
+ value={draft}
453
+ placeholder={props.placeholder}
454
+ disabled={props.disabled}
455
+ required={props.required}
456
+ aria-label={props.label ? undefined : (props.placeholder ?? "Text field")}
457
+ onChange={(event) => {
458
+ const next = event.target.value;
459
+ setDraft(next);
460
+ writeBack("value", next);
461
+ send(next);
462
+ }}
463
+ />
464
+ </FieldShell>
465
+ );
466
+ };
467
+
468
+ export interface TextAreaProps {
469
+ label?: string;
470
+ value?: string;
471
+ placeholder?: string;
472
+ description?: string;
473
+ error?: string;
474
+ rows?: number;
475
+ disabled?: boolean;
476
+ autoGrow?: boolean;
477
+ onChange?: A2UIResolvedAction;
478
+ }
479
+
480
+ export const TextAreaView: A2UIView<TextAreaProps> = ({ props, emit, writeBack, debounceMs }) => {
481
+ const id = React.useId();
482
+ const [draft, setDraft] = useDraft(props.value ?? "");
483
+ const send = useDebouncedEmit((value) => emit(props.onChange, "change", { value }), debounceMs);
484
+ return (
485
+ <FieldShell
486
+ slot="a2ui-text-area"
487
+ label={props.label}
488
+ description={props.description}
489
+ error={props.error}
490
+ controlId={id}
491
+ >
492
+ <Textarea
493
+ id={id}
494
+ value={draft}
495
+ rows={props.rows ?? 4}
496
+ placeholder={props.placeholder}
497
+ disabled={props.disabled}
498
+ autoGrow={props.autoGrow}
499
+ aria-label={props.label ? undefined : (props.placeholder ?? "Text area")}
500
+ onChange={(event) => {
501
+ const next = event.target.value;
502
+ setDraft(next);
503
+ writeBack("value", next);
504
+ send(next);
505
+ }}
506
+ />
507
+ </FieldShell>
508
+ );
509
+ };
510
+
511
+ export interface SelectOption {
512
+ value: string;
513
+ label?: string;
514
+ disabled?: boolean;
515
+ }
516
+
517
+ export interface SelectViewProps {
518
+ label?: string;
519
+ value?: string;
520
+ placeholder?: string;
521
+ description?: string;
522
+ error?: string;
523
+ options: SelectOption[];
524
+ disabled?: boolean;
525
+ onChange?: A2UIResolvedAction;
526
+ }
527
+
528
+ export const SelectView: A2UIView<SelectViewProps> = ({ props, emit, writeBack }) => {
529
+ const [draft, setDraft] = useDraft(props.value ?? null);
530
+ const items = React.useMemo(
531
+ () => props.options.map((option) => ({ value: option.value, label: option.label ?? option.value })),
532
+ [props.options],
533
+ );
534
+ return (
535
+ <FieldShell
536
+ slot="a2ui-select"
537
+ label={props.label}
538
+ description={props.description}
539
+ error={props.error}
540
+ >
541
+ <Select
542
+ items={items}
543
+ value={draft}
544
+ disabled={props.disabled}
545
+ onValueChange={(next) => {
546
+ const value = next === null ? "" : String(next);
547
+ setDraft(value);
548
+ writeBack("value", value);
549
+ emit(props.onChange, "change", { value });
550
+ }}
551
+ >
552
+ <SelectTrigger aria-label={props.label ?? props.placeholder ?? "Select"}>
553
+ <SelectValue placeholder={props.placeholder ?? "Select…"} />
554
+ </SelectTrigger>
555
+ <SelectContent>
556
+ {props.options.map((option) => (
557
+ <SelectItem key={option.value} value={option.value} disabled={option.disabled}>
558
+ {option.label ?? option.value}
559
+ </SelectItem>
560
+ ))}
561
+ </SelectContent>
562
+ </Select>
563
+ </FieldShell>
564
+ );
565
+ };
566
+
567
+ export interface CheckboxViewProps {
568
+ label?: string;
569
+ checked?: boolean;
570
+ description?: string;
571
+ disabled?: boolean;
572
+ onChange?: A2UIResolvedAction;
573
+ }
574
+
575
+ export const CheckboxView: A2UIView<CheckboxViewProps> = ({ props, emit, writeBack }) => {
576
+ const [draft, setDraft] = useDraft(props.checked ?? false);
577
+ return (
578
+ <FieldShell
579
+ slot="a2ui-checkbox"
580
+ description={props.description}
581
+ orientation="horizontal"
582
+ >
583
+ <label className="flex items-start gap-3 text-[length:var(--ds-text-caption-lg)] text-[color:var(--ds-foreground)]">
584
+ <span className="flex h-5 items-center">
585
+ <Checkbox
586
+ checked={draft}
587
+ disabled={props.disabled}
588
+ aria-label={props.label ?? "Checkbox"}
589
+ onCheckedChange={(checked) => {
590
+ setDraft(checked);
591
+ writeBack("checked", checked);
592
+ emit(props.onChange, "change", { checked });
593
+ }}
594
+ />
595
+ </span>
596
+ <span>{props.label}</span>
597
+ </label>
598
+ </FieldShell>
599
+ );
600
+ };
601
+
602
+ export interface SwitchViewProps extends CheckboxViewProps {}
603
+
604
+ export const SwitchView: A2UIView<SwitchViewProps> = ({ props, emit, writeBack }) => {
605
+ const [draft, setDraft] = useDraft(props.checked ?? false);
606
+ return (
607
+ <FieldShell slot="a2ui-switch" description={props.description} orientation="horizontal">
608
+ <label className="flex items-center gap-3 text-[length:var(--ds-text-caption-lg)] text-[color:var(--ds-foreground)]">
609
+ <Switch
610
+ checked={draft}
611
+ disabled={props.disabled}
612
+ aria-label={props.label ?? "Switch"}
613
+ onCheckedChange={(checked) => {
614
+ setDraft(checked);
615
+ writeBack("checked", checked);
616
+ emit(props.onChange, "change", { checked });
617
+ }}
618
+ />
619
+ <span>{props.label}</span>
620
+ </label>
621
+ </FieldShell>
622
+ );
623
+ };
624
+
625
+ export interface RadioGroupViewProps {
626
+ label?: string;
627
+ value?: string;
628
+ description?: string;
629
+ options: SelectOption[];
630
+ disabled?: boolean;
631
+ onChange?: A2UIResolvedAction;
632
+ }
633
+
634
+ export const RadioGroupView: A2UIView<RadioGroupViewProps> = ({ props, emit, writeBack }) => {
635
+ const [draft, setDraft] = useDraft(props.value ?? "");
636
+ return (
637
+ <FieldShell
638
+ slot="a2ui-radio-group"
639
+ label={props.label}
640
+ description={props.description}
641
+ >
642
+ <RadioGroup
643
+ aria-label={props.label ?? "Options"}
644
+ value={draft}
645
+ disabled={props.disabled}
646
+ onValueChange={(next) => {
647
+ const value = String(next);
648
+ setDraft(value);
649
+ writeBack("value", value);
650
+ emit(props.onChange, "change", { value });
651
+ }}
652
+ >
653
+ {props.options.map((option) => (
654
+ <label
655
+ key={option.value}
656
+ className="flex items-start gap-3 text-[length:var(--ds-text-caption-lg)] text-[color:var(--ds-foreground)]"
657
+ >
658
+ <span className="flex h-5 items-center">
659
+ <RadioGroupItem value={option.value} disabled={option.disabled} />
660
+ </span>
661
+ <span>{option.label ?? option.value}</span>
662
+ </label>
663
+ ))}
664
+ </RadioGroup>
665
+ </FieldShell>
666
+ );
667
+ };
668
+
669
+ export interface DateFieldViewProps {
670
+ label?: string;
671
+ /** ISO `yyyy-mm-dd`; the wire format for a date in A2UI. */
672
+ value?: string;
673
+ min?: string;
674
+ max?: string;
675
+ description?: string;
676
+ error?: string;
677
+ disabled?: boolean;
678
+ onChange?: A2UIResolvedAction;
679
+ }
680
+
681
+ export const DateFieldView: A2UIView<DateFieldViewProps> = ({ props, emit, writeBack }) => {
682
+ const id = React.useId();
683
+ const bound = props.value ? parseDateISO(props.value) : undefined;
684
+ const [draft, setDraft] = useDraft<Date | undefined>(bound, props.value ?? "");
685
+ return (
686
+ <FieldShell
687
+ slot="a2ui-date-field"
688
+ label={props.label}
689
+ description={props.description}
690
+ error={props.error}
691
+ controlId={id}
692
+ >
693
+ <DateField
694
+ id={id}
695
+ value={draft}
696
+ min={props.min ? parseDateISO(props.min) : undefined}
697
+ max={props.max ? parseDateISO(props.max) : undefined}
698
+ disabled={props.disabled}
699
+ invalid={Boolean(props.error)}
700
+ aria-label={props.label ?? "Date"}
701
+ onValueChange={(date) => {
702
+ setDraft(date);
703
+ const value = date ? formatDateISO(date) : "";
704
+ writeBack("value", value);
705
+ emit(props.onChange, "change", { value });
706
+ }}
707
+ />
708
+ </FieldShell>
709
+ );
710
+ };
711
+
712
+ export interface ButtonViewProps {
713
+ label: string;
714
+ variant?: "primary" | "secondary" | "outline" | "ghost" | "soft" | "destructive" | "link";
715
+ size?: "sm" | "md" | "lg";
716
+ disabled?: boolean;
717
+ /** Report the whole surface data model with the action, for a form submit. */
718
+ submit?: boolean;
719
+ onPress?: A2UIResolvedAction;
720
+ }
721
+
722
+ export const ButtonView: A2UIView<ButtonViewProps> = ({ props, emit, dataModel }) => (
723
+ <Button
724
+ data-slot="a2ui-button"
725
+ className={INLINE}
726
+ variant={props.variant ?? "primary"}
727
+ size={props.size ?? "md"}
728
+ disabled={props.disabled}
729
+ onClick={() =>
730
+ emit(
731
+ props.onPress,
732
+ props.submit ? "submit" : "press",
733
+ props.submit ? { data: dataModel } : {},
734
+ )
735
+ }
736
+ >
737
+ {props.label}
738
+ </Button>
739
+ );
740
+
741
+ export interface ButtonLinkViewProps {
742
+ label: string;
743
+ href: string;
744
+ variant?: ButtonViewProps["variant"];
745
+ size?: ButtonViewProps["size"];
746
+ target?: "_self" | "_blank";
747
+ }
748
+
749
+ // ButtonLink sets its own `data-slot` after spreading props, so the A2UI marker
750
+ // goes on a wrapper rather than being silently dropped.
751
+ export const ButtonLinkView: A2UIView<ButtonLinkViewProps> = ({ props }) => (
752
+ <span data-slot="a2ui-button-link" className={cn("inline-flex", INLINE)}>
753
+ <ButtonLink
754
+ variant={props.variant ?? "secondary"}
755
+ size={props.size ?? "md"}
756
+ href={props.href}
757
+ target={props.target}
758
+ rel={props.target === "_blank" ? "noopener noreferrer" : undefined}
759
+ >
760
+ {props.label}
761
+ </ButtonLink>
762
+ </span>
763
+ );
764
+
765
+ export interface LinkViewProps {
766
+ label: string;
767
+ href: string;
768
+ target?: "_self" | "_blank";
769
+ }
770
+
771
+ export const LinkView: A2UIView<LinkViewProps> = ({ props }) => (
772
+ <a
773
+ data-slot="a2ui-link"
774
+ href={props.href}
775
+ target={props.target}
776
+ rel={props.target === "_blank" ? "noopener noreferrer" : undefined}
777
+ className={cn(
778
+ INLINE,
779
+ "text-[length:var(--ds-text-body-sm)] text-[color:var(--ds-primary)] underline underline-offset-4",
780
+ "hover:text-[color:var(--ds-primary-hover)]",
781
+ )}
782
+ >
783
+ {props.label}
784
+ </a>
785
+ );
786
+
787
+ /* ------------------------------------------------------------------ */
788
+ /* Feedback */
789
+ /* ------------------------------------------------------------------ */
790
+
791
+ export interface BadgeViewProps {
792
+ label: string;
793
+ variant?: "default" | "success" | "warning" | "danger" | "info" | "neutral" | "outline";
794
+ }
795
+
796
+ export const BadgeView: A2UIView<BadgeViewProps> = ({ props }) => (
797
+ <Badge data-slot="a2ui-badge" className={INLINE} variant={props.variant ?? "default"}>
798
+ {props.label}
799
+ </Badge>
800
+ );
801
+
802
+ export interface AlertViewProps {
803
+ title?: string;
804
+ description?: string;
805
+ variant?: "default" | "success" | "warn" | "danger" | "info";
806
+ }
807
+
808
+ export const AlertView: A2UIView<AlertViewProps> = ({ props }) => {
809
+ const variant = props.variant ?? "default";
810
+ return (
811
+ <Alert data-slot="a2ui-alert" variant={variant}>
812
+ <AlertIcon>{ALERT_ICON[variant]}</AlertIcon>
813
+ <div>
814
+ {props.title ? <AlertTitle>{props.title}</AlertTitle> : null}
815
+ {props.description ? <AlertDescription>{props.description}</AlertDescription> : null}
816
+ </div>
817
+ </Alert>
818
+ );
819
+ };
820
+
821
+ export interface ProgressViewProps {
822
+ /** `null` renders the indeterminate bar. */
823
+ value?: number | null;
824
+ max?: number;
825
+ label?: string;
826
+ showValue?: boolean;
827
+ }
828
+
829
+ export const ProgressViewComponent: A2UIView<ProgressViewProps> = ({ props }) => (
830
+ <Progress
831
+ data-slot="a2ui-progress"
832
+ value={props.value === undefined ? null : props.value}
833
+ max={props.max}
834
+ aria-label={props.label ?? "Progress"}
835
+ >
836
+ {props.label ? <ProgressLabel>{props.label}</ProgressLabel> : null}
837
+ {props.showValue === false ? null : <ProgressValue />}
838
+ </Progress>
839
+ );
840
+
841
+ export interface StatusDotViewProps {
842
+ label?: string;
843
+ tone?: "accent" | "success" | "warning" | "danger" | "info" | "neutral";
844
+ live?: boolean;
845
+ }
846
+
847
+ export const StatusDotView: A2UIView<StatusDotViewProps> = ({ props }) => (
848
+ <StatusDot
849
+ data-slot="a2ui-status-dot"
850
+ className={INLINE}
851
+ tone={props.tone ?? "accent"}
852
+ live={props.live}
853
+ label={props.label}
854
+ />
855
+ );
856
+
857
+ export interface EmptyStateViewProps {
858
+ title: string;
859
+ description?: string;
860
+ size?: "default" | "compact";
861
+ }
862
+
863
+ export const EmptyStateView: A2UIView<EmptyStateViewProps> = ({ props, children }) => (
864
+ <EmptyState
865
+ data-slot="a2ui-empty-state"
866
+ title={props.title}
867
+ description={props.description}
868
+ size={props.size ?? "compact"}
869
+ action={children.length > 0 ? <div className="flex gap-2">{children}</div> : undefined}
870
+ />
871
+ );
872
+
873
+ /* ------------------------------------------------------------------ */
874
+ /* Data */
875
+ /* ------------------------------------------------------------------ */
876
+
877
+ export interface A2UIColumn {
878
+ id: string;
879
+ label?: string;
880
+ align?: "start" | "center" | "end";
881
+ filter?: "faceted" | "text";
882
+ width?: number;
883
+ }
884
+
885
+ /**
886
+ * Where a table's rows come from.
887
+ *
888
+ * `client` carries the rows inline, which suits a short result an agent
889
+ * already has. `server` carries a handle instead: the agent obtains a
890
+ * `sourceId` from a tool and the host resolves it through
891
+ * `resolveDataSource`, so search, sort and paging run on the backend and the
892
+ * agent never has to put ten thousand rows in a message.
893
+ */
894
+ export type A2UITableSource =
895
+ | { kind: "client"; rows: A2UIRow[] }
896
+ | { kind: "server"; sourceId: string };
897
+
898
+ export interface DataTableViewProps {
899
+ columns: A2UIColumn[];
900
+ dataSource: A2UITableSource;
901
+ pageSize?: number;
902
+ density?: "default" | "compact";
903
+ searchPlaceholder?: string;
904
+ emptyMessage?: string;
905
+ maxHeight?: number;
906
+ onRowClick?: A2UIResolvedAction;
907
+ }
908
+
909
+ export const DataTableView: A2UIView<DataTableViewProps> = ({
910
+ props,
911
+ emit,
912
+ resolveDataSource,
913
+ }) => {
914
+ const columns = React.useMemo<DataTableColumnDef<A2UIRow>[]>(
915
+ () =>
916
+ props.columns.map((column) => ({
917
+ id: column.id,
918
+ accessorKey: column.id,
919
+ header: column.label ?? column.id,
920
+ size: column.width,
921
+ meta: {
922
+ align: column.align,
923
+ filter: column.filter,
924
+ label: column.label ?? column.id,
925
+ },
926
+ })) as DataTableColumnDef<A2UIRow>[],
927
+ [props.columns],
928
+ );
929
+
930
+ const source = props.dataSource;
931
+ const resolved: DataSource<A2UIRow> | undefined =
932
+ source.kind === "client" ? { kind: "client", rows: source.rows } : resolveDataSource(source.sourceId);
933
+
934
+ if (!resolved) {
935
+ return (
936
+ <A2UIErrorCard
937
+ title="Unknown data source"
938
+ detail={`No data source is registered for "${(source as { sourceId: string }).sourceId}". The host resolves a server table through resolveDataSource.`}
939
+ />
940
+ );
941
+ }
942
+
943
+ return (
944
+ <div data-slot="a2ui-data-table">
945
+ <DataTable<A2UIRow>
946
+ columns={columns}
947
+ dataSource={resolved}
948
+ pageSize={props.pageSize ?? 10}
949
+ density={props.density ?? "compact"}
950
+ maxHeight={props.maxHeight}
951
+ searchPlaceholder={props.searchPlaceholder}
952
+ emptyMessage={props.emptyMessage}
953
+ onRowClick={(row) => emit(props.onRowClick, "rowClick", { row })}
954
+ />
955
+ </div>
956
+ );
957
+ };
958
+
959
+ export interface ChartViewProps {
960
+ type: ChartType;
961
+ data: unknown;
962
+ title?: string;
963
+ height?: number;
964
+ stacked?: boolean;
965
+ legend?: boolean;
966
+ grid?: boolean;
967
+ scale?: "sequential" | "diverging";
968
+ indexBy?: string;
969
+ keys?: string[];
970
+ from?: string;
971
+ to?: string;
972
+ emptyMessage?: string;
973
+ }
974
+
975
+ export const ChartView: A2UIView<ChartViewProps> = ({ props }) => (
976
+ <div data-slot="a2ui-chart" className="flex flex-col gap-2">
977
+ {props.title ? (
978
+ <p className="text-[length:var(--ds-text-caption-lg)] font-medium text-[color:var(--ds-foreground)]">
979
+ {props.title}
980
+ </p>
981
+ ) : null}
982
+ <Chart
983
+ type={props.type}
984
+ data={props.data as ChartDataFor<ChartType>}
985
+ height={props.height ?? 260}
986
+ stacked={props.stacked}
987
+ legend={props.legend}
988
+ grid={props.grid}
989
+ scale={props.scale}
990
+ indexBy={props.indexBy}
991
+ keys={props.keys}
992
+ from={props.from}
993
+ to={props.to}
994
+ emptyMessage={props.emptyMessage}
995
+ />
996
+ </div>
997
+ );
998
+
999
+ /* ------------------------------------------------------------------ */
1000
+ /* Tabs and media */
1001
+ /* ------------------------------------------------------------------ */
1002
+
1003
+ export interface TabDescriptor {
1004
+ value: string;
1005
+ label: string;
1006
+ }
1007
+
1008
+ export interface TabsViewProps {
1009
+ tabs: TabDescriptor[];
1010
+ value?: string;
1011
+ onChange?: A2UIResolvedAction;
1012
+ }
1013
+
1014
+ export const TabsView: A2UIView<TabsViewProps> = ({ props, children, emit, writeBack }) => {
1015
+ const first = props.tabs[0]?.value ?? "";
1016
+ const [draft, setDraft] = useDraft(props.value ?? first);
1017
+ return (
1018
+ <Tabs
1019
+ data-slot="a2ui-tabs"
1020
+ value={draft}
1021
+ onValueChange={(next) => {
1022
+ const value = String(next);
1023
+ setDraft(value);
1024
+ writeBack("value", value);
1025
+ emit(props.onChange, "tabChange", { value });
1026
+ }}
1027
+ >
1028
+ <TabsList>
1029
+ {props.tabs.map((tab) => (
1030
+ <TabsTrigger key={tab.value} value={tab.value}>
1031
+ {tab.label}
1032
+ </TabsTrigger>
1033
+ ))}
1034
+ </TabsList>
1035
+ {props.tabs.map((tab, index) => (
1036
+ <TabsContent key={tab.value} value={tab.value}>
1037
+ {children[index] ?? null}
1038
+ </TabsContent>
1039
+ ))}
1040
+ </Tabs>
1041
+ );
1042
+ };
1043
+
1044
+ export interface ImageViewProps {
1045
+ src: string;
1046
+ alt: string;
1047
+ width?: number;
1048
+ height?: number;
1049
+ rounded?: boolean;
1050
+ fit?: "cover" | "contain";
1051
+ }
1052
+
1053
+ export const ImageView: A2UIView<ImageViewProps> = ({ props }) => (
1054
+ <img
1055
+ data-slot="a2ui-image"
1056
+ src={props.src}
1057
+ alt={props.alt}
1058
+ width={props.width}
1059
+ height={props.height}
1060
+ className={cn(
1061
+ INLINE,
1062
+ "max-w-full",
1063
+ props.fit === "cover" ? "object-cover" : "object-contain",
1064
+ props.rounded === false ? undefined : "rounded-[var(--ds-radius-md)]",
1065
+ "border border-[var(--ds-border-subtle)]",
1066
+ )}
1067
+ />
1068
+ );
1069
+
1070
+ /* ------------------------------------------------------------------ */
1071
+ /* Error card */
1072
+ /* ------------------------------------------------------------------ */
1073
+
1074
+ /**
1075
+ * What the renderer draws instead of a component it cannot draw.
1076
+ *
1077
+ * Never raw markup and never the offending payload as HTML: the detail is set
1078
+ * as text, so a malformed message cannot inject anything. This is the
1079
+ * "fallback: an error card on malformed payloads" of `05-agent-ui.md`.
1080
+ */
1081
+ export function A2UIErrorCard({
1082
+ title,
1083
+ detail,
1084
+ componentId,
1085
+ }: {
1086
+ title: string;
1087
+ detail?: string;
1088
+ componentId?: string;
1089
+ }) {
1090
+ return (
1091
+ <Alert data-slot="a2ui-error" variant="danger" role="alert">
1092
+ <AlertIcon>
1093
+ <OctagonAlert />
1094
+ </AlertIcon>
1095
+ <div className="min-w-0">
1096
+ <AlertTitle>{title}</AlertTitle>
1097
+ {detail ? (
1098
+ <AlertDescription className="font-[family-name:var(--ds-font-mono)] text-[length:var(--ds-text-caption)] break-words">
1099
+ {detail}
1100
+ </AlertDescription>
1101
+ ) : null}
1102
+ {componentId ? (
1103
+ <AlertDescription className="text-[length:var(--ds-text-caption-sm)] text-[color:var(--ds-muted-foreground)]">
1104
+ Component id: {componentId}
1105
+ </AlertDescription>
1106
+ ) : null}
1107
+ </div>
1108
+ </Alert>
1109
+ );
1110
+ }
1111
+
1112
+ /** Re-exported so consumers can type an action handler without the message module. */
1113
+ export type { A2UIAction };