@ifc-lite/viewer 1.23.0 → 1.25.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.
Files changed (109) hide show
  1. package/.turbo/turbo-build.log +34 -31
  2. package/CHANGELOG.md +96 -0
  3. package/dist/assets/{basketViewActivator-Dn_bHUl2.js → basketViewActivator-CU8_toGq.js} +7 -7
  4. package/dist/assets/{bcf-B9SFl84i.js → bcf-DXGDhw56.js} +23 -23
  5. package/dist/assets/{deflate-yMpdCIqk.js → deflate-Bb1_H2Yf.js} +1 -1
  6. package/dist/assets/{exporters-D-BvrNIg.js → exporters-DZhLN0ux.js} +1861 -1658
  7. package/dist/assets/geometry-controller.worker-DQOSYqtw.js +7 -0
  8. package/dist/assets/geometry.worker-B62e03Ao.js +1 -0
  9. package/dist/assets/{geotiff-D1tvcDCb.js → geotiff-y0ZxbRJd.js} +10 -10
  10. package/dist/assets/{ids-DZLs0snJ.js → ids-DruUNtfD.js} +4 -4
  11. package/dist/assets/ifc-lite-Ch2T9pP9.js +7 -0
  12. package/dist/assets/{ifc-lite_bg-DyHX37GQ.wasm → ifc-lite_bg-D7O1WHgP.wasm} +0 -0
  13. package/dist/assets/{ifc-lite_bg-BIryVCXQ.wasm → ifc-lite_bg-iH_07wf8.wasm} +0 -0
  14. package/dist/assets/index-Bws3UAkj.css +1 -0
  15. package/dist/assets/{index-CXSBhkcJ.js → index-Dr88ZlSY.js} +64100 -47030
  16. package/dist/assets/{jpeg-DUMcZp24.js → jpeg-B3_loqFe.js} +1 -1
  17. package/dist/assets/lens-PYsLu_MA.js +1 -0
  18. package/dist/assets/{lerc-IN4uWojP.js → lerc-nkwS8ZUe.js} +1 -1
  19. package/dist/assets/{lzw-Cnw0hH-m.js → lzw-D3cW5Wpg.js} +1 -1
  20. package/dist/assets/{native-bridge-BVf2uzoH.js → native-bridge-BcYJooq8.js} +2 -2
  21. package/dist/assets/{packbits-BskJCwk0.js → packbits-DDN4xzB5.js} +1 -1
  22. package/dist/assets/{parser.worker-BdtkkaGf.js → parser.worker-BW1IMUed.js} +3 -3
  23. package/dist/assets/raw-CoIXstQ-.js +1 -0
  24. package/dist/assets/{sandbox-VLI_y7cl.js → sandbox-DETNEyQb.js} +498 -470
  25. package/dist/assets/{server-client-BLcKaWQB.js → server-client-CmzJOeS7.js} +1 -1
  26. package/dist/assets/{wasm-bridge-BAfZh7YT.js → wasm-bridge-CT7mK9W0.js} +1 -1
  27. package/dist/assets/{webimage-Db2xzze3.js → webimage-CBjgg4up.js} +1 -1
  28. package/dist/assets/{workerHelpers--sAYm9yN.js → workerHelpers-IEQDo8r3.js} +1 -1
  29. package/dist/assets/{zstd-BDToOQyD.js → zstd-C8oQ6qdS.js} +1 -1
  30. package/dist/index.html +8 -8
  31. package/package.json +11 -9
  32. package/src/App.tsx +5 -2
  33. package/src/components/extensions/AuditLogPanel.tsx +259 -0
  34. package/src/components/extensions/BundlePreview.tsx +102 -0
  35. package/src/components/extensions/CapabilityReview.tsx +333 -0
  36. package/src/components/extensions/ExtensionDockHost.tsx +192 -0
  37. package/src/components/extensions/ExtensionToolbarSlot.tsx +106 -0
  38. package/src/components/extensions/ExtensionsPanel.tsx +481 -0
  39. package/src/components/extensions/FlavorDialog.tsx +398 -0
  40. package/src/components/extensions/FlavorImportPreview.tsx +79 -0
  41. package/src/components/extensions/FlavorIndicator.tsx +81 -0
  42. package/src/components/extensions/FlavorListView.tsx +318 -0
  43. package/src/components/extensions/FlavorMergeDialog.tsx +326 -0
  44. package/src/components/extensions/HelpHint.tsx +182 -0
  45. package/src/components/extensions/IdeasPanel.tsx +344 -0
  46. package/src/components/extensions/PlanCard.tsx +227 -0
  47. package/src/components/extensions/PrivacyPanel.tsx +312 -0
  48. package/src/components/extensions/PromoteToolDialog.tsx +313 -0
  49. package/src/components/extensions/RepairQueuePanel.tsx +222 -0
  50. package/src/components/extensions/icon-registry.ts +92 -0
  51. package/src/components/extensions/toast-helpers.ts +49 -0
  52. package/src/components/extensions/widget/WidgetErrorBoundary.tsx +62 -0
  53. package/src/components/extensions/widget/WidgetRenderer.tsx +428 -0
  54. package/src/components/viewer/ChatPanel.tsx +251 -3
  55. package/src/components/viewer/CommandPalette.tsx +74 -4
  56. package/src/components/viewer/Drawing2DCanvas.tsx +178 -1
  57. package/src/components/viewer/EntityContextMenu.tsx +70 -0
  58. package/src/components/viewer/ExportDialog.tsx +9 -1
  59. package/src/components/viewer/KeyboardShortcutsDialog.tsx +21 -6
  60. package/src/components/viewer/LensPanel.tsx +50 -0
  61. package/src/components/viewer/MainToolbar.tsx +170 -87
  62. package/src/components/viewer/ScriptPanel.tsx +105 -1
  63. package/src/components/viewer/Section2DPanel.tsx +58 -2
  64. package/src/components/viewer/StatusBar.tsx +18 -0
  65. package/src/components/viewer/ViewerLayout.tsx +53 -4
  66. package/src/components/viewer/Viewport.tsx +72 -0
  67. package/src/hooks/useActionLogger.test.ts +161 -0
  68. package/src/hooks/useActionLogger.ts +141 -0
  69. package/src/hooks/useForkExtension.ts +51 -0
  70. package/src/hooks/useIfcFederation.ts +7 -1
  71. package/src/hooks/useInstalledExtensions.ts +43 -0
  72. package/src/hooks/usePrivacyDisclosure.ts +48 -0
  73. package/src/hooks/useRunExtensionTests.ts +67 -0
  74. package/src/hooks/useSlotContributions.ts +38 -0
  75. package/src/hooks/useSymbolicAnnotations.test.ts +124 -0
  76. package/src/hooks/useSymbolicAnnotations.ts +776 -0
  77. package/src/lib/desktop-product.ts +7 -1
  78. package/src/lib/lens/adapter.ts +14 -0
  79. package/src/lib/llm/prompt-cache.ts +77 -0
  80. package/src/lib/llm/stream-client.ts +20 -2
  81. package/src/lib/llm/stream-direct.ts +11 -1
  82. package/src/lib/llm/system-prompt.ts +42 -0
  83. package/src/lib/safe-mode.ts +30 -0
  84. package/src/sdk/ExtensionHostProvider.tsx +103 -0
  85. package/src/services/extensions/flavor-service.ts +183 -0
  86. package/src/services/extensions/host-commands.ts +112 -0
  87. package/src/services/extensions/host-installer.ts +289 -0
  88. package/src/services/extensions/host.ts +514 -0
  89. package/src/services/extensions/idb-flavor-storage.test.ts +140 -0
  90. package/src/services/extensions/idb-flavor-storage.ts +241 -0
  91. package/src/services/extensions/idb-log-storage.test.ts +110 -0
  92. package/src/services/extensions/idb-log-storage.ts +171 -0
  93. package/src/services/extensions/idb-storage.ts +228 -0
  94. package/src/services/extensions/runtime-errors.ts +26 -0
  95. package/src/services/extensions/sandbox-factory.ts +217 -0
  96. package/src/store/constants.ts +48 -6
  97. package/src/store/index.ts +6 -1
  98. package/src/store/slices/drawing2DSlice.ts +8 -0
  99. package/src/store/slices/extensionsSlice.ts +90 -0
  100. package/src/store/slices/lensSlice.ts +28 -0
  101. package/src/store/slices/visibilitySlice.test.ts +6 -0
  102. package/src/store/slices/visibilitySlice.ts +17 -8
  103. package/src/store/types.ts +2 -0
  104. package/dist/assets/geometry-controller.worker-Cm5pvyR6.js +0 -7
  105. package/dist/assets/geometry.worker-ClNvXIrj.js +0 -1
  106. package/dist/assets/ifc-lite-BDg0iIbj.js +0 -7
  107. package/dist/assets/index-DS_xJQfP.css +0 -1
  108. package/dist/assets/lens-CpjUdqpw.js +0 -1
  109. package/dist/assets/raw-DzTtEZIY.js +0 -1
@@ -0,0 +1,62 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * Error boundary for extension-authored widgets. Wraps the
7
+ * `WidgetRenderer` tree so a single malformed widget cannot crash
8
+ * the Extensions panel — instead the user sees a labelled fallback
9
+ * and can carry on using the rest of the panel.
10
+ *
11
+ * Designed as a *narrow* boundary: the host catches everything else
12
+ * via the dispatcher's per-command try/catch. This boundary covers
13
+ * render-time failures (bad bindings, missing fields, etc.).
14
+ */
15
+
16
+ import { Component, type ErrorInfo, type ReactNode } from 'react';
17
+ import { AlertCircle } from 'lucide-react';
18
+
19
+ interface WidgetErrorBoundaryProps {
20
+ /** Human-readable identifier used in the fallback (`extensionId` or `commandId`). */
21
+ label: string;
22
+ children: ReactNode;
23
+ }
24
+
25
+ interface WidgetErrorBoundaryState {
26
+ error: Error | null;
27
+ }
28
+
29
+ export class WidgetErrorBoundary extends Component<
30
+ WidgetErrorBoundaryProps,
31
+ WidgetErrorBoundaryState
32
+ > {
33
+ state: WidgetErrorBoundaryState = { error: null };
34
+
35
+ static getDerivedStateFromError(error: Error): WidgetErrorBoundaryState {
36
+ return { error };
37
+ }
38
+
39
+ componentDidCatch(error: Error, info: ErrorInfo): void {
40
+ console.error(`[WidgetErrorBoundary] widget "${this.props.label}" crashed`, error, info);
41
+ }
42
+
43
+ render(): ReactNode {
44
+ const { error } = this.state;
45
+ if (!error) return this.props.children;
46
+ return (
47
+ <div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs">
48
+ <div className="flex items-start gap-2">
49
+ <AlertCircle className="h-4 w-4 text-destructive mt-0.5 shrink-0" />
50
+ <div className="min-w-0">
51
+ <div className="font-medium text-destructive">
52
+ {this.props.label} crashed while rendering
53
+ </div>
54
+ <div className="text-muted-foreground mt-0.5 font-mono break-words">
55
+ {error.message}
56
+ </div>
57
+ </div>
58
+ </div>
59
+ </div>
60
+ );
61
+ }
62
+ }
@@ -0,0 +1,428 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * Widget DSL renderer.
7
+ *
8
+ * Walks a WidgetNode tree and emits matching React components. Data
9
+ * bindings (`"$.foo.bar"` or `"foo.bar"`) resolve against the state
10
+ * object the widget's handler returned.
11
+ *
12
+ * The renderer is intentionally minimal — chrome only, no inline
13
+ * styles, no client-defined CSS. Themes come from the host's Tailwind
14
+ * tokens; variants/tones get mapped at the leaf.
15
+ *
16
+ * Spec: docs/architecture/ai-customization/03-ui-surface.md §3.
17
+ */
18
+
19
+ import { useMemo } from 'react';
20
+ import { AlertCircle, ChevronDown, ChevronRight, Loader2 } from 'lucide-react';
21
+ import type {
22
+ ButtonNode,
23
+ ChartNode,
24
+ EmptyStateNode,
25
+ EntityListNode,
26
+ ErrorBannerNode,
27
+ FieldNode,
28
+ GroupNode,
29
+ KeyValueGridNode,
30
+ MarkdownNode,
31
+ SpinnerNode,
32
+ StackNode,
33
+ TableNode,
34
+ TabsNode,
35
+ TextNode,
36
+ TreeNode,
37
+ WidgetNode,
38
+ } from '@ifc-lite/extensions';
39
+ import { Button } from '@/components/ui/button';
40
+ import { Input } from '@/components/ui/input';
41
+ import { Label } from '@/components/ui/label';
42
+ import { Switch } from '@/components/ui/switch';
43
+ import { Separator } from '@/components/ui/separator';
44
+ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
45
+ import { ScrollArea } from '@/components/ui/scroll-area';
46
+ import { cn } from '@/lib/utils';
47
+
48
+ export interface WidgetRendererContext {
49
+ /** State object the handler returned (provides data-binding values). */
50
+ state: unknown;
51
+ /** Invoke an extension command. The host dispatcher implementation. */
52
+ invokeCommand?: (commandId: string, args?: Record<string, unknown>) => void;
53
+ }
54
+
55
+ interface WidgetRendererProps {
56
+ node: WidgetNode;
57
+ ctx: WidgetRendererContext;
58
+ }
59
+
60
+ export function WidgetRenderer({ node, ctx }: WidgetRendererProps) {
61
+ switch (node.type) {
62
+ case 'Stack': return <RenderStack node={node} ctx={ctx} />;
63
+ case 'Group': return <RenderGroup node={node} ctx={ctx} />;
64
+ case 'Text': return <RenderText node={node} ctx={ctx} />;
65
+ case 'Field': return <RenderField node={node} ctx={ctx} />;
66
+ case 'Button': return <RenderButton node={node} ctx={ctx} />;
67
+ case 'Table': return <RenderTable node={node} ctx={ctx} />;
68
+ case 'Chart': return <RenderChart node={node} ctx={ctx} />;
69
+ case 'Markdown': return <RenderMarkdown node={node} ctx={ctx} />;
70
+ case 'Tabs': return <RenderTabs node={node} ctx={ctx} />;
71
+ case 'Separator': return <Separator className="my-2" />;
72
+ case 'EmptyState': return <RenderEmptyState node={node} ctx={ctx} />;
73
+ case 'Spinner': return <RenderSpinner node={node} ctx={ctx} />;
74
+ case 'ErrorBanner': return <RenderErrorBanner node={node} ctx={ctx} />;
75
+ case 'EntityList': return <RenderEntityList node={node} ctx={ctx} />;
76
+ case 'Tree': return <RenderTree node={node} ctx={ctx} />;
77
+ case 'KeyValueGrid': return <RenderKeyValueGrid node={node} ctx={ctx} />;
78
+ default:
79
+ return <UnknownNode node={node as { type?: string }} />;
80
+ }
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Bindings
85
+ // ---------------------------------------------------------------------------
86
+
87
+ /** Resolve a binding expression against the state. */
88
+ function resolveBinding(binding: string, state: unknown): unknown {
89
+ const path = binding.replace(/^\$\.?/, '');
90
+ if (!path) return state;
91
+ let cursor: unknown = state;
92
+ for (const segment of path.split('.')) {
93
+ if (cursor === null || cursor === undefined) return undefined;
94
+ if (typeof cursor !== 'object') return undefined;
95
+ cursor = (cursor as Record<string, unknown>)[segment];
96
+ }
97
+ return cursor;
98
+ }
99
+
100
+ function asArray(value: unknown): unknown[] {
101
+ return Array.isArray(value) ? value : [];
102
+ }
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Node renderers
106
+ // ---------------------------------------------------------------------------
107
+
108
+ function RenderStack({ node, ctx }: { node: StackNode; ctx: WidgetRendererContext }) {
109
+ const direction = node.direction === 'horizontal' ? 'flex-row' : 'flex-col';
110
+ const gap = node.gap === 'lg' ? 'gap-4' : node.gap === 'sm' ? 'gap-1' : node.gap === 'none' ? 'gap-0' : 'gap-2';
111
+ const align = node.align === 'center' ? 'items-center' : node.align === 'end' ? 'items-end' : node.align === 'stretch' ? 'items-stretch' : 'items-start';
112
+ const justify = node.justify === 'center' ? 'justify-center' : node.justify === 'end' ? 'justify-end' : node.justify === 'between' ? 'justify-between' : 'justify-start';
113
+ return (
114
+ <div className={cn('flex', direction, gap, align, justify)}>
115
+ {node.children.map((child, i) => (
116
+ <WidgetRenderer key={i} node={child} ctx={ctx} />
117
+ ))}
118
+ </div>
119
+ );
120
+ }
121
+
122
+ function RenderGroup({ node, ctx }: { node: GroupNode; ctx: WidgetRendererContext }) {
123
+ return (
124
+ <fieldset className="rounded-md border p-3">
125
+ {node.title && <legend className="text-xs font-semibold px-1">{node.title}</legend>}
126
+ <div className="flex flex-col gap-2">
127
+ {node.children.map((child, i) => (
128
+ <WidgetRenderer key={i} node={child} ctx={ctx} />
129
+ ))}
130
+ </div>
131
+ </fieldset>
132
+ );
133
+ }
134
+
135
+ function RenderText({ node }: { node: TextNode; ctx: WidgetRendererContext }) {
136
+ const variant = node.variant === 'heading' ? 'text-base font-semibold' : node.variant === 'caption' ? 'text-xs text-muted-foreground' : 'text-sm';
137
+ 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' : '';
138
+ return <p className={cn(variant, tone)}>{node.text}</p>;
139
+ }
140
+
141
+ function RenderField({ node, ctx }: { node: FieldNode; ctx: WidgetRendererContext }) {
142
+ const value = resolveBinding(node.binding, ctx.state);
143
+ // Read-only render for v1 — Field's `binding` reflects state, and
144
+ // the handler decides how to update state on re-run via the
145
+ // command dispatcher. Inline editing without a host write-back path
146
+ // would imply an unenforced state contract.
147
+ switch (node.variant) {
148
+ case 'boolean':
149
+ return (
150
+ <div className="flex items-center gap-2">
151
+ <Switch checked={Boolean(value)} disabled aria-label={node.label} />
152
+ <span className="text-xs">{node.label}</span>
153
+ </div>
154
+ );
155
+ case 'number':
156
+ case 'text':
157
+ default:
158
+ return (
159
+ <div className="flex flex-col gap-1">
160
+ <Label className="text-[11px]">{node.label}</Label>
161
+ <Input
162
+ value={value === undefined || value === null ? '' : String(value)}
163
+ readOnly
164
+ placeholder={node.placeholder}
165
+ aria-label={node.label}
166
+ />
167
+ </div>
168
+ );
169
+ }
170
+ }
171
+
172
+ function RenderButton({ node, ctx }: { node: ButtonNode; ctx: WidgetRendererContext }) {
173
+ const variantMap = {
174
+ primary: 'default',
175
+ secondary: 'secondary',
176
+ destructive: 'destructive',
177
+ ghost: 'ghost',
178
+ } as const;
179
+ const variant = variantMap[node.variant ?? 'primary'];
180
+ return (
181
+ <Button
182
+ variant={variant}
183
+ disabled={Boolean(node.disabled)}
184
+ onClick={() => ctx.invokeCommand?.(node.command, node.args as Record<string, unknown> | undefined)}
185
+ >
186
+ {node.label}
187
+ </Button>
188
+ );
189
+ }
190
+
191
+ function RenderTable({ node, ctx }: { node: TableNode; ctx: WidgetRendererContext }) {
192
+ const rows = asArray(resolveBinding(node.data, ctx.state));
193
+ if (rows.length === 0) {
194
+ return <div className="text-xs text-muted-foreground italic px-2 py-3">No rows</div>;
195
+ }
196
+ return (
197
+ <ScrollArea className="max-h-72 rounded-md border">
198
+ <table className="w-full text-xs">
199
+ <thead className="border-b bg-muted/40 sticky top-0">
200
+ <tr>
201
+ {node.columns.map((c, i) => (
202
+ <th
203
+ key={i}
204
+ className={cn('px-2 py-1.5 text-left font-medium', c.align === 'right' && 'text-right', c.align === 'center' && 'text-center')}
205
+ style={c.width ? { width: c.width } : undefined}
206
+ >
207
+ {c.title}
208
+ </th>
209
+ ))}
210
+ </tr>
211
+ </thead>
212
+ <tbody>
213
+ {rows.map((row, i) => (
214
+ <tr key={i} className="border-b last:border-0">
215
+ {node.columns.map((c, j) => {
216
+ const cell = (row as Record<string, unknown>)?.[c.field];
217
+ return (
218
+ <td
219
+ key={j}
220
+ className={cn('px-2 py-1', c.align === 'right' && 'text-right', c.align === 'center' && 'text-center')}
221
+ >
222
+ {cell === null || cell === undefined ? '' : String(cell)}
223
+ </td>
224
+ );
225
+ })}
226
+ </tr>
227
+ ))}
228
+ </tbody>
229
+ </table>
230
+ </ScrollArea>
231
+ );
232
+ }
233
+
234
+ function RenderChart({ node, ctx }: { node: ChartNode; ctx: WidgetRendererContext }) {
235
+ // Lightweight ASCII-bar chart for v1. Avoids pulling in a chart lib.
236
+ // Real charting can swap this implementation later without changing
237
+ // the DSL.
238
+ const rows = asArray(resolveBinding(node.data, ctx.state));
239
+ const xField = node.xField ?? 'label';
240
+ const yField = node.yField ?? 'value';
241
+ const max = useMemo(() => {
242
+ let m = 0;
243
+ for (const row of rows) {
244
+ const v = Number((row as Record<string, unknown>)[yField] ?? 0);
245
+ if (Number.isFinite(v) && v > m) m = v;
246
+ }
247
+ return m || 1;
248
+ }, [rows, yField]);
249
+ return (
250
+ <div className="rounded-md border p-3 space-y-1.5">
251
+ <div className="text-[11px] text-muted-foreground">{node.variant} chart</div>
252
+ {rows.map((row, i) => {
253
+ const label = String((row as Record<string, unknown>)[xField] ?? '');
254
+ const v = Number((row as Record<string, unknown>)[yField] ?? 0);
255
+ const pct = (Math.max(0, v) / max) * 100;
256
+ return (
257
+ <div key={i} className="text-xs">
258
+ <div className="flex items-center justify-between mb-0.5">
259
+ <span className="truncate">{label}</span>
260
+ <span className="text-muted-foreground tabular-nums">{v}</span>
261
+ </div>
262
+ <div className="h-1.5 bg-muted rounded">
263
+ <div className="h-1.5 bg-primary rounded" style={{ width: `${pct}%` }} />
264
+ </div>
265
+ </div>
266
+ );
267
+ })}
268
+ </div>
269
+ );
270
+ }
271
+
272
+ function RenderMarkdown({ node }: { node: MarkdownNode; ctx: WidgetRendererContext }) {
273
+ // We render plain text only — no HTML, no parser. This preserves
274
+ // the "host renders chrome" invariant; rich markdown ships when
275
+ // we adopt a sanitising renderer in the host.
276
+ return <div className="text-xs whitespace-pre-wrap leading-relaxed">{node.content}</div>;
277
+ }
278
+
279
+ function RenderTabs({ node, ctx }: { node: TabsNode; ctx: WidgetRendererContext }) {
280
+ const first = node.defaultTab ?? node.tabs[0]?.id;
281
+ return (
282
+ <Tabs defaultValue={first}>
283
+ <TabsList>
284
+ {node.tabs.map((tab) => (
285
+ <TabsTrigger key={tab.id} value={tab.id}>
286
+ {tab.label}
287
+ </TabsTrigger>
288
+ ))}
289
+ </TabsList>
290
+ {node.tabs.map((tab) => (
291
+ <TabsContent key={tab.id} value={tab.id}>
292
+ <div className="flex flex-col gap-2">
293
+ {tab.children.map((child, i) => (
294
+ <WidgetRenderer key={i} node={child} ctx={ctx} />
295
+ ))}
296
+ </div>
297
+ </TabsContent>
298
+ ))}
299
+ </Tabs>
300
+ );
301
+ }
302
+
303
+ function RenderEmptyState({ node, ctx }: { node: EmptyStateNode; ctx: WidgetRendererContext }) {
304
+ return (
305
+ <div className="flex flex-col items-center gap-2 py-8 text-center">
306
+ <div className="text-sm font-medium">{node.heading}</div>
307
+ {node.body && <div className="text-xs text-muted-foreground max-w-md">{node.body}</div>}
308
+ {node.cta && (
309
+ <Button size="sm" onClick={() => ctx.invokeCommand?.(node.cta!.command)}>
310
+ {node.cta.label}
311
+ </Button>
312
+ )}
313
+ </div>
314
+ );
315
+ }
316
+
317
+ function RenderSpinner({ node }: { node: SpinnerNode; ctx: WidgetRendererContext }) {
318
+ return (
319
+ <div className="flex items-center gap-2 text-xs text-muted-foreground">
320
+ <Loader2 className="h-3 w-3 animate-spin" />
321
+ {node.label && <span>{node.label}</span>}
322
+ </div>
323
+ );
324
+ }
325
+
326
+ function RenderErrorBanner({ node, ctx }: { node: ErrorBannerNode; ctx: WidgetRendererContext }) {
327
+ return (
328
+ <div className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs">
329
+ <AlertCircle className="h-3.5 w-3.5 text-destructive mt-0.5 shrink-0" />
330
+ <div className="flex-1">
331
+ <div className="text-destructive">{node.message}</div>
332
+ {node.retryCommand && (
333
+ <Button size="sm" variant="ghost" className="mt-1 h-7 px-2" onClick={() => ctx.invokeCommand?.(node.retryCommand!)}>
334
+ Retry
335
+ </Button>
336
+ )}
337
+ </div>
338
+ </div>
339
+ );
340
+ }
341
+
342
+ function RenderEntityList({ node, ctx }: { node: EntityListNode; ctx: WidgetRendererContext }) {
343
+ const rows = asArray(resolveBinding(node.data, ctx.state));
344
+ return (
345
+ <ul className="divide-y rounded-md border">
346
+ {rows.length === 0 && (
347
+ <li className="px-2 py-3 text-xs text-muted-foreground italic">No entities</li>
348
+ )}
349
+ {rows.map((row, i) => {
350
+ const r = row as Record<string, unknown>;
351
+ const id = String(r[node.idField] ?? '');
352
+ const label = node.labelField ? String(r[node.labelField] ?? id) : id;
353
+ return (
354
+ <li key={`${id}-${i}`} className="px-2 py-1.5 text-xs font-mono break-all">
355
+ {label}
356
+ </li>
357
+ );
358
+ })}
359
+ </ul>
360
+ );
361
+ }
362
+
363
+ interface TreeNodeData {
364
+ [key: string]: unknown;
365
+ }
366
+
367
+ function RenderTree({ node, ctx }: { node: TreeNode; ctx: WidgetRendererContext }) {
368
+ const roots = asArray(resolveBinding(node.data, ctx.state));
369
+ return (
370
+ <ul className="text-xs">
371
+ {roots.map((root, i) => (
372
+ <TreeItem key={i} node={root as TreeNodeData} labelField={node.labelField} childrenField={node.childrenField} depth={0} />
373
+ ))}
374
+ </ul>
375
+ );
376
+ }
377
+
378
+ function TreeItem({ node, labelField, childrenField, depth }: {
379
+ node: TreeNodeData;
380
+ labelField: string;
381
+ childrenField: string;
382
+ depth: number;
383
+ }) {
384
+ const label = String(node[labelField] ?? '');
385
+ const children = asArray(node[childrenField]);
386
+ return (
387
+ <li>
388
+ <div className="flex items-center gap-1 py-0.5" style={{ paddingLeft: depth * 12 }}>
389
+ {children.length > 0 ? <ChevronDown className="h-3 w-3 shrink-0" /> : <ChevronRight className="h-3 w-3 shrink-0 invisible" />}
390
+ <span className="truncate">{label}</span>
391
+ </div>
392
+ {children.length > 0 && (
393
+ <ul>
394
+ {children.map((child, i) => (
395
+ <TreeItem
396
+ key={i}
397
+ node={child as TreeNodeData}
398
+ labelField={labelField}
399
+ childrenField={childrenField}
400
+ depth={depth + 1}
401
+ />
402
+ ))}
403
+ </ul>
404
+ )}
405
+ </li>
406
+ );
407
+ }
408
+
409
+ function RenderKeyValueGrid({ node }: { node: KeyValueGridNode; ctx: WidgetRendererContext }) {
410
+ return (
411
+ <dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
412
+ {node.rows.map((row, i) => (
413
+ <div key={i} className="contents">
414
+ <dt className="text-muted-foreground">{row.label}</dt>
415
+ <dd className="break-all">{row.value}</dd>
416
+ </div>
417
+ ))}
418
+ </dl>
419
+ );
420
+ }
421
+
422
+ function UnknownNode({ node }: { node: { type?: string } }) {
423
+ return (
424
+ <div className="text-xs text-destructive italic px-2 py-1">
425
+ Unknown widget node: <code>{String(node.type ?? '?')}</code>
426
+ </div>
427
+ );
428
+ }