dsh-mosaic 0.2.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 (47) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/LICENSE +21 -0
  3. package/README.md +151 -0
  4. package/SKILL.md +153 -0
  5. package/THIRD_PARTY_NOTICES.md +79 -0
  6. package/assets/icon-180.png +0 -0
  7. package/assets/icon.svg +13 -0
  8. package/assets/screenshots/preview.jpg +0 -0
  9. package/cordis.patch.yml +10 -0
  10. package/docs/catalog-entry.example.yml +9 -0
  11. package/docs/publishing.md +29 -0
  12. package/lib/assets/licenses/braintree-sanitize-url.LICENSE +21 -0
  13. package/lib/assets/licenses/chevrotain.LICENSE +202 -0
  14. package/lib/assets/licenses/cytoscape-cose-bilkent.LICENSE +21 -0
  15. package/lib/assets/licenses/cytoscape-fcose.LICENSE +19 -0
  16. package/lib/assets/licenses/cytoscape.LICENSE +19 -0
  17. package/lib/assets/licenses/d3-sankey.LICENSE +27 -0
  18. package/lib/assets/licenses/d3.LICENSE +13 -0
  19. package/lib/assets/licenses/dagre-d3-es.LICENSE +23 -0
  20. package/lib/assets/licenses/dayjs.LICENSE +21 -0
  21. package/lib/assets/licenses/dompurify.LICENSE +202 -0
  22. package/lib/assets/licenses/elkjs.LICENSE +263 -0
  23. package/lib/assets/licenses/es-toolkit.LICENSE +21 -0
  24. package/lib/assets/licenses/iconify-utils.LICENSE +21 -0
  25. package/lib/assets/licenses/katex.LICENSE +21 -0
  26. package/lib/assets/licenses/khroma.LICENSE +21 -0
  27. package/lib/assets/licenses/marked.LICENSE +44 -0
  28. package/lib/assets/licenses/mermaid-js-parser.LICENSE +21 -0
  29. package/lib/assets/licenses/mermaid.LICENSE +21 -0
  30. package/lib/assets/licenses/roughjs.LICENSE +21 -0
  31. package/lib/assets/licenses/stylis.LICENSE +21 -0
  32. package/lib/assets/licenses/ts-dedent.LICENSE +21 -0
  33. package/lib/assets/licenses/types-d3.LICENSE +21 -0
  34. package/lib/assets/licenses/upsetjs-venn.js.LICENSE +22 -0
  35. package/lib/assets/licenses/uuid.LICENSE +9 -0
  36. package/lib/assets/mermaid.min.js +4330 -0
  37. package/lib/client.js +3191 -0
  38. package/lib/index.js +365 -0
  39. package/lib/log.js +212 -0
  40. package/lib/prompt-store.js +354 -0
  41. package/lib/render-tool.js +162 -0
  42. package/lib/validate-tool.js +160 -0
  43. package/lib/validate.js +1070 -0
  44. package/locale/en.json +6 -0
  45. package/locale/zh.json +6 -0
  46. package/package.json +66 -0
  47. package/screenshots.json +3 -0
@@ -0,0 +1,1070 @@
1
+ /**
2
+ * dsh-mosaic spec validation and deterministic repair.
3
+ *
4
+ * The validator is intentionally small: it keeps a compact component
5
+ * vocabulary, drops invalid nodes with a path, and returns a spec only when
6
+ * every node is safe to render. The browser partial parser is more forgiving;
7
+ * this module is the preflight / tool-call authority.
8
+ */
9
+
10
+ export const LIMITS = {
11
+ maxNodes: 80,
12
+ maxDepth: 6,
13
+ maxString: 2000,
14
+ maxCode: 12000,
15
+ maxWidget: 20000,
16
+ maxTableRows: 40,
17
+ maxTableCols: 8,
18
+ maxChartPoints: 40,
19
+ maxSeries: 8,
20
+ maxListItems: 30,
21
+ maxTabs: 8,
22
+ maxQuizOptions: 6,
23
+ }
24
+
25
+ const TEXT_SIZES = new Set(['display', 'h1', 'h2', 'h3', 'body', 'muted', 'caption'])
26
+ const TEXT_TONES = new Set(['danger', 'success', 'warning', 'info', 'muted'])
27
+ const TEXT_ALIGNS = new Set(['left', 'center', 'right'])
28
+ const CARD_TONES = new Set(['info', 'success', 'warning', 'danger'])
29
+ const CALLOUT_TONES = new Set(['info', 'success', 'warning', 'error'])
30
+ const BADGE_TONES = new Set(['success', 'warn', 'danger', 'accent'])
31
+ const BUTTON_TONES = new Set(['primary', 'danger', 'success', 'ghost'])
32
+ const CHART_KINDS = new Set(['bars', 'line', 'donut'])
33
+ const DIAGRAM_KINDS = new Set(['flowchart', 'sequence', 'timeline', 'layers'])
34
+ const DIAGRAM_DIRECTIONS = new Set(['TB', 'LR'])
35
+ const DIAGRAM_TONES = new Set(['accent', 'success', 'warning', 'danger', 'muted'])
36
+ const DIAGRAM_SHAPES = new Set(['box', 'round', 'diamond'])
37
+ const DIAGRAM_EDGE_KINDS = new Set(['solid', 'dashed', 'accent'])
38
+ const SEQUENCE_MESSAGE_KINDS = new Set(['sync', 'return', 'async'])
39
+ const ASPECTS = new Set(['16:9', '4:3', '1:1', '9:16'])
40
+ const LOCAL_ACTIONS = new Set(['reset', 'copy', 'open', 'fullscreen'])
41
+
42
+ function isRecord(value) {
43
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
44
+ }
45
+
46
+ function asString(value, cap = LIMITS.maxString) {
47
+ return typeof value === 'string' ? value.slice(0, cap) : undefined
48
+ }
49
+
50
+ function asNumber(value, min = -Infinity, max = Infinity) {
51
+ return typeof value === 'number' && Number.isFinite(value)
52
+ ? Math.min(max, Math.max(min, value))
53
+ : undefined
54
+ }
55
+
56
+ function asInteger(value, min = -Infinity, max = Infinity) {
57
+ const n = asNumber(value, min, max)
58
+ return n === undefined ? undefined : Math.trunc(n)
59
+ }
60
+
61
+ function asEnum(value, values) {
62
+ return typeof value === 'string' && values.has(value) ? value : undefined
63
+ }
64
+
65
+ function asBoolean(value) {
66
+ return typeof value === 'boolean' ? value : undefined
67
+ }
68
+
69
+ function looksLikeSpec(value) {
70
+ return isRecord(value) && Array.isArray(value.items)
71
+ }
72
+
73
+ function looksLikeNode(value) {
74
+ return isRecord(value) && typeof value.type === 'string'
75
+ }
76
+
77
+ /**
78
+ * Keep only browser-reachable http(s), mailto, or root-relative targets.
79
+ * `data:`, `blob:`, `file:`, `javascript:` and protocol-relative URLs are
80
+ * rejected; widgets are the only arbitrary-code escape hatch.
81
+ */
82
+ export function safeUrl(value, { media = false } = {}) {
83
+ const raw = asString(value, 2048)
84
+ if (raw === undefined) return undefined
85
+ const url = raw.trim()
86
+ if (url === '') return undefined
87
+ if (/^https?:\/\//i.test(url)) return url
88
+ if (!media && /^mailto:[^@\s]+@[^@\s]+$/i.test(url)) return url
89
+ if (url.startsWith('/') && !url.startsWith('//')) return url
90
+ if (url.startsWith('./') || url.startsWith('../')) return url
91
+ return undefined
92
+ }
93
+
94
+ function fail(state, path, message) {
95
+ state.errors.push(`${path}: ${message}`)
96
+ }
97
+
98
+ function takeString(state, path, value, cap = LIMITS.maxString) {
99
+ const result = asString(value, cap)
100
+ if (result === undefined) fail(state, path, 'must be a string')
101
+ return result
102
+ }
103
+
104
+ function takeOptionalNumber(state, path, value, min, max) {
105
+ if (value === undefined) return undefined
106
+ const result = asNumber(value, min, max)
107
+ if (result === undefined) fail(state, path, 'must be a finite number')
108
+ return result
109
+ }
110
+
111
+ function takeOptionalInteger(state, path, value, min, max) {
112
+ if (value === undefined) return undefined
113
+ const result = asInteger(value, min, max)
114
+ if (result === undefined) fail(state, path, 'must be an integer')
115
+ return result
116
+ }
117
+
118
+ function takeNodeList(state, path, value, depth) {
119
+ if (!Array.isArray(value)) {
120
+ fail(state, path, 'must be an array')
121
+ return undefined
122
+ }
123
+ const out = []
124
+ for (let index = 0; index < value.length; index += 1) {
125
+ const node = repairNode(state, value[index], `${path}[${index}]`, depth + 1)
126
+ if (node !== null) out.push(node)
127
+ }
128
+ return out
129
+ }
130
+
131
+ function takeStringList(state, path, value, cap = LIMITS.maxString, maxItems = LIMITS.maxListItems) {
132
+ if (!Array.isArray(value)) {
133
+ fail(state, path, 'must be an array')
134
+ return undefined
135
+ }
136
+ const out = []
137
+ for (let index = 0; index < value.length && out.length < maxItems; index += 1) {
138
+ const item = asString(value[index], cap)
139
+ if (item === undefined) {
140
+ fail(state, `${path}[${index}]`, 'must be a string')
141
+ continue
142
+ }
143
+ out.push(item)
144
+ }
145
+ return out
146
+ }
147
+
148
+ function normalizeListItems(state, path, value, depth) {
149
+ if (!Array.isArray(value)) {
150
+ fail(state, path, 'must be an array')
151
+ return undefined
152
+ }
153
+ const out = []
154
+ for (let index = 0; index < value.length && out.length < LIMITS.maxListItems; index += 1) {
155
+ const item = value[index]
156
+ const at = `${path}[${index}]`
157
+ if (typeof item === 'string') {
158
+ out.push(item.slice(0, LIMITS.maxString))
159
+ continue
160
+ }
161
+ if (looksLikeNode(item)) {
162
+ const node = repairNode(state, item, at, depth + 1)
163
+ if (node !== null) out.push(node)
164
+ continue
165
+ }
166
+ if (!isRecord(item)) {
167
+ fail(state, at, 'must be a string, record, or component')
168
+ continue
169
+ }
170
+ const title = asString(item.title ?? item.label ?? item.name, LIMITS.maxString)
171
+ if (title === undefined) {
172
+ fail(state, at, 'record needs a title')
173
+ continue
174
+ }
175
+ out.push({
176
+ title,
177
+ ...(asString(item.desc ?? item.description ?? item.content ?? item.text, LIMITS.maxString) === undefined
178
+ ? {}
179
+ : { desc: asString(item.desc ?? item.description ?? item.content ?? item.text, LIMITS.maxString) }),
180
+ })
181
+ }
182
+ return out
183
+ }
184
+
185
+ function normalizeTable(state, path, value) {
186
+ let columnsRaw = value.columns ?? value.headers
187
+ let rowsRaw = value.rows ?? value.data
188
+
189
+ if (rowsRaw !== undefined && !Array.isArray(rowsRaw)) {
190
+ fail(state, `${path}.rows`, 'must be an array')
191
+ return null
192
+ }
193
+
194
+ // Object rows: use the declared columns or the first row's keys.
195
+ if (Array.isArray(rowsRaw) && rowsRaw.length > 0 && isRecord(rowsRaw[0])) {
196
+ const keys = Array.isArray(columnsRaw) && columnsRaw.every((column) => typeof column === 'string')
197
+ ? columnsRaw
198
+ : Object.keys(rowsRaw[0])
199
+ rowsRaw = rowsRaw.map((row) => keys.map((key) => (isRecord(row) ? row[key] : undefined)))
200
+ if (!Array.isArray(columnsRaw)) columnsRaw = keys
201
+ }
202
+
203
+ // 2D rows: derive the header from the leading row when columns are absent.
204
+ if ((!Array.isArray(columnsRaw) || columnsRaw.length === 0)
205
+ && Array.isArray(rowsRaw) && rowsRaw.length > 0 && Array.isArray(rowsRaw[0])) {
206
+ columnsRaw = rowsRaw[0]
207
+ rowsRaw = rowsRaw.slice(1)
208
+ }
209
+
210
+ const columns = takeStringList(state, `${path}.columns`, columnsRaw, 256, LIMITS.maxTableCols)
211
+ if (columns === undefined || columns.length === 0) {
212
+ if (columns !== undefined) fail(state, `${path}.columns`, 'must not be empty')
213
+ return null
214
+ }
215
+
216
+ if (!Array.isArray(rowsRaw)) {
217
+ fail(state, `${path}.rows`, 'must be an array')
218
+ return null
219
+ }
220
+
221
+ const rows = []
222
+ for (let rowIndex = 0; rowIndex < rowsRaw.length && rows.length < LIMITS.maxTableRows; rowIndex += 1) {
223
+ const row = rowsRaw[rowIndex]
224
+ if (!Array.isArray(row)) {
225
+ fail(state, `${path}.rows[${rowIndex}]`, 'must be an array')
226
+ continue
227
+ }
228
+ const cells = []
229
+ for (let cellIndex = 0; cellIndex < row.length && cellIndex < columns.length; cellIndex += 1) {
230
+ const cell = row[cellIndex]
231
+ if (typeof cell === 'string') cells.push(cell.slice(0, 512))
232
+ else if (typeof cell === 'number' && Number.isFinite(cell)) cells.push(cell)
233
+ else if (cell === null) cells.push('')
234
+ else fail(state, `${path}.rows[${rowIndex}][${cellIndex}]`, 'must be a string, number, or null')
235
+ }
236
+ rows.push(cells)
237
+ }
238
+
239
+ const types = Array.isArray(value.types)
240
+ ? columns.map((_, index) => {
241
+ const type = value.types[index]
242
+ return typeof type === 'string' && ['text', 'num', 'delta', 'bar', 'badge'].includes(type) ? type : 'text'
243
+ })
244
+ : undefined
245
+
246
+ return {
247
+ type: 'table',
248
+ columns,
249
+ rows,
250
+ ...(types === undefined ? {} : { types }),
251
+ ...(asBoolean(value.total) === true ? { total: true } : {}),
252
+ ...(asBoolean(value.export) === true ? { export: true } : {}),
253
+ ...(asString(value.filter, 200) === undefined ? {} : { filter: asString(value.filter, 200) }),
254
+ ...(asString(value.sort, 200) === undefined ? {} : { sort: asString(value.sort, 200) }),
255
+ }
256
+ }
257
+
258
+ function normalizeChart(state, path, value) {
259
+ const kind = value.kind === undefined ? 'bars' : asEnum(value.kind, CHART_KINDS)
260
+ if (kind === undefined) {
261
+ fail(state, `${path}.kind`, 'must be bars, line, or donut')
262
+ return null
263
+ }
264
+ const data = Array.isArray(value.data)
265
+ ? value.data.slice(0, LIMITS.maxChartPoints).flatMap((datum, index) => {
266
+ if (!isRecord(datum)) {
267
+ fail(state, `${path}.data[${index}]`, 'must be an object')
268
+ return []
269
+ }
270
+ const label = asString(datum.label, 128)
271
+ const number = asNumber(datum.value)
272
+ if (label === undefined || number === undefined) {
273
+ fail(state, `${path}.data[${index}]`, 'needs label (string) and value (finite number)')
274
+ return []
275
+ }
276
+ return [{ label, value: number }]
277
+ })
278
+ : undefined
279
+
280
+ const series = Array.isArray(value.series)
281
+ ? value.series.slice(0, LIMITS.maxSeries).flatMap((entry, index) => {
282
+ if (!isRecord(entry)) {
283
+ fail(state, `${path}.series[${index}]`, 'must be an object')
284
+ return []
285
+ }
286
+ const label = asString(entry.label, 128)
287
+ if (label === undefined) {
288
+ fail(state, `${path}.series[${index}].label`, 'must be a string')
289
+ return []
290
+ }
291
+ const points = Array.isArray(entry.data)
292
+ ? entry.data.slice(0, LIMITS.maxChartPoints).flatMap((point, pointIndex) => {
293
+ if (!isRecord(point)) {
294
+ fail(state, `${path}.series[${index}].data[${pointIndex}]`, 'must be an object')
295
+ return []
296
+ }
297
+ const pointLabel = asString(point.label, 128)
298
+ const pointValue = asNumber(point.value)
299
+ if (pointLabel === undefined || pointValue === undefined) {
300
+ fail(state, `${path}.series[${index}].data[${pointIndex}]`, 'needs label and value')
301
+ return []
302
+ }
303
+ return [{ label: pointLabel, value: pointValue }]
304
+ })
305
+ : []
306
+ return [{ label, data: points }]
307
+ })
308
+ : undefined
309
+
310
+ if ((data === undefined || data.length === 0) && (series === undefined || series.length === 0)) {
311
+ fail(state, path, 'requires data or series')
312
+ return null
313
+ }
314
+ if (kind === 'donut' && (data === undefined || data.length === 0)) {
315
+ fail(state, `${path}.data`, 'is required for donut')
316
+ return null
317
+ }
318
+ if (kind === 'donut' && series !== undefined) {
319
+ fail(state, `${path}.series`, 'is not supported for donut')
320
+ return null
321
+ }
322
+
323
+ return {
324
+ type: 'chart',
325
+ kind,
326
+ ...(data === undefined ? {} : { data }),
327
+ ...(series === undefined ? {} : { series }),
328
+ ...(asBoolean(value.stacked) === true ? { stacked: true } : {}),
329
+ ...(asString(value.title, LIMITS.maxString) === undefined ? {} : { title: asString(value.title, LIMITS.maxString) }),
330
+ }
331
+ }
332
+
333
+ function normalizeWidget(state, path, value) {
334
+ const code = asString(value.code, LIMITS.maxWidget)
335
+ if (code === undefined) {
336
+ fail(state, `${path}.code`, 'must be a string')
337
+ return null
338
+ }
339
+ return {
340
+ type: 'widget',
341
+ code,
342
+ height: asInteger(value.height, 80, 900) ?? 320,
343
+ ...(asString(value.title, 256) === undefined ? {} : { title: asString(value.title, 256) }),
344
+ ...(asString(value.caption, LIMITS.maxString) === undefined ? {} : { caption: asString(value.caption, LIMITS.maxString) }),
345
+ // Widgets may only call the model without a fresh user gesture when they opt in.
346
+ ...(value.actions === 'programmatic' ? { actions: 'programmatic' } : {}),
347
+ }
348
+ }
349
+
350
+ /**
351
+ * Normalize a small structured diagram: flowchart, sequence, timeline, or
352
+ * layered stack. All members are plain JSON so the client can lay them out
353
+ * without a diagram engine.
354
+ */
355
+ function normalizeDiagram(state, path, raw) {
356
+ const inferred = raw.kind === undefined
357
+ ? (Array.isArray(raw.nodes) || Array.isArray(raw.edges) ? 'flowchart'
358
+ : Array.isArray(raw.participants) || Array.isArray(raw.messages) ? 'sequence'
359
+ : Array.isArray(raw.layers) ? 'layers'
360
+ : Array.isArray(raw.items) ? 'timeline' : undefined)
361
+ : undefined
362
+ const kind = raw.kind === undefined ? inferred : asEnum(raw.kind, DIAGRAM_KINDS)
363
+ if (kind === undefined) {
364
+ fail(state, `${path}.kind`, 'must be flowchart, sequence, timeline, or layers')
365
+ return null
366
+ }
367
+ const title = asString(raw.title, 256)
368
+ const common = { type: 'diagram', kind, ...(title === undefined ? {} : { title }) }
369
+
370
+ if (kind === 'flowchart') {
371
+ if (!Array.isArray(raw.nodes) || raw.nodes.length === 0) {
372
+ fail(state, `${path}.nodes`, 'flowchart requires a non-empty nodes array')
373
+ return null
374
+ }
375
+ const nodes = []
376
+ const ids = new Set()
377
+ for (let index = 0; index < raw.nodes.length && nodes.length < 12; index += 1) {
378
+ const node = raw.nodes[index]
379
+ const at = `${path}.nodes[${index}]`
380
+ if (!isRecord(node)) {
381
+ fail(state, at, 'must be an object')
382
+ continue
383
+ }
384
+ const id = asString(node.id, 64)
385
+ const label = asString(node.label, 128)
386
+ if (id === undefined || label === undefined) {
387
+ fail(state, at, 'requires id and label strings')
388
+ continue
389
+ }
390
+ if (ids.has(id)) {
391
+ fail(state, at, `duplicate node id '${id}'`)
392
+ continue
393
+ }
394
+ ids.add(id)
395
+ nodes.push({
396
+ id,
397
+ label,
398
+ ...(asString(node.sub, 128) === undefined ? {} : { sub: asString(node.sub, 128) }),
399
+ ...(asEnum(node.tone, DIAGRAM_TONES) === undefined ? {} : { tone: asEnum(node.tone, DIAGRAM_TONES) }),
400
+ ...(asEnum(node.shape, DIAGRAM_SHAPES) === undefined ? {} : { shape: asEnum(node.shape, DIAGRAM_SHAPES) }),
401
+ })
402
+ }
403
+ if (nodes.length === 0) return null
404
+ const edges = []
405
+ if (raw.edges !== undefined) {
406
+ if (!Array.isArray(raw.edges)) {
407
+ fail(state, `${path}.edges`, 'must be an array')
408
+ return null
409
+ }
410
+ for (let index = 0; index < raw.edges.length && edges.length < 20; index += 1) {
411
+ const edge = raw.edges[index]
412
+ const at = `${path}.edges[${index}]`
413
+ if (!isRecord(edge)) {
414
+ fail(state, at, 'must be an object')
415
+ continue
416
+ }
417
+ const from = asString(edge.from, 64)
418
+ const to = asString(edge.to, 64)
419
+ if (from === undefined || to === undefined) {
420
+ fail(state, at, 'requires from and to strings')
421
+ continue
422
+ }
423
+ if (!ids.has(from) || !ids.has(to)) {
424
+ fail(state, at, `unknown endpoint ${!ids.has(from) ? from : to}`)
425
+ continue
426
+ }
427
+ edges.push({
428
+ from,
429
+ to,
430
+ ...(asString(edge.label, 64) === undefined ? {} : { label: asString(edge.label, 64) }),
431
+ ...(asEnum(edge.kind, DIAGRAM_EDGE_KINDS) === undefined ? {} : { kind: asEnum(edge.kind, DIAGRAM_EDGE_KINDS) }),
432
+ })
433
+ }
434
+ }
435
+ return {
436
+ ...common,
437
+ direction: asEnum(raw.direction, DIAGRAM_DIRECTIONS) ?? 'TB',
438
+ nodes,
439
+ edges,
440
+ }
441
+ }
442
+
443
+ if (kind === 'sequence') {
444
+ if (!Array.isArray(raw.participants) || raw.participants.length === 0) {
445
+ fail(state, `${path}.participants`, 'sequence requires a non-empty participants array')
446
+ return null
447
+ }
448
+ const participants = []
449
+ const ids = new Set()
450
+ for (let index = 0; index < raw.participants.length && participants.length < 8; index += 1) {
451
+ const participant = raw.participants[index]
452
+ const at = `${path}.participants[${index}]`
453
+ if (!isRecord(participant)) {
454
+ fail(state, at, 'must be an object')
455
+ continue
456
+ }
457
+ const id = asString(participant.id, 64)
458
+ const label = asString(participant.label, 128)
459
+ if (id === undefined || label === undefined) {
460
+ fail(state, at, 'requires id and label strings')
461
+ continue
462
+ }
463
+ if (ids.has(id)) {
464
+ fail(state, at, `duplicate participant id '${id}'`)
465
+ continue
466
+ }
467
+ ids.add(id)
468
+ participants.push({ id, label })
469
+ }
470
+ if (participants.length === 0) return null
471
+ if (!Array.isArray(raw.messages) || raw.messages.length === 0) {
472
+ fail(state, `${path}.messages`, 'sequence requires a non-empty messages array')
473
+ return null
474
+ }
475
+ const messages = []
476
+ for (let index = 0; index < raw.messages.length && messages.length < 20; index += 1) {
477
+ const message = raw.messages[index]
478
+ const at = `${path}.messages[${index}]`
479
+ if (!isRecord(message)) {
480
+ fail(state, at, 'must be an object')
481
+ continue
482
+ }
483
+ const from = asString(message.from, 64)
484
+ const to = asString(message.to, 64)
485
+ const label = asString(message.label, 128)
486
+ if (from === undefined || to === undefined || label === undefined) {
487
+ fail(state, at, 'requires from, to, and label strings')
488
+ continue
489
+ }
490
+ if (!ids.has(from) || !ids.has(to)) {
491
+ fail(state, at, `unknown participant ${!ids.has(from) ? from : to}`)
492
+ continue
493
+ }
494
+ messages.push({
495
+ from,
496
+ to,
497
+ label,
498
+ ...(asEnum(message.kind, SEQUENCE_MESSAGE_KINDS) === undefined ? {} : { kind: asEnum(message.kind, SEQUENCE_MESSAGE_KINDS) }),
499
+ })
500
+ }
501
+ if (messages.length === 0) return null
502
+ return { ...common, participants, messages }
503
+ }
504
+
505
+ if (kind === 'timeline') {
506
+ if (!Array.isArray(raw.items) || raw.items.length === 0) {
507
+ fail(state, `${path}.items`, 'timeline requires a non-empty items array')
508
+ return null
509
+ }
510
+ const items = []
511
+ for (let index = 0; index < raw.items.length && items.length < 12; index += 1) {
512
+ const item = raw.items[index]
513
+ const at = `${path}.items[${index}]`
514
+ if (!isRecord(item)) {
515
+ fail(state, at, 'must be an object')
516
+ continue
517
+ }
518
+ const itemTitle = asString(item.title, 256)
519
+ if (itemTitle === undefined) {
520
+ fail(state, at, 'requires a title string')
521
+ continue
522
+ }
523
+ items.push({
524
+ title: itemTitle,
525
+ ...(asString(item.desc, LIMITS.maxString) === undefined ? {} : { desc: asString(item.desc, LIMITS.maxString) }),
526
+ ...(asString(item.time, 64) === undefined ? {} : { time: asString(item.time, 64) }),
527
+ })
528
+ }
529
+ return items.length === 0 ? null : { ...common, items }
530
+ }
531
+
532
+ if (!Array.isArray(raw.layers) || raw.layers.length === 0) {
533
+ fail(state, `${path}.layers`, 'layers requires a non-empty layers array')
534
+ return null
535
+ }
536
+ const layers = []
537
+ for (let index = 0; index < raw.layers.length && layers.length < 8; index += 1) {
538
+ const layer = raw.layers[index]
539
+ const at = `${path}.layers[${index}]`
540
+ if (!isRecord(layer)) {
541
+ fail(state, at, 'must be an object')
542
+ continue
543
+ }
544
+ const layerTitle = asString(layer.title, 128)
545
+ if (layerTitle === undefined) {
546
+ fail(state, at, 'requires a title string')
547
+ continue
548
+ }
549
+ const items = takeStringList(state, `${at}.items`, layer.items, 256, 6)
550
+ if (items === undefined || items.length === 0) {
551
+ fail(state, `${at}.items`, 'must be a non-empty string array')
552
+ continue
553
+ }
554
+ layers.push({ title: layerTitle, items })
555
+ }
556
+ return layers.length === 0 ? null : { ...common, layers }
557
+ }
558
+
559
+ /**
560
+ * Repair one node. Returns a JSON-safe node or null after pushing an error.
561
+ */
562
+ function repairNode(state, raw, path, depth) {
563
+ if (depth > LIMITS.maxDepth) {
564
+ fail(state, path, `exceeds max depth ${LIMITS.maxDepth}`)
565
+ return null
566
+ }
567
+ if (state.count >= LIMITS.maxNodes) {
568
+ fail(state, path, `exceeds max nodes ${LIMITS.maxNodes}`)
569
+ return null
570
+ }
571
+ if (!isRecord(raw)) {
572
+ fail(state, path, 'must be an object')
573
+ return null
574
+ }
575
+ if (typeof raw.type !== 'string') {
576
+ fail(state, path, 'missing string type')
577
+ return null
578
+ }
579
+ state.count += 1
580
+ const type = raw.type
581
+
582
+ switch (type) {
583
+ case 'text': {
584
+ const content = asString(raw.content ?? raw.text, LIMITS.maxString)
585
+ if (content === undefined) {
586
+ fail(state, `${path}.content`, 'must be a string')
587
+ return null
588
+ }
589
+ return {
590
+ type: 'text',
591
+ content,
592
+ ...(asEnum(raw.size, TEXT_SIZES) === undefined ? {} : { size: asEnum(raw.size, TEXT_SIZES) }),
593
+ ...(asEnum(raw.align, TEXT_ALIGNS) === undefined ? {} : { align: asEnum(raw.align, TEXT_ALIGNS) }),
594
+ ...(asEnum(raw.tone, TEXT_TONES) === undefined ? {} : { tone: asEnum(raw.tone, TEXT_TONES) }),
595
+ ...(asBoolean(raw.mono) === true ? { mono: true } : {}),
596
+ }
597
+ }
598
+ case 'row': {
599
+ const items = takeNodeList(state, `${path}.items`, raw.items, depth)
600
+ if (items === undefined || items.length === 0) return null
601
+ return {
602
+ type: 'row',
603
+ items,
604
+ ...(raw.gap === undefined ? {} : { gap: takeOptionalNumber(state, `${path}.gap`, raw.gap, 0, 48) }),
605
+ ...(raw.align === undefined ? {} : { align: asEnum(raw.align, new Set(['start', 'center', 'end', 'between'])) }),
606
+ ...(asBoolean(raw.wrap) === true ? { wrap: true } : {}),
607
+ }
608
+ }
609
+ case 'col': {
610
+ const items = takeNodeList(state, `${path}.items`, raw.items, depth)
611
+ if (items === undefined || items.length === 0) return null
612
+ return {
613
+ type: 'col',
614
+ items,
615
+ ...(raw.gap === undefined ? {} : { gap: takeOptionalNumber(state, `${path}.gap`, raw.gap, 0, 48) }),
616
+ ...(raw.width === undefined ? {} : { width: takeOptionalInteger(state, `${path}.width`, raw.width, 1, 100) }),
617
+ }
618
+ }
619
+ case 'grid': {
620
+ const items = takeNodeList(state, `${path}.items`, raw.items, depth)
621
+ if (items === undefined || items.length === 0) return null
622
+ return {
623
+ type: 'grid',
624
+ cols: asInteger(raw.cols, 1, 6) ?? 2,
625
+ items,
626
+ ...(raw.gap === undefined ? {} : { gap: takeOptionalNumber(state, `${path}.gap`, raw.gap, 0, 48) }),
627
+ }
628
+ }
629
+ case 'card': {
630
+ const items = takeNodeList(state, `${path}.items`, raw.items, depth)
631
+ if (items === undefined || items.length === 0) return null
632
+ return {
633
+ type: 'card',
634
+ items,
635
+ ...(asString(raw.title, LIMITS.maxString) === undefined ? {} : { title: asString(raw.title, LIMITS.maxString) }),
636
+ ...(asString(raw.eyebrow, 128) === undefined ? {} : { eyebrow: asString(raw.eyebrow, 128) }),
637
+ ...(asEnum(raw.tone, CARD_TONES) === undefined ? {} : { tone: asEnum(raw.tone, CARD_TONES) }),
638
+ ...(asBoolean(raw.flat) === true ? { flat: true } : {}),
639
+ }
640
+ }
641
+ case 'divider':
642
+ return { type: 'divider', ...(asString(raw.label, 128) === undefined ? {} : { label: asString(raw.label, 128) }) }
643
+ case 'spacer':
644
+ return { type: 'spacer' }
645
+ case 'stat': {
646
+ const label = asString(raw.label ?? raw.title, LIMITS.maxString)
647
+ const value = asString(raw.value, 128)
648
+ if (label === undefined || value === undefined) {
649
+ fail(state, path, 'stat requires label and value')
650
+ return null
651
+ }
652
+ const spark = Array.isArray(raw.spark)
653
+ ? raw.spark.slice(0, 20).filter((n) => typeof n === 'number' && Number.isFinite(n))
654
+ : undefined
655
+ return {
656
+ type: 'stat',
657
+ label,
658
+ value,
659
+ ...(asString(raw.delta, 64) === undefined ? {} : { delta: asString(raw.delta, 64) }),
660
+ ...(spark !== undefined && spark.length >= 2 ? { spark } : {}),
661
+ }
662
+ }
663
+ case 'badge': {
664
+ const label = asString(raw.label ?? raw.text, LIMITS.maxString)
665
+ if (label === undefined) {
666
+ fail(state, `${path}.label`, 'must be a string')
667
+ return null
668
+ }
669
+ return { type: 'badge', label, ...(asEnum(raw.tone, BADGE_TONES) === undefined ? {} : { tone: asEnum(raw.tone, BADGE_TONES) }) }
670
+ }
671
+ case 'progress': {
672
+ const number = asNumber(raw.value, 0, 100)
673
+ if (number === undefined) {
674
+ fail(state, `${path}.value`, 'must be a number from 0 to 100')
675
+ return null
676
+ }
677
+ return {
678
+ type: 'progress',
679
+ value: number,
680
+ ...(asString(raw.label, LIMITS.maxString) === undefined ? {} : { label: asString(raw.label, LIMITS.maxString) }),
681
+ ...(asString(raw.valueLabel, 64) === undefined ? {} : { valueLabel: asString(raw.valueLabel, 64) }),
682
+ ...(asEnum(raw.variant, new Set(['bar', 'ring'])) === undefined ? {} : { variant: asEnum(raw.variant, new Set(['bar', 'ring'])) }),
683
+ ...(asNumber(raw.target, 0, 100) === undefined ? {} : { target: asNumber(raw.target, 0, 100) }),
684
+ }
685
+ }
686
+ case 'list': {
687
+ const items = normalizeListItems(state, `${path}.items`, raw.items, depth)
688
+ if (items === undefined || items.length === 0) return null
689
+ return { type: 'list', items, ...(asString(raw.filter, 200) === undefined ? {} : { filter: asString(raw.filter, 200) }) }
690
+ }
691
+ case 'table': {
692
+ const table = normalizeTable(state, path, raw)
693
+ return table
694
+ }
695
+ case 'keyvalue': {
696
+ if (!Array.isArray(raw.pairs)) {
697
+ fail(state, `${path}.pairs`, 'must be an array')
698
+ return null
699
+ }
700
+ const pairs = raw.pairs.slice(0, 20).flatMap((pair, index) => {
701
+ if (!isRecord(pair)) {
702
+ fail(state, `${path}.pairs[${index}]`, 'must be an object')
703
+ return []
704
+ }
705
+ const key = asString(pair.key ?? pair.label, 256)
706
+ const value = asString(pair.value, LIMITS.maxString)
707
+ if (key === undefined || value === undefined) {
708
+ fail(state, `${path}.pairs[${index}]`, 'needs key and value strings')
709
+ return []
710
+ }
711
+ return [{ key, value }]
712
+ })
713
+ if (pairs.length === 0) return null
714
+ return { type: 'keyvalue', pairs }
715
+ }
716
+ case 'steps': {
717
+ if (!Array.isArray(raw.steps)) {
718
+ fail(state, `${path}.steps`, 'must be an array')
719
+ return null
720
+ }
721
+ const steps = raw.steps.slice(0, 12).flatMap((step, index) => {
722
+ if (!isRecord(step)) {
723
+ fail(state, `${path}.steps[${index}]`, 'must be an object')
724
+ return []
725
+ }
726
+ const title = asString(step.title, 256)
727
+ if (title === undefined) {
728
+ fail(state, `${path}.steps[${index}].title`, 'must be a string')
729
+ return []
730
+ }
731
+ return [{ title, ...(asString(step.desc, LIMITS.maxString) === undefined ? {} : { desc: asString(step.desc, LIMITS.maxString) }) }]
732
+ })
733
+ if (steps.length === 0) return null
734
+ return {
735
+ type: 'steps',
736
+ steps,
737
+ ...(asInteger(raw.current, 0, steps.length) === undefined ? {} : { current: asInteger(raw.current, 0, steps.length) }),
738
+ }
739
+ }
740
+ case 'callout': {
741
+ const content = asString(raw.content ?? raw.text ?? raw.body ?? raw.desc, LIMITS.maxString)
742
+ if (content === undefined) {
743
+ fail(state, `${path}.content`, 'must be a string')
744
+ return null
745
+ }
746
+ const toneRaw = raw.tone ?? raw.kind
747
+ const tone = toneRaw === 'danger' ? 'error' : toneRaw === 'warn' ? 'warning' : asEnum(toneRaw, CALLOUT_TONES)
748
+ return {
749
+ type: 'callout',
750
+ content,
751
+ ...(tone === undefined ? {} : { tone }),
752
+ ...(asString(raw.title, LIMITS.maxString) === undefined ? {} : { title: asString(raw.title, LIMITS.maxString) }),
753
+ }
754
+ }
755
+ case 'code': {
756
+ const code = asString(raw.code, LIMITS.maxCode)
757
+ if (code === undefined) {
758
+ fail(state, `${path}.code`, 'must be a string')
759
+ return null
760
+ }
761
+ return { type: 'code', code, ...(asString(raw.lang, 32) === undefined ? {} : { lang: asString(raw.lang, 32) }) }
762
+ }
763
+ case 'diff': {
764
+ if (!Array.isArray(raw.diffs)) {
765
+ fail(state, `${path}.diffs`, 'must be an array')
766
+ return null
767
+ }
768
+ const diffs = raw.diffs.slice(0, 8).flatMap((diff, index) => {
769
+ if (!isRecord(diff)) {
770
+ fail(state, `${path}.diffs[${index}]`, 'must be an object')
771
+ return []
772
+ }
773
+ const file = asString(diff.path, 512)
774
+ const newText = asString(diff.newText, LIMITS.maxCode)
775
+ if (file === undefined || newText === undefined) {
776
+ fail(state, `${path}.diffs[${index}]`, 'needs path and newText')
777
+ return []
778
+ }
779
+ return [{
780
+ path: file,
781
+ newText,
782
+ oldText: typeof diff.oldText === 'string' ? diff.oldText.slice(0, LIMITS.maxCode) : null,
783
+ }]
784
+ })
785
+ if (diffs.length === 0) return null
786
+ return { type: 'diff', diffs }
787
+ }
788
+ case 'chart':
789
+ return normalizeChart(state, path, raw)
790
+ case 'image': {
791
+ const src = safeUrl(raw.src ?? raw.url, { media: true })
792
+ if (src === undefined) {
793
+ fail(state, `${path}.src`, 'must be an http(s) or relative URL')
794
+ return null
795
+ }
796
+ return {
797
+ type: 'image',
798
+ src,
799
+ ...(asString(raw.alt, LIMITS.maxString) === undefined ? {} : { alt: asString(raw.alt, LIMITS.maxString) }),
800
+ ...(asString(raw.caption, LIMITS.maxString) === undefined ? {} : { caption: asString(raw.caption, LIMITS.maxString) }),
801
+ }
802
+ }
803
+ case 'video': {
804
+ const src = safeUrl(raw.src ?? raw.url, { media: true })
805
+ if (src === undefined) {
806
+ fail(state, `${path}.src`, 'must be an http(s) or relative URL')
807
+ return null
808
+ }
809
+ const poster = raw.poster === undefined ? undefined : safeUrl(raw.poster, { media: true })
810
+ if (raw.poster !== undefined && poster === undefined) {
811
+ fail(state, `${path}.poster`, 'must be a safe media URL')
812
+ return null
813
+ }
814
+ return {
815
+ type: 'video',
816
+ src,
817
+ ...(poster === undefined ? {} : { poster }),
818
+ ...(asEnum(raw.aspect, ASPECTS) === undefined ? {} : { aspect: asEnum(raw.aspect, ASPECTS) }),
819
+ ...(asString(raw.caption, LIMITS.maxString) === undefined ? {} : { caption: asString(raw.caption, LIMITS.maxString) }),
820
+ }
821
+ }
822
+ case 'audio': {
823
+ const src = safeUrl(raw.src ?? raw.url, { media: true })
824
+ if (src === undefined) {
825
+ fail(state, `${path}.src`, 'must be an http(s) or relative URL')
826
+ return null
827
+ }
828
+ return {
829
+ type: 'audio',
830
+ src,
831
+ ...(asString(raw.caption, LIMITS.maxString) === undefined ? {} : { caption: asString(raw.caption, LIMITS.maxString) }),
832
+ }
833
+ }
834
+ case 'pdf': {
835
+ const src = safeUrl(raw.src ?? raw.url, { media: true })
836
+ if (src === undefined) {
837
+ fail(state, `${path}.src`, 'must be an http(s) or relative URL')
838
+ return null
839
+ }
840
+ return {
841
+ type: 'pdf',
842
+ src,
843
+ ...(asString(raw.title, 256) === undefined ? {} : { title: asString(raw.title, 256) }),
844
+ }
845
+ }
846
+ case 'link': {
847
+ const label = asString(raw.label ?? raw.text, LIMITS.maxString)
848
+ const href = safeUrl(raw.href ?? raw.url)
849
+ if (label === undefined || href === undefined) {
850
+ fail(state, path, 'link requires label and an http(s)/mailto href')
851
+ return null
852
+ }
853
+ return {
854
+ type: 'link',
855
+ label,
856
+ href,
857
+ ...(asString(raw.description, LIMITS.maxString) === undefined ? {} : { description: asString(raw.description, LIMITS.maxString) }),
858
+ }
859
+ }
860
+ case 'button': {
861
+ const label = asString(raw.label ?? raw.text, LIMITS.maxString)
862
+ if (label === undefined) {
863
+ fail(state, `${path}.label`, 'must be a string')
864
+ return null
865
+ }
866
+ const action = asString(raw.action, 200)
867
+ const local = asEnum(raw.local, LOCAL_ACTIONS)
868
+ if (local === 'copy' && typeof raw.text !== 'string') {
869
+ fail(state, `${path}.text`, 'is required for local copy')
870
+ return null
871
+ }
872
+ if (local === 'open' && safeUrl(raw.url) === undefined) {
873
+ fail(state, `${path}.url`, 'is required for local open')
874
+ return null
875
+ }
876
+ return {
877
+ type: 'button',
878
+ label,
879
+ ...(action === undefined ? {} : { action }),
880
+ ...(local === undefined ? {} : { local }),
881
+ ...(raw.text === undefined ? {} : { text: asString(raw.text, LIMITS.maxCode) }),
882
+ ...(raw.url === undefined ? {} : { url: asString(raw.url, 2048) }),
883
+ ...(asEnum(raw.tone, BUTTON_TONES) === undefined ? {} : { tone: asEnum(raw.tone, BUTTON_TONES) }),
884
+ ...(asBoolean(raw.full) === true ? { full: true } : {}),
885
+ }
886
+ }
887
+ case 'input': {
888
+ return {
889
+ type: 'input',
890
+ ...(asString(raw.id, 128) === undefined ? {} : { id: asString(raw.id, 128) }),
891
+ ...(asString(raw.label, LIMITS.maxString) === undefined ? {} : { label: asString(raw.label, LIMITS.maxString) }),
892
+ ...(asString(raw.placeholder, LIMITS.maxString) === undefined ? {} : { placeholder: asString(raw.placeholder, LIMITS.maxString) }),
893
+ ...(asString(raw.value, LIMITS.maxString) === undefined ? {} : { value: asString(raw.value, LIMITS.maxString) }),
894
+ ...(asString(raw.action, 200) === undefined ? {} : { action: asString(raw.action, 200) }),
895
+ }
896
+ }
897
+ case 'select': {
898
+ const options = takeStringList(state, `${path}.options`, raw.options, 256, 20)
899
+ if (options === undefined || options.length === 0) return null
900
+ return {
901
+ type: 'select',
902
+ options,
903
+ ...(asString(raw.id, 128) === undefined ? {} : { id: asString(raw.id, 128) }),
904
+ ...(asString(raw.label, LIMITS.maxString) === undefined ? {} : { label: asString(raw.label, LIMITS.maxString) }),
905
+ ...(asInteger(raw.selected, 0, options.length - 1) === undefined ? {} : { selected: asInteger(raw.selected, 0, options.length - 1) }),
906
+ ...(asString(raw.action, 200) === undefined ? {} : { action: asString(raw.action, 200) }),
907
+ }
908
+ }
909
+ case 'slider': {
910
+ const min = asNumber(raw.min, -1e9, 1e9) ?? 0
911
+ const max = asNumber(raw.max, -1e9, 1e9) ?? 100
912
+ const lo = Math.min(min, max)
913
+ const hi = Math.max(min, max)
914
+ return {
915
+ type: 'slider',
916
+ min: lo,
917
+ max: hi,
918
+ value: asNumber(raw.value, lo, hi) ?? lo,
919
+ ...(asNumber(raw.step, 1e-9, Math.max(hi - lo, 1e-9)) === undefined ? {} : { step: asNumber(raw.step, 1e-9, Math.max(hi - lo, 1e-9)) }),
920
+ ...(asString(raw.id, 128) === undefined ? {} : { id: asString(raw.id, 128) }),
921
+ ...(asString(raw.label, LIMITS.maxString) === undefined ? {} : { label: asString(raw.label, LIMITS.maxString) }),
922
+ ...(asString(raw.action, 200) === undefined ? {} : { action: asString(raw.action, 200) }),
923
+ }
924
+ }
925
+ case 'tabs': {
926
+ if (!Array.isArray(raw.tabs)) {
927
+ fail(state, `${path}.tabs`, 'must be an array')
928
+ return null
929
+ }
930
+ const tabs = raw.tabs.slice(0, LIMITS.maxTabs).flatMap((tab, index) => {
931
+ if (!isRecord(tab)) {
932
+ fail(state, `${path}.tabs[${index}]`, 'must be an object')
933
+ return []
934
+ }
935
+ const label = asString(tab.label, 128)
936
+ if (label === undefined) {
937
+ fail(state, `${path}.tabs[${index}].label`, 'must be a string')
938
+ return []
939
+ }
940
+ const items = takeNodeList(state, `${path}.tabs[${index}].items`, tab.items ?? [], depth)
941
+ return [{ label, items: items ?? [] }]
942
+ })
943
+ if (tabs.length === 0) return null
944
+ return { type: 'tabs', tabs }
945
+ }
946
+ case 'quiz': {
947
+ const question = asString(raw.question, LIMITS.maxString)
948
+ if (question === undefined || !Array.isArray(raw.options)) {
949
+ fail(state, path, 'quiz requires question and options')
950
+ return null
951
+ }
952
+ const options = raw.options.slice(0, LIMITS.maxQuizOptions).flatMap((option, index) => {
953
+ if (typeof option === 'string') return [{ label: option.slice(0, 512) }]
954
+ if (!isRecord(option)) {
955
+ fail(state, `${path}.options[${index}]`, 'must be a string or object')
956
+ return []
957
+ }
958
+ const label = asString(option.label, 512)
959
+ if (label === undefined) {
960
+ fail(state, `${path}.options[${index}].label`, 'must be a string')
961
+ return []
962
+ }
963
+ return [{
964
+ label,
965
+ ...(asBoolean(option.correct) === true ? { correct: true } : {}),
966
+ ...(asString(option.feedback, LIMITS.maxString) === undefined ? {} : { feedback: asString(option.feedback, LIMITS.maxString) }),
967
+ }]
968
+ })
969
+ if (options.length < 2) {
970
+ fail(state, `${path}.options`, 'needs at least two options')
971
+ return null
972
+ }
973
+ return {
974
+ type: 'quiz',
975
+ question,
976
+ options,
977
+ ...(asString(raw.explanation, LIMITS.maxString) === undefined ? {} : { explanation: asString(raw.explanation, LIMITS.maxString) }),
978
+ }
979
+ }
980
+ case 'widget':
981
+ return normalizeWidget(state, path, raw)
982
+ case 'diagram':
983
+ return normalizeDiagram(state, path, raw)
984
+ default:
985
+ fail(state, `${path}.type`, `unknown component type '${type}'`)
986
+ return null
987
+ }
988
+ }
989
+
990
+ /**
991
+ * Validate and repair a raw view spec.
992
+ *
993
+ * @param {unknown} value Raw spec (object, JSON string, or single component).
994
+ * @returns {{ spec: object | null, errors: string[], warnings: string[] }}
995
+ */
996
+ export function normalizeView(value) {
997
+ const state = { count: 0, errors: [], warnings: [] }
998
+ let root = value
999
+ if (typeof root === 'string') {
1000
+ try {
1001
+ root = JSON.parse(root)
1002
+ } catch (error) {
1003
+ return { spec: null, errors: [`invalid JSON: ${error instanceof Error ? error.message : String(error)}`], warnings: [] }
1004
+ }
1005
+ }
1006
+ if (Array.isArray(root)) root = { items: root }
1007
+ if (looksLikeNode(root)) root = { items: [root] }
1008
+ if (!isRecord(root)) {
1009
+ return { spec: null, errors: ['spec root must be an object'], warnings: [] }
1010
+ }
1011
+ if (!Array.isArray(root.items)) {
1012
+ return { spec: null, errors: ['spec.items must be an array'], warnings: [] }
1013
+ }
1014
+
1015
+ const items = []
1016
+ for (let index = 0; index < root.items.length; index += 1) {
1017
+ const node = repairNode(state, root.items[index], `items[${index}]`, 0)
1018
+ if (node !== null) items.push(node)
1019
+ }
1020
+
1021
+ const spec = state.errors.length === 0 && items.length > 0
1022
+ ? {
1023
+ ...(asString(root.title, LIMITS.maxString) === undefined ? {} : { title: asString(root.title, LIMITS.maxString) }),
1024
+ ...(asNumber(root.gap, 0, 48) === undefined ? {} : { gap: asNumber(root.gap, 0, 48) }),
1025
+ items,
1026
+ }
1027
+ : null
1028
+
1029
+ return { spec, errors: state.errors, warnings: state.warnings }
1030
+ }
1031
+
1032
+ /**
1033
+ * Deep-copy a value into lossless JSON: drops undefined/function/symbol
1034
+ * properties and non-finite numbers. The Harness rejects a tool
1035
+ * `presentationMeta` that is not lossless JSON, so root and nested optional
1036
+ * fields must never carry `undefined` keys.
1037
+ */
1038
+ export function jsonSafe(value) {
1039
+ if (value === null) return null
1040
+ if (Array.isArray(value)) {
1041
+ const out = []
1042
+ for (const item of value) {
1043
+ const safe = jsonSafe(item)
1044
+ if (safe !== undefined) out.push(safe)
1045
+ }
1046
+ return out
1047
+ }
1048
+ if (typeof value === 'object') {
1049
+ const out = {}
1050
+ for (const [key, child] of Object.entries(value)) {
1051
+ if (child === undefined || typeof child === 'function' || typeof child === 'symbol') continue
1052
+ const safe = jsonSafe(child)
1053
+ if (safe !== undefined) out[key] = safe
1054
+ }
1055
+ return out
1056
+ }
1057
+ if (typeof value === 'number') return Number.isFinite(value) ? value : undefined
1058
+ if (typeof value === 'function' || typeof value === 'symbol' || typeof value === 'bigint') return undefined
1059
+ return value
1060
+ }
1061
+
1062
+ /** Whether a normalized result can be rendered. */
1063
+ export function isRenderable(result) {
1064
+ return result.spec !== null && result.errors.length === 0 && result.spec.items.length > 0
1065
+ }
1066
+
1067
+ /** A compact human-readable failure for a tool protocol. */
1068
+ export function describeFailure(result) {
1069
+ return result.errors.length > 0 ? result.errors.join('; ') : 'empty spec'
1070
+ }