// Instant UI ⇄ Google A2UI v0.9 adapter — the PUBLIC, package-shipped home of // the projection (round #127; owner-approved 2026-07-25). Integrators import // `toA2UI` / `fromA2UI` from `@pouchy_ai/companion-sdk` (or the CDN bundle) and // put the adapter in front of any renderer built against the A2UI catalog — // no more vendoring the repo source, which was the one DX gap of the A2UI // alignment. // // WHY the projection exists: Pouchy's Instant UI is a NESTED, state-bag schema; // Google's A2UI v0.9 (the standard AGenUI implements) is a FLAT component list // with ID references, a separate dataModel, and JSON-Pointer bindings. Three // structural transforms: // (i) nested tree → flat components map + ID refs // (ii) state bag + bind/template → dataModel + {path:"/key"} JSON Pointer // (iii) Button's embedded action → A2UI event {name, context}, the action // carried in context so the host can dispatch it locally (no LLM // round-trip) — external A2UI agents just receive the event. // // Fidelity: concepts A2UI genuinely has no standard for (Progress, Accordion, // Chart's area/pie kinds, computed, `when`, Text tone, Slider step, TextField // placeholder/maxLength, Select placeholder) ride a per-component `_pouchy` // extension (A2UI renderers ignore unknown keys; fromA2UI reconstructs from // it). Input widgets otherwise emit their STANDARD A2UI fields — TextField // `variant` (longText|shortText), ChoicePicker `variant` (mutuallyExclusive), // and a single-placeholder Text template emits a real {path} binding — so an // external renderer honours the widget kind, not just a default. // `fromA2UI(toA2UI(x))` is a faithful round-trip (pinned by the app-side // adapter tests + the admin A2UI conformance probe). // // Spec conformance: component + field names below are verified against // AGenUI's agenui_catalog.json and google/A2UI v0.9 — Text/Button/Row/Column/ // Card/Tabs/Image(url,description)/Video(url)/AudioPlayer(url)/Slider/ // CheckBox/ChoicePicker/TextField/DateTimeInput/Divider(axis) are standard; // Table(columns,rows), Chart(chartType,data) and Markdown(content) are also // standard catalog entries, so they emit the standard shape while `_pouchy` // carries Pouchy's richer original for a lossless round-trip. Only Progress + // Accordion stay `pouchy/`-namespaced custom components. Centralized in the // A2UI map so a spec change is a one-line edit, never a transform rewrite. // // TYPES: the panel types below are a STRUCTURAL MIRROR of the app's canonical // genui schema (src/lib/genui/schema.ts) — the package must stay standalone // (no app imports; the npm build's rootDir is this directory), so the type // text is duplicated ON PURPOSE, with one deliberate loosening: a Button / // Table-row action is an opaque `EmbeddedAction { type }` here (the app's full // AgentAction union is app-internal; the adapter only carries actions // verbatim, never reads their fields). Drift is compile-enforced: the app-side // shim (src/lib/genui/a2ui-adapter.ts) passes its STRICT DynamicInterface // straight into `toA2UI` with no cast, so a mirror that goes incompatible or // loses a node kind fails `pnpm check`; the node-type CATALOG is additionally // pinned to the app's GENUI_NODE_SET by a2ui-sdk-drift.test.ts. import type { RenderInterfacePayload } from './protocol'; // ─── Instant UI panel types (mirror — see header) ──────────────────────────── export type StateValue = string | number | boolean; /** A prop value that is either a literal or a live reference to a state key. */ export type ValueRef = T | { $state: string }; /** An action embedded in a Button / Table row — carried OPAQUELY through the * projection (stuffed into the A2UI event context verbatim, restored on the * way back). The app narrows it to its full AgentAction union; hosts treat it * as data to hand back, never to interpret. */ export interface EmbeddedAction { type: string; } export type GenuiTextTone = 'default' | 'muted' | 'strong'; export type GenuiButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger'; export type GenuiProgressVariant = 'default' | 'affection' | 'energy' | 'health' | 'tier'; export interface GenuiTextNode { type: 'Text'; text?: string; /** Template with `{key}` placeholders resolved against interface state. */ template?: string; tone?: GenuiTextTone; } export interface GenuiButtonNode { type: 'Button'; label: string; variant?: GenuiButtonVariant; action: EmbeddedAction | null; } export interface GenuiProgressNode { type: 'Progress'; value: ValueRef; max?: number; variant?: GenuiProgressVariant; } export interface GenuiIconNode { type: 'Icon'; name: string; size?: number; } export interface GenuiSliderNode { type: 'Slider'; bind: string; min?: number; max?: number; step?: number; label?: string; } export interface GenuiSwitchNode { type: 'Switch'; bind: string; label?: string; } export interface GenuiSelectOption { value: string; label: string; } export interface GenuiSelectNode { type: 'Select'; bind: string; options: GenuiSelectOption[]; placeholder?: string; } export interface GenuiTextInputNode { type: 'TextInput'; bind: string; label?: string; placeholder?: string; multiline?: boolean; maxLength?: number; } export type GenuiGroupDirection = 'row' | 'column'; export interface GenuiGroupNode { type: 'Group'; direction: GenuiGroupDirection; gap?: number; wrap?: boolean; children: GenuiNode[]; } export interface GenuiImageNode { type: 'Image'; src: string; alt?: string; ratio?: number; } export interface GenuiVideoNode { type: 'Video'; src: string; ratio?: number; } export interface GenuiAudioNode { type: 'AudioPlayer'; src: string; title?: string; } export interface GenuiMarkdownNode { type: 'Markdown'; text: string; } export type GenuiChartKind = 'bar' | 'line' | 'area' | 'pie' | 'donut'; export interface GenuiChartDatum { label?: string; value: number; } export interface GenuiChartNode { type: 'Chart'; chart: GenuiChartKind; data: GenuiChartDatum[]; max?: number; title?: string; } export interface GenuiTableNode { type: 'Table'; columns: string[]; rows: string[][]; title?: string; rowActions?: (EmbeddedAction | null)[]; } export interface GenuiDatePickerNode { type: 'DatePicker'; bind: string; label?: string; min?: string; max?: string; } export interface GenuiWhen { key: string; equals?: StateValue; truthy?: boolean; } export interface GenuiTab { label: string; children: GenuiNode[]; } export interface GenuiTabsNode { type: 'Tabs'; tabs: GenuiTab[]; bind?: string; } export interface GenuiAccordionSection { label: string; children: GenuiNode[]; open?: boolean; } export interface GenuiAccordionNode { type: 'Accordion'; sections: GenuiAccordionSection[]; multi?: boolean; } export interface GenuiDividerNode { type: 'Divider'; } export interface GenuiCardNode { type: 'Card'; title?: string; children: GenuiNode[]; } export type GenuiNode = ( | GenuiTextNode | GenuiButtonNode | GenuiProgressNode | GenuiIconNode | GenuiSliderNode | GenuiSwitchNode | GenuiSelectNode | GenuiTextInputNode | GenuiGroupNode | GenuiImageNode | GenuiChartNode | GenuiTableNode | GenuiDatePickerNode | GenuiTabsNode | GenuiAccordionNode | GenuiDividerNode | GenuiCardNode | GenuiVideoNode | GenuiAudioNode | GenuiMarkdownNode ) & { when?: GenuiWhen }; export interface GenuiComputed { op: 'mul' | 'add' | 'sub' | 'div'; args: (string | number)[]; round?: number; } /** The Instant UI panel — what `companion.ui_action` carries in * `payload.interface` (typed strictly here; the event payload declares the * loose wire shape). */ export interface DynamicInterface { title?: string; nodes: GenuiNode[]; state?: Record; computed?: Record; reportChanges?: boolean; allowedActions?: string[]; } /** What `toA2UI` accepts: the strict panel type, or the loose * `payload.interface` exactly as `onRender` delivers it (already validated * and clamped server-side, so the structural cast inside is safe). */ export type InstantUIInput = DynamicInterface | RenderInterfacePayload['interface']; /** The Instant UI node-type catalog, as data — drift-pinned against the app's * canonical GENUI_NODE_SET so the mirror can never silently lag a new atom. */ export const INSTANT_UI_NODE_TYPES = [ 'Text', 'Button', 'Progress', 'Icon', 'Slider', 'Switch', 'Select', 'TextInput', 'Group', 'Image', 'Chart', 'Table', 'DatePicker', 'Tabs', 'Accordion', 'Divider', 'Card', 'Video', 'AudioPlayer', 'Markdown' ] as const; // ─── A2UI wire types (the subset we emit/accept) ────────────────────────────── /** A2UI binding: a literal, a JSON-Pointer data binding, or a function call. */ export type A2UIValue = StateValue | { path: string } | { call: string; args?: unknown }; export interface A2UIComponent { id: string; /** A2UI component type, e.g. "Text" / "Button" / "Row" / "pouchy/Progress". */ component: string; /** Standard A2UI property bag (component-specific; see the mapping per type). */ [key: string]: unknown; /** Pouchy-only reconstruction data for concepts A2UI has no standard for. * A2UI renderers ignore it; fromA2UI() uses it to rebuild the exact node. */ _pouchy?: Record; } /** An A2UI surface = a flat component map + a root id + the data model. The * `pouchy` block carries panel-level extensions A2UI has no slot for. */ export interface A2UISurface { root: string; components: Record; dataModel: Record; pouchy?: { title?: string; computed?: Record; reportChanges?: boolean; allowedActions?: string[]; }; } // Centralized so a spec correction never touches the transform logic below. const A2UI = { rowComponent: 'Row', columnComponent: 'Column', // Pouchy-custom components — NOT in the A2UI v0.9 catalog, so they're // namespaced and registered as custom components for external interop // (they carry semantics A2UI's standard catalog lacks). Progress + Accordion // have no standard A2UI equivalent; Table/Chart/Markdown DO (Table+Chart are // SDK/Playground catalog entries, Markdown is a Playground example), so those // use the standard names below and round-trip losslessly via `_pouchy`. progressComponent: 'pouchy/Progress', accordionComponent: 'pouchy/Accordion', // Standard-catalog component names (verified against AGenUI agenui_catalog.json // / google/A2UI v0.9: Table columns/rows, Chart chartType+data, Markdown content). chartComponent: 'Chart', tableComponent: 'Table', textComponent: 'Text', buttonComponent: 'Button', iconComponent: 'Icon', imageComponent: 'Image', sliderComponent: 'Slider', checkboxComponent: 'CheckBox', choicePickerComponent: 'ChoicePicker', textFieldComponent: 'TextField', dateTimeComponent: 'DateTimeInput', tabsComponent: 'Tabs', dividerComponent: 'Divider', cardComponent: 'Card', videoComponent: 'Video', audioComponent: 'AudioPlayer', markdownComponent: 'Markdown', // The event name carrying a Pouchy-embedded action. pouchyActionEvent: 'pouchyAction' } as const; /** JSON Pointer for a top-level dataModel key (keys are flat, no nesting). */ const ptr = (key: string): { path: string } => ({ path: `/${key}` }); const isPtr = (v: unknown): v is { path: string } => !!v && typeof v === 'object' && typeof (v as { path?: unknown }).path === 'string'; /** Key from a top-level JSON Pointer ("/qty" → "qty"). */ const ptrKey = (p: { path: string }): string => p.path.replace(/^\//, ''); /** If a template is exactly ONE whole-value placeholder ("{total}"), return the * key; else null. A2UI Text has no inline string interpolation, but its `text` * IS a DynamicString that accepts a {path} binding — so a single-placeholder * template can be emitted as a real binding an external renderer interpolates. * A mixed template ("¥{total} left") has no A2UI form and stays literal. */ const singlePlaceholderKey = (tmpl: string): string | null => { const m = /^\{([\w.-]+)\}$/.exec(tmpl.trim()); return m ? m[1] : null; }; // ─── toA2UI: Instant UI panel → A2UI surface ───────────────────────────────── export function toA2UI(input: InstantUIInput): A2UISurface { // The loose `payload.interface` shape is server-validated before it ever // reaches a host, so the structural narrow here is safe (see InstantUIInput). const iface = input as DynamicInterface; const components: Record = {}; let counter = 0; const nextId = (): string => `c${counter++}`; /** Emit one component (and its descendants), return its id. */ const emit = (node: GenuiNode): string => { const id = nextId(); const c = buildComponent(id, node, emit); if (node.when) c._pouchy = { ...(c._pouchy ?? {}), when: node.when }; components[id] = c; return id; }; // The surface root is a Column wrapping the top-level node list (A2UI surfaces // have a single root component). const childIds = iface.nodes.map(emit); const rootId = nextId(); components[rootId] = { id: rootId, component: A2UI.columnComponent, children: childIds }; const pouchy: A2UISurface['pouchy'] = {}; if (iface.title) pouchy.title = iface.title; if (iface.computed) pouchy.computed = iface.computed; if (iface.reportChanges) pouchy.reportChanges = true; if (iface.allowedActions) pouchy.allowedActions = iface.allowedActions; return { root: rootId, components, dataModel: { ...(iface.state ?? {}) }, ...(Object.keys(pouchy).length > 0 ? { pouchy } : {}) }; } /** Encode a Button's embedded action as an A2UI event (host dispatches it * locally from context — external agents just receive the event). */ const encodeAction = (action: EmbeddedAction | null) => action ? { event: { name: A2UI.pouchyActionEvent, context: { action } } } : null; /** Map Pouchy's chart kinds to A2UI's standard `chartType` (line | donut | bar). * A2UI has no area/pie, so area→line and pie→donut — the original kind is kept * in `_pouchy.chart` so fromA2UI restores it exactly. */ const A2UI_CHART_TYPE: Record = { bar: 'bar', line: 'line', area: 'line', pie: 'donut', donut: 'donut' }; /** Build A2UI's standard Chart `data` ({series:[{name,data:[{value,label}]}]}) * from Pouchy's number[] | {label?,value}[] so an external A2UI renderer draws * the series even without reading `_pouchy`. */ function toA2UIChartData(data: unknown): { series: Array<{ name: string; data: Array<{ value: number; label?: string }> }>; } { const arr = Array.isArray(data) ? data : []; const points = arr.map((d, i) => typeof d === 'number' ? { value: d, label: String(i + 1) } : { value: Number((d as { value?: unknown })?.value ?? 0), ...((d as { label?: unknown })?.label !== undefined ? { label: String((d as { label?: unknown }).label) } : {}) } ); return { series: [{ name: 'series', data: points }] }; } function buildComponent( id: string, node: GenuiNode, emit: (n: GenuiNode) => string ): A2UIComponent { switch (node.type) { case 'Text': { // A2UI Text's `text` is a DynamicString (literal | {path} | function). // A single-placeholder template ("{total}") becomes a real {path} // binding so an external renderer interpolates it live; a mixed template // stays literal (no A2UI inline-interp form). Tone + the literal-vs- // template distinction ride `_pouchy` so the round-trip is exact. const soleKey = node.template ? singlePlaceholderKey(node.template) : null; const text: A2UIValue = soleKey ? ptr(soleKey) : (node.template ?? node.text ?? ''); return { id, component: A2UI.textComponent, text, _pouchy: { kind: node.template ? 'template' : 'text', ...(node.tone ? { tone: node.tone } : {}) } }; } case 'Button': { // A2UI Button references a child component (the label Text). const labelId = emit({ type: 'Text', text: node.label }); return { id, component: A2UI.buttonComponent, child: labelId, action: encodeAction(node.action), ...(node.variant ? { variant: node.variant } : {}) }; } case 'Progress': return { id, component: A2UI.progressComponent, value: isStateRefVal(node.value) ? ptr(node.value.$state) : node.value, ...(node.max !== undefined ? { max: node.max } : {}), ...(node.variant ? { variant: node.variant } : {}) }; case 'Icon': return { id, component: A2UI.iconComponent, name: node.name, ...(node.size ? { size: node.size } : {}) }; case 'Slider': return { id, component: A2UI.sliderComponent, value: ptr(node.bind), ...(node.min !== undefined ? { min: node.min } : {}), ...(node.max !== undefined ? { max: node.max } : {}), ...(node.label ? { label: node.label } : {}), _pouchy: { ...(node.step !== undefined ? { step: node.step } : {}) } }; case 'Switch': return { id, component: A2UI.checkboxComponent, value: ptr(node.bind), ...(node.label ? { label: node.label } : {}), _pouchy: { kind: 'switch' } }; case 'Select': // Pouchy Select is single-select → A2UI ChoicePicker variant // 'mutuallyExclusive'. placeholder is non-standard → rides `_pouchy`. return { id, component: A2UI.choicePickerComponent, value: ptr(node.bind), options: node.options, variant: 'mutuallyExclusive', ...(node.placeholder ? { _pouchy: { placeholder: node.placeholder } } : {}) }; case 'TextInput': // A2UI TextField uses `variant` (longText | shortText | number | // obscured) instead of a multiline flag; placeholder + maxLength are // non-standard, so they ride `_pouchy` for the round-trip. return { id, component: A2UI.textFieldComponent, value: ptr(node.bind), variant: node.multiline ? 'longText' : 'shortText', ...(node.label ? { label: node.label } : {}), ...(node.placeholder !== undefined || node.maxLength !== undefined ? { _pouchy: { ...(node.placeholder ? { placeholder: node.placeholder } : {}), ...(node.maxLength !== undefined ? { maxLength: node.maxLength } : {}) } } : {}) }; case 'DatePicker': return { id, component: A2UI.dateTimeComponent, value: ptr(node.bind), ...(node.label ? { label: node.label } : {}), ...(node.min ? { min: node.min } : {}), ...(node.max ? { max: node.max } : {}) }; case 'Image': return { id, component: A2UI.imageComponent, url: node.src, ...(node.alt ? { description: node.alt } : {}), ...(node.ratio !== undefined ? { _pouchy: { ratio: node.ratio } } : {}) }; case 'Group': { // gap/wrap have no A2UI standard, so they ride `_pouchy` — but only emit // the extension when there's something to carry, so plain layout // wrappers (and the Column wrappers Tabs/Card synthesize) stay clean // instead of shipping an empty `_pouchy: {}`. const ext = { ...(node.gap !== undefined ? { gap: node.gap } : {}), ...(node.wrap ? { wrap: true } : {}) }; return { id, component: node.direction === 'row' ? A2UI.rowComponent : A2UI.columnComponent, children: node.children.map(emit), ...(Object.keys(ext).length > 0 ? { _pouchy: ext } : {}) }; } case 'Tabs': return { id, component: A2UI.tabsComponent, // A2UI tab = { title, child }; wrap each tab's node list in a Column. tabs: node.tabs.map((t) => ({ title: t.label, child: emit({ type: 'Group', direction: 'column', children: t.children }) })), ...(node.bind ? { _pouchy: { bind: node.bind } } : {}) }; case 'Accordion': return { id, component: A2UI.accordionComponent, _pouchy: { sections: node.sections.map((s) => ({ label: s.label, childIds: s.children.map(emit), ...(s.open ? { open: true } : {}) })), ...(node.multi ? { multi: true } : {}) } }; case 'Chart': // Emit the STANDARD A2UI Chart shape (chartType + data series) so an // external renderer draws it, AND carry Pouchy's richer original (area/pie // kinds, raw data, max) in `_pouchy` for a lossless round-trip back. return { id, component: A2UI.chartComponent, chartType: A2UI_CHART_TYPE[node.chart] ?? 'bar', data: toA2UIChartData(node.data), _pouchy: { chart: node.chart, data: node.data, ...(node.max !== undefined ? { max: node.max } : {}), ...(node.title ? { title: node.title } : {}) } }; case 'Table': return { id, component: A2UI.tableComponent, columns: node.columns, rows: node.rows, ...(node.title ? { _pouchy: { title: node.title } } : {}) }; case 'Video': return { id, component: A2UI.videoComponent, url: node.src, ...(node.ratio !== undefined ? { _pouchy: { ratio: node.ratio } } : {}) }; case 'AudioPlayer': return { id, component: A2UI.audioComponent, url: node.src, ...(node.title ? { _pouchy: { title: node.title } } : {}) }; case 'Markdown': // A2UI's Markdown field is `content` (not `text`). return { id, component: A2UI.markdownComponent, content: node.text }; case 'Divider': // A2UI Divider carries an `axis`; Pouchy only renders horizontal rules. return { id, component: A2UI.dividerComponent, axis: 'horizontal' }; case 'Card': // A2UI Card is single-child; wrap the node list in a Column and carry // the optional title on the extension (A2UI Card has no title slot). return { id, component: A2UI.cardComponent, child: emit({ type: 'Group', direction: 'column', children: node.children }), ...(node.title ? { _pouchy: { title: node.title } } : {}) }; default: // Unreachable for validated panels (the exhaustive union above). Kept // for LOOSE SDK input (InstantUIInput): an unknown atom degrades to an // empty Column instead of crashing the whole projection. return { id, component: A2UI.columnComponent, children: [] }; } } const isStateRefVal = (v: unknown): v is { $state: string } => !!v && typeof v === 'object' && typeof (v as { $state?: unknown }).$state === 'string'; // ─── fromA2UI: A2UI surface → Instant UI panel ─────────────────────────────── export function fromA2UI(surface: A2UISurface): DynamicInterface { const { components, root } = surface; const rebuild = (id: string): GenuiNode | null => { const c = components[id]; if (!c) return null; const node = rebuildNode(c, rebuild); if (!node) return null; const when = c._pouchy?.when; if (when) (node as GenuiNode).when = when as GenuiNode['when']; return node; }; const rootComp = components[root]; const nodes: GenuiNode[] = []; if (rootComp && Array.isArray(rootComp.children)) { for (const childId of rootComp.children as string[]) { const n = rebuild(childId); if (n) nodes.push(n); } } const iface: DynamicInterface = { nodes }; if (surface.pouchy?.title) iface.title = surface.pouchy.title; if (surface.dataModel && Object.keys(surface.dataModel).length > 0) iface.state = { ...surface.dataModel }; if (surface.pouchy?.computed) iface.computed = surface.pouchy.computed; if (surface.pouchy?.reportChanges) iface.reportChanges = true; if (surface.pouchy?.allowedActions) iface.allowedActions = surface.pouchy.allowedActions; return iface; } /** Decode an A2UI action event back to the embedded action. */ const decodeAction = (raw: unknown): EmbeddedAction | null => { const ev = (raw as { event?: { name?: string; context?: { action?: unknown } } } | null)?.event; if (ev?.name === A2UI.pouchyActionEvent && ev.context?.action) return ev.context.action as EmbeddedAction; return null; }; const bindFrom = (v: unknown): string => (isPtr(v) ? ptrKey(v) : ''); function rebuildNode(c: A2UIComponent, rebuild: (id: string) => GenuiNode | null): GenuiNode | null { const p = c._pouchy ?? {}; switch (c.component) { case A2UI.textComponent: { // `text` may be a literal string OR a {path} binding (a single- // placeholder template); reconstruct the "{key}" template from the // pointer in that case. let node: GenuiNode; if (p.kind === 'template') { const tmpl = isPtr(c.text) ? `{${ptrKey(c.text)}}` : typeof c.text === 'string' ? c.text : ''; node = { type: 'Text', template: tmpl }; } else { node = { type: 'Text', text: typeof c.text === 'string' ? c.text : '' }; } if (p.tone) (node as { tone?: unknown }).tone = p.tone; return node; } case A2UI.buttonComponent: { // The label is the child Text component A2UI Button references. const child = typeof c.child === 'string' ? rebuild(c.child) : null; const label = child && child.type === 'Text' ? (child.template ?? child.text ?? '') : ''; return { type: 'Button', label, ...(c.variant ? { variant: c.variant as never } : {}), action: decodeAction(c.action) } as GenuiNode; } case A2UI.progressComponent: return { type: 'Progress', value: isPtr(c.value) ? { $state: ptrKey(c.value) } : (c.value as number), ...(typeof c.max === 'number' ? { max: c.max } : {}), ...(c.variant ? { variant: c.variant as never } : {}) } as GenuiNode; case A2UI.iconComponent: return { type: 'Icon', name: String(c.name ?? ''), ...(typeof c.size === 'number' ? { size: c.size } : {}) }; case A2UI.sliderComponent: return { type: 'Slider', bind: bindFrom(c.value), ...(typeof c.min === 'number' ? { min: c.min } : {}), ...(typeof c.max === 'number' ? { max: c.max } : {}), ...(typeof p.step === 'number' ? { step: p.step } : {}), ...(c.label ? { label: c.label as string } : {}) } as GenuiNode; case A2UI.checkboxComponent: return { type: 'Switch', bind: bindFrom(c.value), ...(c.label ? { label: c.label as string } : {}) } as GenuiNode; case A2UI.choicePickerComponent: // placeholder rides `_pouchy`; `variant` (mutuallyExclusive) is the // single-select marker Pouchy already implies, so it's dropped here. return { type: 'Select', bind: bindFrom(c.value), options: (c.options as never) ?? [], ...(typeof p.placeholder === 'string' ? { placeholder: p.placeholder } : {}) } as GenuiNode; case A2UI.textFieldComponent: // multiline ← variant:'longText'; placeholder + maxLength ← `_pouchy`. return { type: 'TextInput', bind: bindFrom(c.value), ...(c.label ? { label: c.label as string } : {}), ...(typeof p.placeholder === 'string' ? { placeholder: p.placeholder } : {}), ...(c.variant === 'longText' ? { multiline: true } : {}), ...(typeof p.maxLength === 'number' ? { maxLength: p.maxLength } : {}) } as GenuiNode; case A2UI.dateTimeComponent: return { type: 'DatePicker', bind: bindFrom(c.value), ...(c.label ? { label: c.label as string } : {}), ...(c.min ? { min: c.min as string } : {}), ...(c.max ? { max: c.max as string } : {}) } as GenuiNode; case A2UI.imageComponent: return { type: 'Image', src: String(c.url ?? ''), ...(c.description ? { alt: c.description as string } : {}), ...(typeof p.ratio === 'number' ? { ratio: p.ratio } : {}) } as GenuiNode; case A2UI.rowComponent: case A2UI.columnComponent: { const children = Array.isArray(c.children) ? (c.children as string[]).map(rebuild).filter((n): n is GenuiNode => n !== null) : []; return { type: 'Group', direction: c.component === A2UI.rowComponent ? 'row' : 'column', ...(typeof p.gap === 'number' ? { gap: p.gap } : {}), ...(p.wrap ? { wrap: true } : {}), children } as GenuiNode; } case A2UI.tabsComponent: { const tabs = Array.isArray(c.tabs) ? (c.tabs as Array<{ title: string; child: string }>).map((t) => { const wrapper = rebuild(t.child); const children = wrapper && wrapper.type === 'Group' ? wrapper.children : []; return { label: t.title, children }; }) : []; return { type: 'Tabs', tabs, ...(p.bind ? { bind: p.bind as string } : {}) } as GenuiNode; } case A2UI.accordionComponent: { const sections = Array.isArray(p.sections) ? (p.sections as Array<{ label: string; childIds: string[]; open?: boolean }>).map((s) => ({ label: s.label, children: s.childIds.map(rebuild).filter((n): n is GenuiNode => n !== null), ...(s.open ? { open: true } : {}) })) : []; return { type: 'Accordion', sections, ...(p.multi ? { multi: true } : {}) } as GenuiNode; } case A2UI.chartComponent: { // Prefer Pouchy's lossless `_pouchy` original; when absent (an external // A2UI Chart), reconstruct from the standard chartType + data series. if (p.chart !== undefined || p.data !== undefined) { return { type: 'Chart', chart: p.chart as never, data: (p.data as never) ?? [], ...(typeof p.max === 'number' ? { max: p.max } : {}), ...(p.title ? { title: p.title as string } : {}) } as GenuiNode; } const series = ( c.data as { series?: Array<{ data?: Array<{ value?: number; label?: string }> }> } )?.series?.[0]?.data; const data = Array.isArray(series) ? series.map((pt) => ({ value: Number(pt?.value ?? 0), ...(pt?.label ? { label: pt.label } : {}) })) : []; return { type: 'Chart', chart: (c.chartType as never) ?? ('bar' as never), data: data as never } as GenuiNode; } case A2UI.tableComponent: return { type: 'Table', columns: (c.columns as never) ?? [], rows: (c.rows as never) ?? [], ...(p.title ? { title: p.title as string } : {}) } as GenuiNode; case A2UI.videoComponent: return { type: 'Video', src: String(c.url ?? ''), ...(typeof p.ratio === 'number' ? { ratio: p.ratio } : {}) } as GenuiNode; case A2UI.audioComponent: return { type: 'AudioPlayer', src: String(c.url ?? ''), ...(p.title ? { title: p.title as string } : {}) } as GenuiNode; case A2UI.markdownComponent: // Prefer A2UI's `content`; fall back to `text` when ingesting a surface // that used the older field name. return { type: 'Markdown', text: String(c.content ?? c.text ?? '') } as GenuiNode; case A2UI.dividerComponent: return { type: 'Divider' }; case A2UI.cardComponent: { const wrapper = typeof c.child === 'string' ? rebuild(c.child) : null; const children = wrapper && wrapper.type === 'Group' ? wrapper.children : []; return { type: 'Card', ...(p.title ? { title: p.title as string } : {}), children } as GenuiNode; } default: return null; } }