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.
- package/README.md +51 -0
- package/dist/index.d.ts +437 -16
- package/dist/index.js +4327 -2066
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/src/a2ui/catalogue-doc.ts +218 -0
- package/src/a2ui/catalogue.ts +950 -0
- package/src/a2ui/index.ts +73 -0
- package/src/a2ui/message.ts +590 -0
- package/src/a2ui/renderer.tsx +438 -0
- package/src/a2ui/views.tsx +1113 -0
- package/src/hooks/use-cortena-theme.ts +12 -4
- package/src/index.ts +2 -0
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The A2UI React renderer.
|
|
5
|
+
*
|
|
6
|
+
* Takes A2UI messages, folds them onto surfaces, walks the component tree and
|
|
7
|
+
* draws each node through the catalogue. Nothing an agent sends reaches the DOM
|
|
8
|
+
* as markup: a node is a catalogue name plus properties that must pass the
|
|
9
|
+
* entry's zod schema, and anything else becomes an inline error card.
|
|
10
|
+
*
|
|
11
|
+
* ## Why this is written rather than adopted
|
|
12
|
+
*
|
|
13
|
+
* `@a2ui/react` 0.11.0 (Google, Apache-2.0, published 2026-09-01) is a real,
|
|
14
|
+
* maintained v0.9 renderer and it does let a host supply a catalogue. It was
|
|
15
|
+
* evaluated for DESIGN-33 and not adopted, for three reasons:
|
|
16
|
+
*
|
|
17
|
+
* 1. **zod major.** `@a2ui/web_core`'s generic binder reads zod 3 internals —
|
|
18
|
+
* `schema._def.typeName`, `_def.shape()`, `_def.innerType` — to decide how to
|
|
19
|
+
* bind each property. cortena-ui's zod peer is `^3.25 || ^4`, and zod 4 has
|
|
20
|
+
* no `_def.typeName` and exposes `shape` as a property, so a catalogue
|
|
21
|
+
* authored with the consumer's zod 4 fails inside the binder. Adopting would
|
|
22
|
+
* pin the whole package's schema layer to zod 3 while `form.tsx` and
|
|
23
|
+
* `@hookform/resolvers` follow the consumer's.
|
|
24
|
+
* 2. **Dialect.** Cortena already speaks A2UI v0.8: core bundles the Lit
|
|
25
|
+
* renderer at `src/canvas-host/a2ui/` and the `canvas` tool pushes
|
|
26
|
+
* `beginRendering` / `surfaceUpdate` / `dataModelUpdate` as JSONL.
|
|
27
|
+
* `@a2ui/react` splits v0_8 and v0_9 into separate entry points with separate
|
|
28
|
+
* catalogues, so neither one reads both. Every payload written for the
|
|
29
|
+
* native nodes would have to be rewritten before it rendered on the web.
|
|
30
|
+
* 3. **Weight and surface.** It brings `@a2ui/web_core` (3.8 MB unpacked),
|
|
31
|
+
* markdown-it, `@preact/signals-core` and date-fns into a package whose build
|
|
32
|
+
* externalises everything and whose markdown already goes through
|
|
33
|
+
* react-markdown with a sanitize schema. And the API this task needs —
|
|
34
|
+
* `resolveDataSource` for server tables, `onAction` for the AG-UI round trip —
|
|
35
|
+
* has no counterpart there, so it would be wrapped either way.
|
|
36
|
+
*
|
|
37
|
+
* What we keep is the part that matters for interop: the **wire format** is
|
|
38
|
+
* A2UI's, the action we emit is shaped like A2UI's client-to-server `action`
|
|
39
|
+
* (`{ name, surfaceId, sourceComponentId, context }`), and the catalogue is a
|
|
40
|
+
* schema-per-component registry. Swapping the engine later is mechanical.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import * as React from "react";
|
|
44
|
+
import type { DataSource } from "@/components/data-table";
|
|
45
|
+
import { cn } from "@/lib/cn";
|
|
46
|
+
import {
|
|
47
|
+
A2UI_DEFAULT_SURFACE_ID,
|
|
48
|
+
foldMessages,
|
|
49
|
+
isDataBinding,
|
|
50
|
+
readAction,
|
|
51
|
+
readChildren,
|
|
52
|
+
readComponent,
|
|
53
|
+
resolvePointer,
|
|
54
|
+
resolveValue,
|
|
55
|
+
setAtPointer,
|
|
56
|
+
type A2UIAction,
|
|
57
|
+
type A2UIComponentSpec,
|
|
58
|
+
type A2UIMessageInput,
|
|
59
|
+
type A2UISurface,
|
|
60
|
+
} from "./message";
|
|
61
|
+
import { defaultCatalogue, type A2UICatalogue } from "./catalogue";
|
|
62
|
+
import { A2UIErrorCard, type A2UINodeInfo, type A2UIResolvedAction, type A2UIRow } from "./views";
|
|
63
|
+
|
|
64
|
+
export interface A2UIRendererProps {
|
|
65
|
+
/**
|
|
66
|
+
* The payload. One message, the list an AG-UI `CUSTOM` event carries, or the
|
|
67
|
+
* JSONL text `canvas.a2ui.pushJSONL` sends. Later messages fold onto earlier
|
|
68
|
+
* ones, so a push stream can be accumulated by the host and handed here whole.
|
|
69
|
+
*/
|
|
70
|
+
message: A2UIMessageInput;
|
|
71
|
+
/** The component vocabulary. Defaults to the whole cortena-ui catalogue. */
|
|
72
|
+
catalogue?: A2UICatalogue;
|
|
73
|
+
/** Receives every user event, shaped like A2UI's client-to-server action. */
|
|
74
|
+
onAction?: (action: A2UIAction) => void;
|
|
75
|
+
/**
|
|
76
|
+
* Resolves a `{ kind: "server", sourceId }` table handle into a real data
|
|
77
|
+
* source. Without it, a server table renders an error card rather than an
|
|
78
|
+
* empty one, because a silently empty table reads as "no results".
|
|
79
|
+
*/
|
|
80
|
+
resolveDataSource?: (sourceId: string) => DataSource<A2UIRow> | undefined;
|
|
81
|
+
/** Render only this surface. Default: every surface, in arrival order. */
|
|
82
|
+
surfaceId?: string;
|
|
83
|
+
/** Debounce before a continuous input becomes an action. @default 300 */
|
|
84
|
+
debounceMs?: number;
|
|
85
|
+
className?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Depth limit; a tree deeper than this is a malformed payload, not a design. */
|
|
89
|
+
const MAX_DEPTH = 32;
|
|
90
|
+
|
|
91
|
+
interface NodeContext {
|
|
92
|
+
surface: A2UISurface;
|
|
93
|
+
catalogue: A2UICatalogue;
|
|
94
|
+
model: Record<string, unknown>;
|
|
95
|
+
setModel: (updater: (model: Record<string, unknown>) => Record<string, unknown>) => void;
|
|
96
|
+
onAction: ((action: A2UIAction) => void) | undefined;
|
|
97
|
+
resolveDataSource: (sourceId: string) => DataSource<A2UIRow> | undefined;
|
|
98
|
+
debounceMs: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A2UIRenderer — draws A2UI messages through cortena-ui.
|
|
103
|
+
*
|
|
104
|
+
* ```tsx
|
|
105
|
+
* <A2UIRenderer
|
|
106
|
+
* message={customEvent.value}
|
|
107
|
+
* catalogue={defaultCatalogue}
|
|
108
|
+
* onAction={(action) => agent.send(action)}
|
|
109
|
+
* resolveDataSource={(id) => sources[id]}
|
|
110
|
+
* />
|
|
111
|
+
* ```
|
|
112
|
+
*/
|
|
113
|
+
export function A2UIRenderer({
|
|
114
|
+
message,
|
|
115
|
+
catalogue = defaultCatalogue,
|
|
116
|
+
onAction,
|
|
117
|
+
resolveDataSource,
|
|
118
|
+
surfaceId,
|
|
119
|
+
debounceMs = 300,
|
|
120
|
+
className,
|
|
121
|
+
}: A2UIRendererProps) {
|
|
122
|
+
const fold = React.useMemo(() => foldMessages(message), [message]);
|
|
123
|
+
|
|
124
|
+
// Local data model per surface, seeded from the folded one. Inputs write here
|
|
125
|
+
// so a bound value updates as the user types; a new payload replaces it,
|
|
126
|
+
// because the agent is then the authority on what the surface shows.
|
|
127
|
+
const [models, setModels] = React.useState<Record<string, Record<string, unknown>>>(() =>
|
|
128
|
+
seedModels(fold.surfaces),
|
|
129
|
+
);
|
|
130
|
+
const seenFold = React.useRef(fold);
|
|
131
|
+
if (seenFold.current !== fold) {
|
|
132
|
+
seenFold.current = fold;
|
|
133
|
+
setModels(seedModels(fold.surfaces));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const resolve = React.useCallback(
|
|
137
|
+
(sourceId: string) => resolveDataSource?.(sourceId),
|
|
138
|
+
[resolveDataSource],
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const surfaces = surfaceId
|
|
142
|
+
? fold.surfaces.filter((surface) => surface.id === surfaceId)
|
|
143
|
+
: fold.surfaces;
|
|
144
|
+
|
|
145
|
+
const missingSurface = Boolean(surfaceId) && surfaces.length === 0 && fold.surfaces.length > 0;
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<div data-slot="a2ui-renderer" className={cn("flex min-w-0 flex-col gap-3", className)}>
|
|
149
|
+
{fold.errors.length > 0 ? (
|
|
150
|
+
<A2UIErrorCard
|
|
151
|
+
title="Malformed A2UI payload"
|
|
152
|
+
detail={fold.errors.slice(0, 5).join(" ")}
|
|
153
|
+
/>
|
|
154
|
+
) : null}
|
|
155
|
+
{missingSurface ? (
|
|
156
|
+
<A2UIErrorCard
|
|
157
|
+
title="Unknown surface"
|
|
158
|
+
detail={`No surface "${surfaceId}" in this payload.`}
|
|
159
|
+
/>
|
|
160
|
+
) : null}
|
|
161
|
+
{surfaces.map((surface) => (
|
|
162
|
+
<A2UISurfaceView
|
|
163
|
+
key={surface.id}
|
|
164
|
+
surface={surface}
|
|
165
|
+
catalogue={catalogue}
|
|
166
|
+
model={models[surface.id] ?? surface.dataModel}
|
|
167
|
+
setModel={(updater) =>
|
|
168
|
+
setModels((current) => ({
|
|
169
|
+
...current,
|
|
170
|
+
[surface.id]: updater(current[surface.id] ?? surface.dataModel),
|
|
171
|
+
}))
|
|
172
|
+
}
|
|
173
|
+
onAction={onAction}
|
|
174
|
+
resolveDataSource={resolve}
|
|
175
|
+
debounceMs={debounceMs}
|
|
176
|
+
/>
|
|
177
|
+
))}
|
|
178
|
+
</div>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function seedModels(surfaces: readonly A2UISurface[]): Record<string, Record<string, unknown>> {
|
|
183
|
+
const models: Record<string, Record<string, unknown>> = {};
|
|
184
|
+
for (const surface of surfaces) {
|
|
185
|
+
models[surface.id] = surface.dataModel;
|
|
186
|
+
}
|
|
187
|
+
return models;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function A2UISurfaceView({
|
|
191
|
+
surface,
|
|
192
|
+
catalogue,
|
|
193
|
+
model,
|
|
194
|
+
setModel,
|
|
195
|
+
onAction,
|
|
196
|
+
resolveDataSource,
|
|
197
|
+
debounceMs,
|
|
198
|
+
}: {
|
|
199
|
+
surface: A2UISurface;
|
|
200
|
+
catalogue: A2UICatalogue;
|
|
201
|
+
model: Record<string, unknown>;
|
|
202
|
+
setModel: (updater: (model: Record<string, unknown>) => Record<string, unknown>) => void;
|
|
203
|
+
onAction: ((action: A2UIAction) => void) | undefined;
|
|
204
|
+
resolveDataSource: (sourceId: string) => DataSource<A2UIRow> | undefined;
|
|
205
|
+
debounceMs: number;
|
|
206
|
+
}) {
|
|
207
|
+
const context: NodeContext = {
|
|
208
|
+
surface,
|
|
209
|
+
catalogue,
|
|
210
|
+
model,
|
|
211
|
+
setModel,
|
|
212
|
+
onAction,
|
|
213
|
+
resolveDataSource,
|
|
214
|
+
debounceMs,
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
if (!surface.root) {
|
|
218
|
+
return (
|
|
219
|
+
<div data-slot="a2ui-surface" data-surface-id={surface.id}>
|
|
220
|
+
<A2UIErrorCard
|
|
221
|
+
title="Empty A2UI surface"
|
|
222
|
+
detail={`Surface "${surface.id}" has no root component. Send updateComponents with a component whose id is "root", or name one in createSurface.`}
|
|
223
|
+
/>
|
|
224
|
+
</div>
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return (
|
|
229
|
+
<div data-slot="a2ui-surface" data-surface-id={surface.id} className="flex min-w-0 flex-col">
|
|
230
|
+
<A2UINode context={context} id={surface.root} contextPath="/" ancestors={[]} depth={0} />
|
|
231
|
+
</div>
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function A2UINode({
|
|
236
|
+
context,
|
|
237
|
+
id,
|
|
238
|
+
contextPath,
|
|
239
|
+
ancestors,
|
|
240
|
+
depth,
|
|
241
|
+
}: {
|
|
242
|
+
context: NodeContext;
|
|
243
|
+
id: string;
|
|
244
|
+
contextPath: string;
|
|
245
|
+
ancestors: readonly string[];
|
|
246
|
+
depth: number;
|
|
247
|
+
}) {
|
|
248
|
+
const spec = context.surface.components.get(id);
|
|
249
|
+
|
|
250
|
+
if (!spec) {
|
|
251
|
+
return <A2UIErrorCard title="Unknown component id" detail={`No component "${id}" in this surface.`} />;
|
|
252
|
+
}
|
|
253
|
+
if (ancestors.includes(id)) {
|
|
254
|
+
return (
|
|
255
|
+
<A2UIErrorCard
|
|
256
|
+
title="Circular component reference"
|
|
257
|
+
detail={`"${id}" is already an ancestor of itself: ${[...ancestors, id].join(" → ")}.`}
|
|
258
|
+
componentId={id}
|
|
259
|
+
/>
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
if (depth > MAX_DEPTH) {
|
|
263
|
+
return (
|
|
264
|
+
<A2UIErrorCard
|
|
265
|
+
title="Component tree too deep"
|
|
266
|
+
detail={`More than ${MAX_DEPTH} levels below the root.`}
|
|
267
|
+
componentId={id}
|
|
268
|
+
/>
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const declaration = readComponent(spec);
|
|
273
|
+
if (!declaration) {
|
|
274
|
+
return (
|
|
275
|
+
<A2UIErrorCard
|
|
276
|
+
title="Component has no type"
|
|
277
|
+
detail="Expected `component` to be a catalogue name, or an object keyed by one."
|
|
278
|
+
componentId={id}
|
|
279
|
+
/>
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const entry = context.catalogue[declaration.name];
|
|
284
|
+
if (!entry) {
|
|
285
|
+
return (
|
|
286
|
+
<A2UIErrorCard
|
|
287
|
+
title={`Unknown component "${declaration.name}"`}
|
|
288
|
+
detail={`The catalogue has: ${Object.keys(context.catalogue).sort().join(", ")}.`}
|
|
289
|
+
componentId={id}
|
|
290
|
+
/>
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Resolve bindings first, then normalise actions, then validate. A view
|
|
295
|
+
// therefore only ever sees values that passed the entry's schema.
|
|
296
|
+
const raw = declaration.properties;
|
|
297
|
+
const bindings: Record<string, string> = {};
|
|
298
|
+
const resolved: Record<string, unknown> = {};
|
|
299
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
300
|
+
if (key === "children") {
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
if (isDataBinding(value)) {
|
|
304
|
+
bindings[key] = resolvePointer(value.path, contextPath);
|
|
305
|
+
}
|
|
306
|
+
const next = resolveValue(value, context.model, contextPath);
|
|
307
|
+
resolved[key] = key.startsWith("on") ? readAction(next) : next;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const parsed = entry.props.safeParse(resolved);
|
|
311
|
+
if (!parsed.success) {
|
|
312
|
+
const issues = parsed.error.issues
|
|
313
|
+
.slice(0, 4)
|
|
314
|
+
.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
|
|
315
|
+
.join("; ");
|
|
316
|
+
return (
|
|
317
|
+
<A2UIErrorCard
|
|
318
|
+
title={`Invalid props for ${declaration.name}`}
|
|
319
|
+
detail={issues}
|
|
320
|
+
componentId={id}
|
|
321
|
+
/>
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const node: A2UINodeInfo = {
|
|
326
|
+
surfaceId: context.surface.id,
|
|
327
|
+
componentId: id,
|
|
328
|
+
dataContextPath: contextPath,
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
const children =
|
|
332
|
+
entry.children === "many"
|
|
333
|
+
? renderChildren(context, spec, raw, contextPath, [...ancestors, id], depth)
|
|
334
|
+
: [];
|
|
335
|
+
|
|
336
|
+
const emit = (
|
|
337
|
+
action: A2UIResolvedAction | undefined,
|
|
338
|
+
fallbackName: string,
|
|
339
|
+
payload?: Record<string, unknown>,
|
|
340
|
+
) => {
|
|
341
|
+
context.onAction?.({
|
|
342
|
+
surfaceId: context.surface.id,
|
|
343
|
+
componentId: id,
|
|
344
|
+
name: action?.name ?? fallbackName,
|
|
345
|
+
payload: { ...(action?.context ?? {}), ...(payload ?? {}) },
|
|
346
|
+
});
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
const writeBack = (property: string, value: unknown) => {
|
|
350
|
+
const pointer = bindings[property];
|
|
351
|
+
if (!pointer) {
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
context.setModel((model) => setAtPointer(model, pointer, value));
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
const View = entry.view as React.FC<{
|
|
358
|
+
props: unknown;
|
|
359
|
+
node: A2UINodeInfo;
|
|
360
|
+
children: React.ReactNode[];
|
|
361
|
+
dataModel: Record<string, unknown>;
|
|
362
|
+
emit: typeof emit;
|
|
363
|
+
writeBack: typeof writeBack;
|
|
364
|
+
resolveDataSource: (sourceId: string) => DataSource<A2UIRow> | undefined;
|
|
365
|
+
debounceMs: number;
|
|
366
|
+
}>;
|
|
367
|
+
|
|
368
|
+
return (
|
|
369
|
+
<View
|
|
370
|
+
props={parsed.data}
|
|
371
|
+
node={node}
|
|
372
|
+
dataModel={context.model}
|
|
373
|
+
emit={emit}
|
|
374
|
+
writeBack={writeBack}
|
|
375
|
+
resolveDataSource={context.resolveDataSource}
|
|
376
|
+
debounceMs={context.debounceMs}
|
|
377
|
+
>
|
|
378
|
+
{children}
|
|
379
|
+
</View>
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Render a component's children.
|
|
385
|
+
*
|
|
386
|
+
* An explicit list renders each id once. A template renders the referenced
|
|
387
|
+
* component once per item in a bound array, each with its own data context, so
|
|
388
|
+
* an agent describes a row of a table or a list item once instead of repeating
|
|
389
|
+
* it per item.
|
|
390
|
+
*/
|
|
391
|
+
function renderChildren(
|
|
392
|
+
context: NodeContext,
|
|
393
|
+
spec: A2UIComponentSpec,
|
|
394
|
+
properties: Record<string, unknown>,
|
|
395
|
+
contextPath: string,
|
|
396
|
+
ancestors: readonly string[],
|
|
397
|
+
depth: number,
|
|
398
|
+
): React.ReactNode[] {
|
|
399
|
+
const children = readChildren(spec, properties);
|
|
400
|
+
if (children.kind === "none") {
|
|
401
|
+
return [];
|
|
402
|
+
}
|
|
403
|
+
if (children.kind === "list") {
|
|
404
|
+
return children.ids.map((childId) => (
|
|
405
|
+
<A2UINode
|
|
406
|
+
key={childId}
|
|
407
|
+
context={context}
|
|
408
|
+
id={childId}
|
|
409
|
+
contextPath={contextPath}
|
|
410
|
+
ancestors={ancestors}
|
|
411
|
+
depth={depth + 1}
|
|
412
|
+
/>
|
|
413
|
+
));
|
|
414
|
+
}
|
|
415
|
+
const pointer = resolvePointer(children.path, contextPath);
|
|
416
|
+
const items = resolveValue({ path: children.path }, context.model, contextPath);
|
|
417
|
+
if (!Array.isArray(items)) {
|
|
418
|
+
return [
|
|
419
|
+
<A2UIErrorCard
|
|
420
|
+
key="template-error"
|
|
421
|
+
title="Template list is not an array"
|
|
422
|
+
detail={`"${pointer}" does not resolve to an array in the data model.`}
|
|
423
|
+
/>,
|
|
424
|
+
];
|
|
425
|
+
}
|
|
426
|
+
return items.map((_item, index) => (
|
|
427
|
+
<A2UINode
|
|
428
|
+
key={`${children.componentId}:${index}`}
|
|
429
|
+
context={context}
|
|
430
|
+
id={children.componentId}
|
|
431
|
+
contextPath={`${pointer === "/" ? "" : pointer}/${index}`}
|
|
432
|
+
ancestors={ancestors}
|
|
433
|
+
depth={depth + 1}
|
|
434
|
+
/>
|
|
435
|
+
));
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export { A2UI_DEFAULT_SURFACE_ID };
|