diff --git a/v2/sdui/renderers/lit/src/AgDynamicRenderer.ts b/v2/sdui/renderers/lit/src/AgDynamicRenderer.ts index f6b33f47e..5c981f119 100644 --- a/v2/sdui/renderers/lit/src/AgDynamicRenderer.ts +++ b/v2/sdui/renderers/lit/src/AgDynamicRenderer.ts @@ -236,7 +236,7 @@ function renderNode( .errorMessage=${node.errorMessage ?? nothing} .helpText=${node.helpText ?? nothing} @click=${() => doDispatch(node.on_click, actions)} - @change=${() => doDispatch(node.on_change, actions)} + @change=${(e: Event) => doDispatch(node.on_change, actions, { id: node.id, value: (e as CustomEvent<{ checked: boolean; value: string }>).detail?.checked })} >`; case 'AgCopyButton': @@ -390,7 +390,7 @@ function renderNode( .errorMessage=${node.errorMessage ?? nothing} .helpText=${node.helpText ?? nothing} @click=${() => doDispatch(node.on_click, actions)} - @change=${() => doDispatch(node.on_change, actions)} + @change=${(e: Event) => doDispatch(node.on_change, actions, { id: node.id, value: (e as CustomEvent<{ value: string }>).detail?.value ?? (e as unknown as { target?: { value?: string } }).target?.value ?? '' })} >`; case 'AgIntlFormatter': @@ -520,7 +520,7 @@ function renderNode( .errorMessage=${node.errorMessage ?? nothing} .helpText=${node.helpText ?? nothing} @click=${() => doDispatch(node.on_click, actions)} - @change=${() => doDispatch(node.on_change, actions)} + @change=${(e: Event) => doDispatch(node.on_change, actions, { id: node.id, value: (e as CustomEvent<{ value: string }>).detail?.value ?? '' })} >`; case 'AgRating': @@ -559,7 +559,7 @@ function renderNode( .errorMessage=${node.errorMessage ?? nothing} .helpText=${node.helpText ?? nothing} @click=${() => doDispatch(node.on_click, actions)} - @change=${() => doDispatch(node.on_change, actions)} + @change=${(e: Event) => doDispatch(node.on_change, actions, { id: node.id, value: (e as CustomEvent<{ value: string | string[] }>).detail?.value ?? '' })} >`; case 'AgSelectionButton': @@ -583,7 +583,7 @@ function renderNode( .values=${node.values ?? nothing} .disabled=${node.disabled ?? false} .required=${node.required ?? false} - @selection-change=${(e: Event) => doDispatch(node.on_change, actions, (e as CustomEvent<{value: string}>).detail.value)} + @selection-change=${(e: Event) => doDispatch(node.on_change, actions, { id: node.id, value: (e as CustomEvent<{ value: string }>).detail?.value })} >${renderChildren(node.children)}`; case 'AgSelectionCard': @@ -605,7 +605,7 @@ function renderNode( .values=${node.values ?? nothing} .disabled=${node.disabled ?? false} .required=${node.required ?? false} - @selection-change=${(e: Event) => doDispatch(node.on_change, actions, (e as CustomEvent<{value: string}>).detail.value)} + @selection-change=${(e: Event) => doDispatch(node.on_change, actions, { id: node.id, value: (e as CustomEvent<{ value: string }>).detail?.value })} >${renderChildren(node.children)}`; case 'AgSkeletonLoader': @@ -679,7 +679,7 @@ function renderNode( .name=${node.name ?? nothing} .value=${node.value ?? nothing} @click=${() => doDispatch(node.on_click, actions)} - @toggle-change=${() => doDispatch(node.on_change, actions)} + @toggle-change=${(e: Event) => doDispatch(node.on_change, actions, { id: node.id, value: (e as CustomEvent<{ checked: boolean; value: string }>).detail?.checked })} >`; case 'AgTooltip': diff --git a/v2/sdui/renderers/react/src/AgDynamicRenderer.tsx b/v2/sdui/renderers/react/src/AgDynamicRenderer.tsx index ffd23be6b..062e46f25 100644 --- a/v2/sdui/renderers/react/src/AgDynamicRenderer.tsx +++ b/v2/sdui/renderers/react/src/AgDynamicRenderer.tsx @@ -286,7 +286,7 @@ function renderNode( errorMessage={node.errorMessage} helpText={node.helpText} onClick={() => dispatch(node.on_click, actions)} - onChange={() => dispatch(node.on_change, actions)} /> + onChange={(e) => dispatch(node.on_change, actions, { id: node.id, value: (e as CustomEvent<{ checked: boolean; value: string }>).detail?.checked })} /> ); case 'AgCopyButton': @@ -476,7 +476,7 @@ function renderNode( errorMessage={node.errorMessage} helpText={node.helpText} onClick={() => dispatch(node.on_click, actions)} - onChange={() => dispatch(node.on_change, actions)} /> + onChange={(e) => dispatch(node.on_change, actions, { id: node.id, value: ((e as unknown) as React.ChangeEvent).target?.value ?? ((e as unknown) as CustomEvent<{ value: string }>).detail?.value ?? '' })} /> ); case 'AgIntlFormatter': @@ -644,7 +644,7 @@ function renderNode( errorMessage={node.errorMessage} helpText={node.helpText} onClick={() => dispatch(node.on_click, actions)} - onChange={() => dispatch(node.on_change, actions)} /> + onChange={(e) => dispatch(node.on_change, actions, { id: node.id, value: (e as CustomEvent<{ value: string }>).detail?.value ?? '' })} /> ); case 'AgRating': @@ -687,7 +687,7 @@ function renderNode( errorMessage={node.errorMessage} helpText={node.helpText} onClick={() => dispatch(node.on_click, actions)} - onChange={() => dispatch(node.on_change, actions)} /> + onChange={(e) => dispatch(node.on_change, actions, { id: node.id, value: ((e as unknown) as { value?: string | string[] })?.value ?? '' })} /> ); case 'AgSelectionButton': @@ -718,7 +718,7 @@ function renderNode( values={node.values} disabled={node.disabled} required={node.required} - onSelectionChange={(e) => dispatch(node.on_change, actions, (e as CustomEvent<{value: string}>).detail.value)} + onSelectionChange={(e) => dispatch(node.on_change, actions, { id: node.id, value: (e as CustomEvent<{ value: string }>).detail?.value })} > {renderChildren(node.children)} @@ -750,7 +750,7 @@ function renderNode( values={node.values} disabled={node.disabled} required={node.required} - onSelectionChange={(e) => dispatch(node.on_change, actions, (e as CustomEvent<{value: string}>).detail.value)} + onSelectionChange={(e) => dispatch(node.on_change, actions, { id: node.id, value: (e as CustomEvent<{ value: string }>).detail?.value })} > {renderChildren(node.children)} @@ -847,7 +847,7 @@ function renderNode( name={node.name} value={node.value} onClick={() => dispatch(node.on_click, actions)} - onToggleChange={() => dispatch(node.on_change, actions)} /> + onToggleChange={(e) => dispatch(node.on_change, actions, { id: node.id, value: (e as CustomEvent<{ checked: boolean; value: string }>).detail?.checked })} /> ); case 'AgTooltip': diff --git a/v2/sdui/renderers/vue/src/AgDynamicRenderer.ts b/v2/sdui/renderers/vue/src/AgDynamicRenderer.ts index ce1e06fbc..3b9690d0c 100644 --- a/v2/sdui/renderers/vue/src/AgDynamicRenderer.ts +++ b/v2/sdui/renderers/vue/src/AgDynamicRenderer.ts @@ -286,7 +286,7 @@ function renderNode( errorMessage: node.errorMessage, helpText: node.helpText, onClick: () => doDispatch(node.on_click, actions), - onChange: () => doDispatch(node.on_change, actions), + onChange: (e: unknown) => doDispatch(node.on_change, actions, { id: node.id, value: (e as unknown as { checked?: boolean })?.checked }), }, ); @@ -480,7 +480,7 @@ function renderNode( errorMessage: node.errorMessage, helpText: node.helpText, onClick: () => doDispatch(node.on_click, actions), - onChange: () => doDispatch(node.on_change, actions), + onChange: (e: unknown) => doDispatch(node.on_change, actions, { id: node.id, value: (e as unknown as { value?: string; target?: { value?: string } })?.value ?? (e as unknown as { target?: { value?: string } })?.target?.value ?? '' }), }, ); @@ -646,7 +646,7 @@ function renderNode( errorMessage: node.errorMessage, helpText: node.helpText, onClick: () => doDispatch(node.on_click, actions), - onChange: () => doDispatch(node.on_change, actions), + onChange: (e: unknown) => doDispatch(node.on_change, actions, { id: node.id, value: (e as unknown as { value?: string })?.value ?? '' }), }, ); @@ -691,7 +691,7 @@ function renderNode( errorMessage: node.errorMessage, helpText: node.helpText, onClick: () => doDispatch(node.on_click, actions), - onChange: () => doDispatch(node.on_change, actions), + onChange: (e: unknown) => doDispatch(node.on_change, actions, { id: node.id, value: (e as unknown as { value?: string | string[] })?.value ?? '' }), }, ); @@ -722,7 +722,7 @@ function renderNode( values: node.values, disabled: node.disabled, required: node.required, - onSelectionChange: (e: Event) => doDispatch(node.on_change, actions, (e as unknown as {value: string}).value), + onSelectionChange: (e: unknown) => doDispatch(node.on_change, actions, { id: node.id, value: (e as unknown as { value?: string })?.value }), }, { default: () => renderChildren(node.children) }, ); @@ -752,7 +752,7 @@ function renderNode( values: node.values, disabled: node.disabled, required: node.required, - onSelectionChange: (e: Event) => doDispatch(node.on_change, actions, (e as unknown as {value: string}).value), + onSelectionChange: (e: unknown) => doDispatch(node.on_change, actions, { id: node.id, value: (e as unknown as { value?: string })?.value }), }, { default: () => renderChildren(node.children) }, ); @@ -850,7 +850,7 @@ function renderNode( name: node.name, value: node.value, onClick: () => doDispatch(node.on_click, actions), - onToggleChange: () => doDispatch(node.on_change, actions), + onToggleChange: (e: unknown) => doDispatch(node.on_change, actions, { id: node.id, value: (e as unknown as { checked?: boolean })?.checked }), }, ); diff --git a/v2/sdui/schema/SPECIFICATION.md b/v2/sdui/schema/SPECIFICATION.md index 6df164fae..480569257 100644 --- a/v2/sdui/schema/SPECIFICATION.md +++ b/v2/sdui/schema/SPECIFICATION.md @@ -283,14 +283,83 @@ function dispatch(alias: string | undefined, actions: Actions): void { } ``` -For events that carry a payload (currently `onSelectionChange`), the renderer emits a one-argument -lambda and passes the extracted value to the action function: +For events that carry a payload (all form input nodes), the renderer emits a one-argument lambda +and passes a structured payload to the action function. The payload shape is standardized across +all input components and all three renderers: ```ts -// React: -onSelectionChange={(e) => dispatch(node.on_change, actions, (e as CustomEvent<{value:string}>).detail.value)} -// Vue (wrappers emit detail directly, not wrapped in CustomEvent): -onSelectionChange={(e) => dispatch(node.on_change, actions, (e as {value:string}).value)} +{ id: string; value: unknown } +``` + +- `id` is the node's `id` string, allowing the consumer to identify which field changed without + inspecting event targets. +- `value` is the semantic current value of the input: a `string` for text, select, and radio; a + `boolean` (`checked`) for checkbox and toggle; `string | string[]` for multi-select. + +```ts +// React — AgInput, AgRadio, AgSelect, AgCheckbox, AgToggle, AgSelectionButtonGroup, AgSelectionCardGroup: +onChange={(e) => dispatch(node.on_change, actions, { id: node.id, value: ... })} + +// Vue (wrappers emit detail directly as first arg): +onChange: (detail: unknown) => doDispatch(node.on_change, actions, { id: node.id, value: ... }) + +// Lit (native CustomEvent): +@change=${(e: Event) => doDispatch(node.on_change, actions, { id: node.id, value: ... })} +``` + +This payload shape enables questionnaire-style UIs to accumulate answers without parsing raw DOM +events. See section 5.4 for the recommended integration pattern. + +### 5.4 Questionnaire / Adaptive-Flow Integration Pattern + +For multi-step UIs where the next screen depends on accumulated answers (health intake forms, +onboarding surveys, insurance applications), the renderer stays stateless. The consumer owns +the transition logic: + +``` +1. User changes an input + → on_change fires with { id: node.id, value: currentValue } + → consumer accumulates: answers[id] = value + +2. User clicks "Next" (or any navigation alias) + → on_click fires (no payload) + → consumer POSTs accumulated answers to server/LLM endpoint + → server returns next AgNode[] (only the next screen's nodes) + → consumer calls setNodes(nextNodes) + → renderer displays the next screen +``` + +The renderer never needs to know about steps, conditions, or skip logic. All branching +intelligence lives server-side (or in an LLM prompt). This keeps the renderer predictable +and testable: given `nodes[]` and `actions{}`, the output is deterministic. + +Example consumer skeleton (React): + +```tsx +const [nodes, setNodes] = useState(initialNodes); +const [answers, setAnswers] = useState>({}); + +const actions = { + NEXT_STEP: async () => { + const nextNodes = await fetch('/api/next-step', { + method: 'POST', + body: JSON.stringify(answers), + }).then(r => r.json()); + setNodes(nextNodes); + }, +}; + +// on_change handlers accumulate answers — no application code needed in the renderer +const changeActions = new Proxy(actions, { + get: (target, alias: string) => + alias in target + ? target[alias as keyof typeof target] + : (payload: { id: string; value: unknown }) => { + setAnswers(prev => ({ ...prev, [payload.id]: payload.value })); + }, +}); + + ``` ### 5.2 XSS Boundary diff --git a/v2/sdui/schema/scripts/codegen.config.ts b/v2/sdui/schema/scripts/codegen.config.ts index de6a18d3b..b08803158 100644 --- a/v2/sdui/schema/scripts/codegen.config.ts +++ b/v2/sdui/schema/scripts/codegen.config.ts @@ -98,27 +98,112 @@ export const actionAliasMap: Record = { /** * actionPayloadMap: maps function prop names to a JS expression that extracts - * a serializable payload from the event argument. + * a serializable payload from the event argument (React + Lit default). * * When an event is listed here, the renderer emits a one-arg lambda * `(e) => dispatch(node.alias, actions, )` * instead of the zero-arg form `() => dispatch(node.alias, actions)`. * - * The expression is evaluated in a context where `e` is the raw event. + * Per-component overrides in componentActionPayloadMap take priority. */ -export const actionPayloadMap: Record = { - // SelectionChangeEvent.detail.value is the selected card's value string - onSelectionChange: '(e as CustomEvent<{value: string}>).detail.value', -}; +export const actionPayloadMap: Record = {}; /** * vueActionPayloadMap: Vue-specific overrides for actionPayloadMap. * Vue wrappers emit detail directly (not wrapped in CustomEvent), so * handlers receive the detail object as the first argument, not a CustomEvent. + * Per-component overrides in vueComponentActionPayloadMap take priority. + */ +export const vueActionPayloadMap: Record = {}; + +/** + * componentActionPayloadMap: per-component, per-sourceName payload expressions + * for the React renderer. Takes priority over actionPayloadMap. + * + * All form input nodes emit { id: node.id, value: } so that + * consumers can accumulate questionnaire answers without parsing DOM events. + * node.id and node. are in scope in the generated lambda. */ -export const vueActionPayloadMap: Record = { - // Vue emits detail directly: e IS the SelectionChangeEventDetail - onSelectionChange: '(e as unknown as {value: string}).value', +export const componentActionPayloadMap: Record> = { + AgCheckbox: { + onChange: `{ id: node.id, value: (e as CustomEvent<{ checked: boolean; value: string }>).detail?.checked }`, + }, + AgInput: { + onChange: `{ id: node.id, value: ((e as unknown) as React.ChangeEvent).target?.value ?? ((e as unknown) as CustomEvent<{ value: string }>).detail?.value ?? '' }`, + }, + AgRadio: { + onChange: `{ id: node.id, value: (e as CustomEvent<{ value: string }>).detail?.value ?? '' }`, + }, + AgSelect: { + onChange: `{ id: node.id, value: ((e as unknown) as { value?: string | string[] })?.value ?? '' }`, + }, + AgToggle: { + onToggleChange: `{ id: node.id, value: (e as CustomEvent<{ checked: boolean; value: string }>).detail?.checked }`, + }, + AgSelectionButtonGroup: { + onSelectionChange: `{ id: node.id, value: (e as CustomEvent<{ value: string }>).detail?.value }`, + }, + AgSelectionCardGroup: { + onSelectionChange: `{ id: node.id, value: (e as CustomEvent<{ value: string }>).detail?.value }`, + }, +}; + +/** + * vueComponentActionPayloadMap: per-component Vue payload expressions. + * Vue wrappers emit detail as the first argument (not wrapped in CustomEvent). + * All form inputs use the same { id, value } shape as React. + */ +export const vueComponentActionPayloadMap: Record> = { + AgCheckbox: { + onChange: `{ id: node.id, value: (e as unknown as { checked?: boolean })?.checked }`, + }, + AgInput: { + onChange: `{ id: node.id, value: (e as unknown as { value?: string; target?: { value?: string } })?.value ?? (e as unknown as { target?: { value?: string } })?.target?.value ?? '' }`, + }, + AgRadio: { + onChange: `{ id: node.id, value: (e as unknown as { value?: string })?.value ?? '' }`, + }, + AgSelect: { + onChange: `{ id: node.id, value: (e as unknown as { value?: string | string[] })?.value ?? '' }`, + }, + AgToggle: { + onToggleChange: `{ id: node.id, value: (e as unknown as { checked?: boolean })?.checked }`, + }, + AgSelectionButtonGroup: { + onSelectionChange: `{ id: node.id, value: (e as unknown as { value?: string })?.value }`, + }, + AgSelectionCardGroup: { + onSelectionChange: `{ id: node.id, value: (e as unknown as { value?: string })?.value }`, + }, +}; + +/** + * litComponentActionPayloadMap: per-component Lit payload expressions. + * Lit fires native CustomEvents from web components. + * All form inputs use the same { id, value } shape as React. + */ +export const litComponentActionPayloadMap: Record> = { + AgCheckbox: { + onChange: `{ id: node.id, value: (e as CustomEvent<{ checked: boolean; value: string }>).detail?.checked }`, + }, + AgInput: { + onChange: `{ id: node.id, value: (e as CustomEvent<{ value: string }>).detail?.value ?? (e as unknown as { target?: { value?: string } }).target?.value ?? '' }`, + }, + AgRadio: { + onChange: `{ id: node.id, value: (e as CustomEvent<{ value: string }>).detail?.value ?? '' }`, + }, + AgSelect: { + onChange: `{ id: node.id, value: (e as CustomEvent<{ value: string | string[] }>).detail?.value ?? '' }`, + }, + AgToggle: { + onToggleChange: `{ id: node.id, value: (e as CustomEvent<{ checked: boolean; value: string }>).detail?.checked }`, + }, + AgSelectionButtonGroup: { + onSelectionChange: `{ id: node.id, value: (e as CustomEvent<{ value: string }>).detail?.value }`, + }, + AgSelectionCardGroup: { + onSelectionChange: `{ id: node.id, value: (e as CustomEvent<{ value: string }>).detail?.value }`, + }, }; /** diff --git a/v2/sdui/schema/scripts/codegen.ts b/v2/sdui/schema/scripts/codegen.ts index 119ac629b..24c298e80 100644 --- a/v2/sdui/schema/scripts/codegen.ts +++ b/v2/sdui/schema/scripts/codegen.ts @@ -6,7 +6,7 @@ import { Project, type Type, type InterfaceDeclaration, type Symbol as MorphSymb import { resolve, dirname } from 'path'; import { fileURLToPath } from 'url'; import { writeFileSync } from 'fs'; -import { omitConfig, actionAliasMap, actionPayloadMap, vueActionPayloadMap, typeOverrides, skipComponents, rendererSlotConfig, reactPropRenames, rendererPrimitives, noUndefinedProps, type RendererSlot, type RendererPrimitive } from './codegen.config.js'; +import { omitConfig, actionAliasMap, actionPayloadMap, vueActionPayloadMap, componentActionPayloadMap, vueComponentActionPayloadMap, litComponentActionPayloadMap, typeOverrides, skipComponents, rendererSlotConfig, reactPropRenames, rendererPrimitives, noUndefinedProps, type RendererSlot, type RendererPrimitive } from './codegen.config.js'; // scripts/ -> schema/ -> sdui/ -> v2/ -> agnosticui/ export const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../../..'); @@ -330,7 +330,7 @@ function generateReactCase(c: ComponentData): string { } } for (const a of c.actions) { - const payloadExpr = actionPayloadMap[a.sourceName]; + const payloadExpr = componentActionPayloadMap[sdui]?.[a.sourceName] ?? actionPayloadMap[a.sourceName]; if (payloadExpr) { propLines.push(` ${a.sourceName}={(e) => dispatch(node.${quoteName(a.alias)}, actions, ${payloadExpr})}`); } else { @@ -466,9 +466,9 @@ function generateVueCase(c: ComponentData): string { propsObj.push(` ${p.name}: node.${quoteName(p.name)},`); } for (const a of c.actions) { - const payloadExpr = vueActionPayloadMap[a.sourceName] ?? actionPayloadMap[a.sourceName]; + const payloadExpr = vueComponentActionPayloadMap[sdui]?.[a.sourceName] ?? vueActionPayloadMap[a.sourceName] ?? actionPayloadMap[a.sourceName]; if (payloadExpr) { - propsObj.push(` ${a.sourceName}: (e: Event) => doDispatch(node.${quoteName(a.alias)}, actions, ${payloadExpr}),`); + propsObj.push(` ${a.sourceName}: (e: unknown) => doDispatch(node.${quoteName(a.alias)}, actions, ${payloadExpr}),`); } else { propsObj.push(` ${a.sourceName}: () => doDispatch(node.${quoteName(a.alias)}, actions),`); } @@ -619,7 +619,7 @@ function generateLitCase(c: ComponentData): string { return ` .${p.name}=\${node.${quoteName(p.name)}${def}}`; }); for (const a of c.actions) { - const payloadExpr = actionPayloadMap[a.sourceName]; + const payloadExpr = litComponentActionPayloadMap[sdui]?.[a.sourceName] ?? actionPayloadMap[a.sourceName]; if (payloadExpr) { attrLines.push(` @${a.litEvent}=\${(e: Event) => doDispatch(node.${a.alias}, actions, ${payloadExpr})}`); } else {