/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ /** * Widget DSL renderer. * * Walks a WidgetNode tree and emits matching React components. Data * bindings (`"$.foo.bar"` or `"foo.bar"`) resolve against the state * object the widget's handler returned. * * The renderer is intentionally minimal โ€” chrome only, no inline * styles, no client-defined CSS. Themes come from the host's Tailwind * tokens; variants/tones get mapped at the leaf. * * Spec: docs/architecture/ai-customization/03-ui-surface.md ยง3. */ import { useMemo } from 'react'; import { AlertCircle, ChevronDown, ChevronRight, Loader2 } from 'lucide-react'; import type { ButtonNode, ChartNode, EmptyStateNode, EntityListNode, ErrorBannerNode, FieldNode, GroupNode, KeyValueGridNode, MarkdownNode, SpinnerNode, StackNode, TableNode, TabsNode, TextNode, TreeNode, WidgetNode, } from '@ifc-lite/extensions'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Switch } from '@/components/ui/switch'; import { Separator } from '@/components/ui/separator'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { ScrollArea } from '@/components/ui/scroll-area'; import { cn } from '@/lib/utils'; export interface WidgetRendererContext { /** State object the handler returned (provides data-binding values). */ state: unknown; /** Invoke an extension command. The host dispatcher implementation. */ invokeCommand?: (commandId: string, args?: Record) => void; } interface WidgetRendererProps { node: WidgetNode; ctx: WidgetRendererContext; } export function WidgetRenderer({ node, ctx }: WidgetRendererProps) { switch (node.type) { case 'Stack': return ; case 'Group': return ; case 'Text': return ; case 'Field': return ; case 'Button': return ; case 'Table': return ; case 'Chart': return ; case 'Markdown': return ; case 'Tabs': return ; case 'Separator': return ; case 'EmptyState': return ; case 'Spinner': return ; case 'ErrorBanner': return ; case 'EntityList': return ; case 'Tree': return ; case 'KeyValueGrid': return ; default: return ; } } // --------------------------------------------------------------------------- // Bindings // --------------------------------------------------------------------------- /** Resolve a binding expression against the state. */ function resolveBinding(binding: string, state: unknown): unknown { const path = binding.replace(/^\$\.?/, ''); if (!path) return state; let cursor: unknown = state; for (const segment of path.split('.')) { if (cursor === null || cursor === undefined) return undefined; if (typeof cursor !== 'object') return undefined; cursor = (cursor as Record)[segment]; } return cursor; } function asArray(value: unknown): unknown[] { return Array.isArray(value) ? value : []; } // --------------------------------------------------------------------------- // Node renderers // --------------------------------------------------------------------------- function RenderStack({ node, ctx }: { node: StackNode; ctx: WidgetRendererContext }) { const direction = node.direction === 'horizontal' ? 'flex-row' : 'flex-col'; const gap = node.gap === 'lg' ? 'gap-4' : node.gap === 'sm' ? 'gap-1' : node.gap === 'none' ? 'gap-0' : 'gap-2'; const align = node.align === 'center' ? 'items-center' : node.align === 'end' ? 'items-end' : node.align === 'stretch' ? 'items-stretch' : 'items-start'; const justify = node.justify === 'center' ? 'justify-center' : node.justify === 'end' ? 'justify-end' : node.justify === 'between' ? 'justify-between' : 'justify-start'; return (
{node.children.map((child, i) => ( ))}
); } function RenderGroup({ node, ctx }: { node: GroupNode; ctx: WidgetRendererContext }) { return (
{node.title && {node.title}}
{node.children.map((child, i) => ( ))}
); } function RenderText({ node }: { node: TextNode; ctx: WidgetRendererContext }) { const variant = node.variant === 'heading' ? 'text-base font-semibold' : node.variant === 'caption' ? 'text-xs text-muted-foreground' : 'text-sm'; const tone = node.tone === 'error' ? 'text-destructive' : node.tone === 'warn' ? 'text-amber-600 dark:text-amber-400' : node.tone === 'success' ? 'text-emerald-600 dark:text-emerald-400' : node.tone === 'info' ? 'text-sky-600 dark:text-sky-400' : node.tone === 'muted' ? 'text-muted-foreground' : ''; return

{node.text}

; } function RenderField({ node, ctx }: { node: FieldNode; ctx: WidgetRendererContext }) { const value = resolveBinding(node.binding, ctx.state); // Read-only render for v1 โ€” Field's `binding` reflects state, and // the handler decides how to update state on re-run via the // command dispatcher. Inline editing without a host write-back path // would imply an unenforced state contract. switch (node.variant) { case 'boolean': return (
{node.label}
); case 'number': case 'text': default: return (
); } } function RenderButton({ node, ctx }: { node: ButtonNode; ctx: WidgetRendererContext }) { const variantMap = { primary: 'default', secondary: 'secondary', destructive: 'destructive', ghost: 'ghost', } as const; const variant = variantMap[node.variant ?? 'primary']; return ( ); } function RenderTable({ node, ctx }: { node: TableNode; ctx: WidgetRendererContext }) { const rows = asArray(resolveBinding(node.data, ctx.state)); if (rows.length === 0) { return
No rows
; } return ( {node.columns.map((c, i) => ( ))} {rows.map((row, i) => ( {node.columns.map((c, j) => { const cell = (row as Record)?.[c.field]; return ( ); })} ))}
{c.title}
{cell === null || cell === undefined ? '' : String(cell)}
); } function RenderChart({ node, ctx }: { node: ChartNode; ctx: WidgetRendererContext }) { // Lightweight ASCII-bar chart for v1. Avoids pulling in a chart lib. // Real charting can swap this implementation later without changing // the DSL. const rows = asArray(resolveBinding(node.data, ctx.state)); const xField = node.xField ?? 'label'; const yField = node.yField ?? 'value'; const max = useMemo(() => { let m = 0; for (const row of rows) { const v = Number((row as Record)[yField] ?? 0); if (Number.isFinite(v) && v > m) m = v; } return m || 1; }, [rows, yField]); return (
{node.variant} chart
{rows.map((row, i) => { const label = String((row as Record)[xField] ?? ''); const v = Number((row as Record)[yField] ?? 0); const pct = (Math.max(0, v) / max) * 100; return (
{label} {v}
); })}
); } function RenderMarkdown({ node }: { node: MarkdownNode; ctx: WidgetRendererContext }) { // We render plain text only โ€” no HTML, no parser. This preserves // the "host renders chrome" invariant; rich markdown ships when // we adopt a sanitising renderer in the host. return
{node.content}
; } function RenderTabs({ node, ctx }: { node: TabsNode; ctx: WidgetRendererContext }) { const first = node.defaultTab ?? node.tabs[0]?.id; return ( {node.tabs.map((tab) => ( {tab.label} ))} {node.tabs.map((tab) => (
{tab.children.map((child, i) => ( ))}
))}
); } function RenderEmptyState({ node, ctx }: { node: EmptyStateNode; ctx: WidgetRendererContext }) { return (
{node.heading}
{node.body &&
{node.body}
} {node.cta && ( )}
); } function RenderSpinner({ node }: { node: SpinnerNode; ctx: WidgetRendererContext }) { return (
{node.label && {node.label}}
); } function RenderErrorBanner({ node, ctx }: { node: ErrorBannerNode; ctx: WidgetRendererContext }) { return (
{node.message}
{node.retryCommand && ( )}
); } function RenderEntityList({ node, ctx }: { node: EntityListNode; ctx: WidgetRendererContext }) { const rows = asArray(resolveBinding(node.data, ctx.state)); return (
    {rows.length === 0 && (
  • No entities
  • )} {rows.map((row, i) => { const r = row as Record; const id = String(r[node.idField] ?? ''); const label = node.labelField ? String(r[node.labelField] ?? id) : id; return (
  • {label}
  • ); })}
); } interface TreeNodeData { [key: string]: unknown; } function RenderTree({ node, ctx }: { node: TreeNode; ctx: WidgetRendererContext }) { const roots = asArray(resolveBinding(node.data, ctx.state)); return (
    {roots.map((root, i) => ( ))}
); } function TreeItem({ node, labelField, childrenField, depth }: { node: TreeNodeData; labelField: string; childrenField: string; depth: number; }) { const label = String(node[labelField] ?? ''); const children = asArray(node[childrenField]); return (
  • {children.length > 0 ? : } {label}
    {children.length > 0 && (
      {children.map((child, i) => ( ))}
    )}
  • ); } function RenderKeyValueGrid({ node }: { node: KeyValueGridNode; ctx: WidgetRendererContext }) { return (
    {node.rows.map((row, i) => (
    {row.label}
    {row.value}
    ))}
    ); } function UnknownNode({ node }: { node: { type?: string } }) { return (
    Unknown widget node: {String(node.type ?? '?')}
    ); }