@changfenhuang/dsh-genui 0.9.8 → 0.9.9

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 (53) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +13 -8
  3. package/README.zh-CN.md +13 -8
  4. package/SKILL.md +5 -5
  5. package/lib/client.js +3 -3
  6. package/lib/index.js +1273 -338
  7. package/lib/types/client/blocks/advanced.d.ts +1 -1
  8. package/lib/types/client/blocks/checkbox.d.ts +17 -0
  9. package/lib/types/client/blocks/forms.d.ts +3 -8
  10. package/lib/types/client/blocks/state.d.ts +18 -14
  11. package/lib/types/client/dom-fence.d.ts +1 -1
  12. package/lib/types/client/genui-runtime/diagnostics.d.ts +18 -0
  13. package/lib/types/client/genui-runtime/index.d.ts +1 -0
  14. package/lib/types/client/genui-runtime/limits.d.ts +54 -0
  15. package/lib/types/client/genui-runtime/normalize.d.ts +15 -0
  16. package/lib/types/client/genui-runtime/schema.d.ts +73 -0
  17. package/lib/types/client/genui-runtime/value-utils.d.ts +18 -0
  18. package/lib/types/client/guard.d.ts +60 -71
  19. package/lib/types/client/interaction-store.d.ts +7 -2
  20. package/lib/types/client/panel-command.d.ts +1 -1
  21. package/lib/types/client/primitive-labels.d.ts +11 -0
  22. package/lib/types/client/spec.d.ts +17 -17
  23. package/lib/types/plugin/chart-contract.d.ts +6 -4
  24. package/lib/types/plugin/index.d.ts +1 -4
  25. package/package.json +74 -13
  26. package/src/client/GenuiBlock.tsx +27 -11
  27. package/src/client/blocks/advanced.tsx +6 -5
  28. package/src/client/blocks/charts.tsx +1 -1
  29. package/src/client/blocks/checkbox.tsx +60 -0
  30. package/src/client/blocks/diagram/index.tsx +1 -1
  31. package/src/client/blocks/forms.tsx +51 -28
  32. package/src/client/blocks/render-node.tsx +3 -16
  33. package/src/client/blocks/state.ts +18 -14
  34. package/src/client/dom-fence.tsx +2 -1
  35. package/src/client/fence-render.tsx +26 -15
  36. package/src/client/genui-runtime/diagnostics.ts +143 -0
  37. package/src/client/genui-runtime/index.ts +1 -0
  38. package/src/client/genui-runtime/limits.ts +54 -0
  39. package/src/client/genui-runtime/normalize.ts +115 -0
  40. package/src/client/genui-runtime/schema.ts +291 -0
  41. package/src/client/genui-runtime/value-utils.ts +67 -0
  42. package/src/client/guard.ts +314 -169
  43. package/src/client/index.tsx +3 -2
  44. package/src/client/interaction-store.ts +7 -2
  45. package/src/client/panel-command.ts +2 -1
  46. package/src/client/panel.tsx +1 -1
  47. package/src/client/primitive-labels.ts +37 -0
  48. package/src/client/spec.ts +17 -17
  49. package/src/client/toolview.tsx +2 -1
  50. package/src/index.ts +3 -6
  51. package/src/plugin/chart-contract.ts +7 -97
  52. package/src/plugin/index.ts +2 -6
  53. package/src/plugin/tool.ts +60 -40
@@ -20,68 +20,18 @@
20
20
  */
21
21
  import type { GenuiFileTreeNode, GenuiList, GenuiNode, GenuiPlot, GenuiPlotSeries, GenuiScene3D, GenuiSpec, GenuiDiagram, GenuiDiagramTheme, GenuiDiagramKind } from './spec.ts'
22
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
23
+ import {
24
+ BADGE_TONES, BUTTON_TONES, CALLOUT_TONES, CHART_KINDS, COMPONENT_SCHEMAS, DIAGRAM_EDGE_KINDS,
25
+ DIAGRAM_KINDS, DIAGRAM_NODE_TYPES, DIAGRAM_ROUTES, DIAGRAM_VARIANTS, ECHART_PRESETS, FILE_TYPES,
26
+ GENUI_NATIVE_TYPES, GENUI_SPEC_SCHEMA, INPUT_TYPES, MEDIA_ASPECT_RATIOS, MESH_SHAPES, PLOT_KINDS,
27
+ TEXT_SIZES,
28
+ } from './genui-runtime/schema.ts'
29
+ import type { ComponentFieldKind, ComponentRecordSchema, ComponentSchema } from './genui-runtime/schema.ts'
30
+ import { normalizeGenuiSpec } from './genui-runtime/normalize.ts'
31
+ import { diagnoseUnknownGenuiFields } from './genui-runtime/diagnostics.ts'
32
+ import type { GenuiDiagnostic } from './genui-runtime/diagnostics.ts'
33
+ import { GENUI_LIMITS } from './genui-runtime/limits.ts'
34
+ import { color, enu, int, num, obj, opt, safeHref, safeMediaSrc, str } from './genui-runtime/value-utils.ts'
85
35
 
86
36
  /** Result of `validateGenuiSpec`. */
87
37
  export interface GenuiValidation {
@@ -90,111 +40,155 @@ export interface GenuiValidation {
90
40
  errors: string[]
91
41
  }
92
42
 
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
43
+ /* ---------------- schema field helpers ---------------- */
44
+
45
+ function fieldKindMatches(value: unknown, kind: ComponentFieldKind): boolean {
46
+ switch (kind) {
47
+ case 'string': return typeof value === 'string'
48
+ case 'string-or-null': return value === null || typeof value === 'string'
49
+ case 'number': return typeof value === 'number' && Number.isFinite(value)
50
+ case 'boolean': return typeof value === 'boolean'
51
+ case 'nodes': return Array.isArray(value)
52
+ case 'array': return Array.isArray(value)
53
+ case 'object': return obj(value) !== undefined
54
+ case 'unknown': return true
55
+ }
131
56
  }
132
57
 
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
58
+ function fieldKindLabel(kind: ComponentFieldKind): string {
59
+ switch (kind) {
60
+ case 'string-or-null': return 'a string or null'
61
+ case 'number': return 'a finite number'
62
+ case 'boolean': return 'a boolean'
63
+ case 'nodes': case 'array': return 'an array'
64
+ case 'object': return 'an object'
65
+ case 'unknown': return 'a value'
66
+ default: return 'a string'
67
+ }
143
68
  }
144
69
 
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
70
+ /** Detect an existing field diagnostic using both canonical and legacy messages. */
71
+ function hasFieldError(errors: readonly string[], at: string, field: string): boolean {
72
+ return errors.some(error =>
73
+ error.startsWith(`${at}.${field} `) || error.includes(`'${field}'`) || error.includes(`requires ${field}`))
148
74
  }
149
75
 
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
76
+ /** Validate present fields against the runtime schema without duplicating errors. */
77
+ function validateSchemaFieldKinds(
78
+ value: Record<string, unknown>,
79
+ at: string,
80
+ definition: ComponentSchema,
81
+ errors: string[],
82
+ excludedFields: readonly string[] = [],
83
+ ): void {
84
+ for (const [field, kind] of Object.entries(definition.fields)) {
85
+ if (excludedFields.includes(field) || value[field] === undefined) continue
86
+ if (!fieldKindMatches(value[field], kind) && !hasFieldError(errors, at, field)) {
87
+ errors.push(`${at}.${field} must be ${fieldKindLabel(kind)}`)
88
+ }
89
+ }
90
+ for (const [field, values] of Object.entries(definition.enums)) {
91
+ if (excludedFields.includes(field) || value[field] === undefined || !fieldKindMatches(value[field], 'string')) continue
92
+ if (!values.includes(value[field] as string) && !hasFieldError(errors, at, field)) {
93
+ errors.push(`${at}.${field} must be one of ${values.join(', ')}`)
94
+ }
95
+ }
153
96
  }
154
97
 
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
98
+ /** Validate one schema-declared nested record and all records below it. */
99
+ function validateRecordSchema(
100
+ value: unknown,
101
+ at: string,
102
+ definition: ComponentRecordSchema,
103
+ errors: string[],
104
+ ): void {
105
+ const holder = obj(value)
106
+ if (holder === undefined) {
107
+ errors.push(`${at} must be an object`)
108
+ return
109
+ }
110
+ for (const field of definition.required) {
111
+ const kind = definition.fields[field]
112
+ if (holder[field] === undefined) {
113
+ if (!hasFieldError(errors, at, field)) errors.push(`${at}: requires ${field}${kind === undefined ? '' : ` (${fieldKindLabel(kind)})`}`)
114
+ } else if (kind !== undefined && !fieldKindMatches(holder[field], kind)) {
115
+ if (!hasFieldError(errors, at, field)) errors.push(`${at}.${field} must be ${fieldKindLabel(kind)}`)
116
+ }
117
+ }
118
+ for (const [field, kind] of Object.entries(definition.fields)) {
119
+ if (holder[field] !== undefined && !fieldKindMatches(holder[field], kind)) {
120
+ if (!hasFieldError(errors, at, field)) errors.push(`${at}.${field} must be ${fieldKindLabel(kind)}`)
121
+ }
122
+ }
123
+ for (const [field, values] of Object.entries(definition.enums)) {
124
+ if (holder[field] !== undefined && typeof holder[field] === 'string' && !values.includes(holder[field])) {
125
+ if (!hasFieldError(errors, at, field)) errors.push(`${at}.${field} must be one of ${values.join(', ')}`)
126
+ }
127
+ }
128
+ for (const [field, nested] of Object.entries(definition.nested)) {
129
+ const nestedValue = holder[field]
130
+ if (nestedValue === undefined) continue
131
+ if (Array.isArray(nestedValue)) {
132
+ nestedValue.forEach((item, index) => validateRecordSchema(item, `${at}.${field}[${index}]`, nested, errors))
133
+ } else {
134
+ validateRecordSchema(nestedValue, `${at}.${field}`, nested, errors)
135
+ }
136
+ }
158
137
  }
159
138
 
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
139
+ /** Validate registry-declared nested records before repair can discard them. */
140
+ function validateNestedRecordSchemas(
141
+ value: Record<string, unknown>,
142
+ at: string,
143
+ definition: ComponentSchema,
144
+ errors: string[],
145
+ ): void {
146
+ for (const [field, nested] of Object.entries(definition.nested)) {
147
+ const nestedValue = value[field]
148
+ if (nestedValue === undefined) continue
149
+ if (Array.isArray(nestedValue)) {
150
+ nestedValue.forEach((item, index) => validateRecordSchema(item, `${at}.${field}[${index}]`, nested, errors))
151
+ } else {
152
+ validateRecordSchema(nestedValue, `${at}.${field}`, nested, errors)
153
+ }
154
+ }
163
155
  }
164
156
 
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>>
157
+ /** Validate the registry-declared presence and primitive shape of a native node. */
158
+ function validateRegistryFields(
159
+ value: Record<string, unknown>,
160
+ at: string,
161
+ definition: ComponentSchema,
162
+ errors: string[],
163
+ ): void {
164
+ const type = String(value.type)
165
+ const alreadyReported = (field: string): boolean => hasFieldError(errors, at, field)
166
+ for (const field of definition.required) {
167
+ const kind = definition.fields[field]
168
+ if (kind === undefined || value[field] === undefined) {
169
+ if (!alreadyReported(field)) errors.push(`${at}: type '${type}' requires ${field}${kind === undefined ? '' : ` (${fieldKindLabel(kind)})`}`)
170
+ continue
171
+ }
172
+ if (!fieldKindMatches(value[field], kind) && !alreadyReported(field)) errors.push(`${at}.${field} must be ${fieldKindLabel(kind)}`)
173
+ }
174
+ validateSchemaFieldKinds(value, at, definition, errors, ['type'])
175
+ for (const group of definition.oneOfRequired) {
176
+ if (!group.some(field => value[field] !== undefined)
177
+ && !errors.some(error => error.includes(`requires ${group.join(' or ')}`))) {
178
+ errors.push(`${at}: type '${type}' requires one of ${group.join(' or ')}`)
179
+ }
180
+ }
181
+ for (const rule of definition.conditionalRequired) {
182
+ if (value[rule.when.field] !== rule.when.equals) continue
183
+ for (const field of rule.required) {
184
+ if (value[field] === undefined && !alreadyReported(field)) {
185
+ errors.push(`${at}: type '${type}' requires ${field}${rule.message === undefined ? '' : ` (${rule.message})`}`)
186
+ }
187
+ }
188
+ }
189
+ validateNestedRecordSchemas(value, at, definition, errors)
173
190
  }
174
191
 
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
192
  /* ---------------- repair ---------------- */
199
193
 
200
194
  interface RepairCtx {
@@ -276,7 +270,12 @@ function repairNode(value: unknown, ctx: RepairCtx, depth: number): GenuiNode |
276
270
  case 'checkbox': {
277
271
  const label = str(v.label, GENUI_LIMITS.maxString)
278
272
  if (label === undefined) return null
279
- return { type: 'checkbox', label, ...opt('checked', v.checked === true ? true : undefined), ...opt('action', str(v.action, 200)) }
273
+ return {
274
+ type: 'checkbox', label,
275
+ ...opt('checked', v.checked === true ? true : undefined),
276
+ ...opt('action', str(v.action, 200)),
277
+ ...opt('group', str(v.group, 200)),
278
+ }
280
279
  }
281
280
  case 'link': {
282
281
  const label = str(v.label, GENUI_LIMITS.maxString)
@@ -1110,13 +1109,13 @@ function sanitizeEChartOption(v: unknown, depth: number, budget: EChartSanitizeB
1110
1109
  * dropping/clamping/truncating. Idempotent: repairing a repaired spec is a
1111
1110
  * no-op.
1112
1111
  */
1113
- export function repairGenuiSpec(value: unknown): GenuiSpec | null {
1112
+ function repairCanonicalGenuiSpec(value: unknown): GenuiSpec | null {
1114
1113
  const v = obj(value)
1115
1114
  if (v === undefined) return null
1116
1115
  if (!Array.isArray(v.items)) {
1117
1116
  const wrapped = wrapSingleComponentRoot(value)
1118
1117
  if (wrapped === null) return null
1119
- return repairGenuiSpec(wrapped)
1118
+ return repairCanonicalGenuiSpec(wrapped)
1120
1119
  }
1121
1120
  const ctx: RepairCtx = { remaining: GENUI_LIMITS.maxNodes }
1122
1121
  return {
@@ -1128,6 +1127,20 @@ export function repairGenuiSpec(value: unknown): GenuiSpec | null {
1128
1127
  }
1129
1128
  }
1130
1129
 
1130
+ /**
1131
+ * Deterministically repair a raw spec into a renderable GenuiSpec.
1132
+ *
1133
+ * Alias normalization is performed before the existing resource, type, and
1134
+ * security repair rules. The result remains idempotent and keeps the legacy
1135
+ * public API used by the fence renderer and client components.
1136
+ *
1137
+ * @param value - Raw GenUI spec or bare component.
1138
+ * @returns Repaired canonical spec, or null for an invalid root.
1139
+ */
1140
+ export function repairGenuiSpec(value: unknown): GenuiSpec | null {
1141
+ return repairCanonicalGenuiSpec(normalizeGenuiSpec(value).value)
1142
+ }
1143
+
1131
1144
  /* ---------------- validation ---------------- */
1132
1145
 
1133
1146
  /**
@@ -1164,8 +1177,8 @@ export function countGenuiNodes(value: unknown, cap = Number.POSITIVE_INFINITY):
1164
1177
  // Layout containers hold real children; skipping them undercounted
1165
1178
  // the tree and hid silent drops from validate_dsh_ui (issue #42).
1166
1179
  walk(v.items)
1167
- } else if (v.type === 'file-tree' && Array.isArray(v.items)) {
1168
- walk(v.items)
1180
+ } else if (v.type === 'file-tree') {
1181
+ // file-tree.items are data records, not GenUI children.
1169
1182
  } else if (v.type === 'list' && Array.isArray(v.items)) {
1170
1183
  // Typed list children are nodes too (repair charges them against the
1171
1184
  // budget); strings and {title,desc} shapes are skipped.
@@ -1185,13 +1198,7 @@ export function countGenuiNodes(value: unknown, cap = Number.POSITIVE_INFINITY):
1185
1198
  /** Every white-listed node `type`. Keep in sync with the repairNode switch —
1186
1199
  * validate_dsh_ui uses it to tell declared GenUI nodes apart from unrelated
1187
1200
  * `"type"` strings (e.g. file-tree's `{type:'file'}` children). */
1188
- export const GENUI_NODE_TYPES: ReadonlySet<string> = new Set([
1189
- 'accordion', 'audio', 'avatar', 'badge', 'breadcrumb', 'button', 'callout', 'card', 'chart',
1190
- 'checkbox', 'code', 'col', 'copy', 'diff', 'divider', 'file-tree', 'grid', 'image', 'input', 'json',
1191
- 'keyvalue', 'link', 'list', 'mermaid', 'plot', 'progress', 'quiz', 'radio', 'row', 'scene3d',
1192
- 'select', 'slider', 'spacer', 'stat', 'steps', 'submit', 'switch', 'table', 'tabs', 'text',
1193
- 'textarea', 'timeline', 'video', 'echart', 'diagram',
1194
- ])
1201
+ export const GENUI_NODE_TYPES: ReadonlySet<string> = GENUI_NATIVE_TYPES
1195
1202
 
1196
1203
  /**
1197
1204
  * Visit and count declared nodes in a raw spec tree: objects whose `type` is a
@@ -1263,6 +1270,11 @@ export function countDeclaredGenuiNodes(value: unknown, cap = Number.POSITIVE_IN
1263
1270
  return visitDeclaredGenuiNodes(value, cap, () => {})
1264
1271
  }
1265
1272
 
1273
+ /** Count native nodes that survived repair, excluding opaque custom nodes. */
1274
+ export function countRenderedNativeGenuiNodes(value: unknown, cap = Number.POSITIVE_INFINITY): number {
1275
+ return countDeclaredGenuiNodes(value, cap)
1276
+ }
1277
+
1266
1278
  /**
1267
1279
  * Return field-level chart errors without changing other repairable component families.
1268
1280
  * @param value - raw GenUI spec.
@@ -1284,7 +1296,7 @@ export function validateGenuiChartSemantics(value: unknown): string[] {
1284
1296
  * custom type is valid only when a renderer is registered — the guard cannot
1285
1297
  * know, so it flags them as warnings).
1286
1298
  */
1287
- export function validateGenuiSpec(value: unknown): GenuiValidation {
1299
+ function validateCanonicalGenuiSpec(value: unknown): GenuiValidation {
1288
1300
  const errors: string[] = []
1289
1301
  const v = obj(value)
1290
1302
  if (v === undefined) return { ok: false, errors: ['spec root must be an object'] }
@@ -1292,11 +1304,10 @@ export function validateGenuiSpec(value: unknown): GenuiValidation {
1292
1304
  // Single-component root: validate through the wrapped form so the tool
1293
1305
  // agrees with the renderer about what is a valid fence body.
1294
1306
  const wrapped = wrapSingleComponentRoot(value)
1295
- if (wrapped !== null) return validateGenuiSpec(wrapped)
1307
+ if (wrapped !== null) return validateCanonicalGenuiSpec(wrapped)
1296
1308
  return { ok: false, errors: ['spec.items must be an array'] }
1297
1309
  }
1298
- if (v.title !== undefined && typeof v.title !== 'string') errors.push('spec.title must be a string')
1299
- if (v.gap !== undefined && (typeof v.gap !== 'number' || !Number.isFinite(v.gap))) errors.push('spec.gap must be a finite number')
1310
+ validateSchemaFieldKinds(v, 'spec', GENUI_SPEC_SCHEMA, errors, ['items'])
1300
1311
  let count = 0
1301
1312
  let capped = false
1302
1313
  const walk = (list: unknown, depth: number, path: string): void => {
@@ -1322,6 +1333,101 @@ export function validateGenuiSpec(value: unknown): GenuiValidation {
1322
1333
  return { ok: errors.length === 0, errors }
1323
1334
  }
1324
1335
 
1336
+ /**
1337
+ * Validate a raw GenUI value after deterministic alias normalization.
1338
+ *
1339
+ * Validation therefore only sees canonical fields; repairable aliases do not
1340
+ * produce false required-field errors. Structural and semantic defects remain
1341
+ * errors, while native unknown fields are exposed by the warning diagnostics
1342
+ * returned from `processGenuiSpec`.
1343
+ *
1344
+ * @param value - Raw GenUI spec or bare component.
1345
+ * @returns Validation result with human-readable errors.
1346
+ */
1347
+ export function validateGenuiSpec(value: unknown): GenuiValidation {
1348
+ return validateCanonicalGenuiSpec(normalizeGenuiSpec(value).value)
1349
+ }
1350
+
1351
+ export interface GenuiProcessResult {
1352
+ /** Canonical alias-normalized input value. */
1353
+ value: unknown
1354
+ /** Alias-normalized value, provided as an explicit descriptive alias. */
1355
+ normalized: unknown
1356
+ /** Canonical repaired value consumed by the renderer. */
1357
+ repaired: GenuiSpec | null
1358
+ /** Renderer-facing alias for `repaired`. */
1359
+ spec: GenuiSpec | null
1360
+ /** Structural and semantic validation errors. */
1361
+ errors: string[]
1362
+ /** Alias and native unknown-field warnings. */
1363
+ warnings: GenuiDiagnostic[]
1364
+ /** Native nodes declared in the canonical input, before repair drops. */
1365
+ declaredCount: number
1366
+ /** Nodes present in the repaired tree. */
1367
+ renderedCount: number
1368
+ /** Native nodes declared before repair, excluding custom payloads. */
1369
+ declaredNativeCount: number
1370
+ /** Native nodes present after repair, excluding custom and file-tree data. */
1371
+ renderedNativeCount: number
1372
+ /** All rendered component nodes, including opaque custom nodes. */
1373
+ renderedTotalCount: number
1374
+ }
1375
+
1376
+ /**
1377
+ * Run the shared canonical GenUI pipeline.
1378
+ *
1379
+ * The pipeline normalizes aliases, applies the existing deterministic repair
1380
+ * and security filters, validates the canonical input, and reports native
1381
+ * declarations that disappeared during repair. Custom nodes stay opaque and
1382
+ * do not participate in native unknown-field diagnostics.
1383
+ *
1384
+ * @param value - Raw GenUI spec or bare component.
1385
+ * @returns Canonical input, repaired output, diagnostics, and node counts.
1386
+ */
1387
+ export function processGenuiSpec(value: unknown): GenuiProcessResult {
1388
+ const normalized = normalizeGenuiSpec(value)
1389
+ const repaired = repairCanonicalGenuiSpec(normalized.value)
1390
+ const validation = validateCanonicalGenuiSpec(normalized.value)
1391
+ const declaredNativeCount = countDeclaredGenuiNodes(normalized.value, GENUI_LIMITS.maxNodes + 1)
1392
+ const renderedNativeCount = repaired === null ? 0 : countRenderedNativeGenuiNodes(repaired)
1393
+ const renderedTotalCount = repaired === null ? 0 : countGenuiNodes(repaired)
1394
+ // The shared public validator preserves its historical diagnostic for an
1395
+ // unknown type. The processing pipeline is renderer-aware by contract:
1396
+ // custom nodes stay opaque and must not fail native schema validation.
1397
+ const errors = validation.errors.filter(error => !error.includes(': unknown type '))
1398
+ if (declaredNativeCount > renderedNativeCount) {
1399
+ errors.push(`repair dropped ${declaredNativeCount - renderedNativeCount} declared native node(s): declared ${declaredNativeCount}, rendered ${renderedNativeCount}`)
1400
+ }
1401
+ return {
1402
+ value: normalized.value,
1403
+ normalized: normalized.value,
1404
+ repaired,
1405
+ spec: repaired,
1406
+ errors,
1407
+ warnings: [...normalized.warnings, ...diagnoseUnknownGenuiFields(normalized.value)],
1408
+ // Compatibility fields retain their historical meanings: declaredCount
1409
+ // is native declarations, while renderedCount is the total rendered tree.
1410
+ declaredCount: declaredNativeCount,
1411
+ renderedCount: renderedTotalCount,
1412
+ declaredNativeCount,
1413
+ renderedNativeCount,
1414
+ renderedTotalCount,
1415
+ }
1416
+ }
1417
+
1418
+ /** Return whether the only process errors describe an intentional budget tail cut. */
1419
+ export function isIntentionalBudgetCut(processed: GenuiProcessResult): boolean {
1420
+ return processed.errors.length > 0
1421
+ && processed.errors.every(error => error.startsWith('spec exceeds ') || error.startsWith('repair dropped '))
1422
+ && processed.renderedNativeCount === GENUI_LIMITS.maxNodes
1423
+ && processed.declaredNativeCount === GENUI_LIMITS.maxNodes + 1
1424
+ }
1425
+
1426
+ /** Decide whether a repaired spec is safe to expose to any GenUI renderer. */
1427
+ export function isRenderableProcess(processed: GenuiProcessResult): boolean {
1428
+ return processed.spec !== null && (processed.errors.length === 0 || isIntentionalBudgetCut(processed))
1429
+ }
1430
+
1325
1431
  type Walker = (list: unknown, depth: number, path: string) => void
1326
1432
 
1327
1433
  function validateChartData(value: unknown, at: string, errors: string[]): void {
@@ -1373,10 +1479,44 @@ function validateChartNode(v: Record<string, unknown>, at: string, errors: strin
1373
1479
  || !CHART_KINDS.includes(v.kind as typeof CHART_KINDS[number]))) {
1374
1480
  errors.push(`${at}.kind must be bars, line, or donut`)
1375
1481
  }
1482
+ const kind = v.kind === undefined ? 'bars' : v.kind
1483
+ if (Array.isArray(v.data) && v.data.length === 0) errors.push(`${at}.data must not be empty`)
1484
+ if (Array.isArray(v.series)) {
1485
+ if (v.series.length === 0) errors.push(`${at}.series must not be empty`)
1486
+ if (kind === 'line' || kind === 'donut') errors.push(`${at}.series is only supported for bars`)
1487
+ for (let index = 0; index < v.series.length; index++) {
1488
+ const series = obj(v.series[index])
1489
+ if (series !== undefined && Array.isArray(series.data) && series.data.length === 0) {
1490
+ errors.push(`${at}.series[${index}].data must not be empty`)
1491
+ }
1492
+ }
1493
+ }
1494
+ if ((kind === 'line' || kind === 'donut') && v.data === undefined) {
1495
+ errors.push(`${at}.data is required for ${kind}`)
1496
+ }
1376
1497
  validateChartData(v.data, `${at}.data`, errors)
1377
1498
  validateChartSeries(v.series, `${at}.series`, errors)
1378
1499
  }
1379
1500
 
1501
+ /** Validate table rows before repair can silently remove malformed cells. */
1502
+ function validateTableRows(value: unknown, at: string, errors: string[]): void {
1503
+ if (!Array.isArray(value)) return
1504
+ for (let rowIndex = 0; rowIndex < value.length; rowIndex++) {
1505
+ const row = value[rowIndex]
1506
+ if (obj(row) !== undefined) continue
1507
+ if (!Array.isArray(row)) {
1508
+ errors.push(`${at}[${rowIndex}] must be an array or object`)
1509
+ continue
1510
+ }
1511
+ for (let cellIndex = 0; cellIndex < row.length; cellIndex++) {
1512
+ const cell = row[cellIndex]
1513
+ if (typeof cell !== 'string' && (typeof cell !== 'number' || !Number.isFinite(cell))) {
1514
+ errors.push(`${at}[${rowIndex}][${cellIndex}] must be a string or finite number`)
1515
+ }
1516
+ }
1517
+ }
1518
+ }
1519
+
1380
1520
  function validateNode(value: unknown, depth: number, at: string, errors: string[], walk: Walker): void {
1381
1521
  if (depth > GENUI_LIMITS.maxDepth) {
1382
1522
  errors.push(`${at}: exceeds max depth ${GENUI_LIMITS.maxDepth}`)
@@ -1472,6 +1612,7 @@ function validateNode(value: unknown, depth: number, at: string, errors: string[
1472
1612
  case 'table':
1473
1613
  if (!Array.isArray(v.columns)) errors.push(`${at}: type 'table' requires columns (array)`)
1474
1614
  if (!Array.isArray(v.rows)) errors.push(`${at}: type 'table' requires rows (array)`)
1615
+ validateTableRows(v.rows, `${at}.rows`, errors)
1475
1616
  break
1476
1617
  case 'chart':
1477
1618
  validateChartNode(v, at, errors)
@@ -1559,4 +1700,8 @@ function validateNode(value: unknown, depth: number, at: string, errors: string[
1559
1700
  // renderer exists; the guard cannot know, so report as a warning.
1560
1701
  errors.push(`${at}: unknown type '${type}' (custom renderer?)`)
1561
1702
  }
1703
+ const definition = COMPONENT_SCHEMAS[type]
1704
+ if (definition !== undefined) {
1705
+ validateRegistryFields(v, at, definition, errors)
1706
+ }
1562
1707
  }
@@ -22,8 +22,9 @@
22
22
  */
23
23
 
24
24
  import type { Context } from '@deepseek-ai/cordis'
25
- import type {} from '@deepseek-ai/dsh-client-runtime/client'
26
- import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
25
+ import type {} from '@deepseek-ai/dsh-api-session-controller/client'
26
+ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
27
+ import type { SessionId } from '@deepseek-ai/dsh-session/types'
27
28
  import type { IConversation } from '@deepseek-ai/dsh-client-ui-conversation/client'
28
29
  import type { Key, ReactNode } from 'react'
29
30
  import * as primitives from '@deepseek-ai/dsh-client-ui-primitives'
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Interaction-state store: durable per-block GenUI interaction state
3
- * (radio answers, submit lock, input/textarea values) in localStorage.
3
+ * (radio answers, checkbox groups, submit lock, input/textarea values) in
4
+ * localStorage.
4
5
  *
5
6
  * LOCAL-FIRST persistence: a ```dsh-ui block's interactive state survives
6
7
  * page refresh and session reopen because it is keyed by
@@ -10,7 +11,7 @@
10
11
  * share state because their content fingerprints differ.
11
12
  *
12
13
  * Bounded: at most MAX_BLOCKS entries, LRU-evicted on write; each block's
13
- * payload is small (answers map + a few field values).
14
+ * payload is small (answer maps + a few field values).
14
15
  * @module @changfenhuang/dsh-genui/client/interaction-store
15
16
  */
16
17
 
@@ -18,6 +19,10 @@
18
19
  export interface BlockInteractionState {
19
20
  /** group → chosen option label (radio aggregation answers). */
20
21
  answers?: Record<string, string>
22
+ /** group → chosen option labels (checkbox aggregation answers). Empty arrays
23
+ * are retained so an explicitly cleared group wins over `checked` defaults
24
+ * after refresh. */
25
+ multiAnswers?: Record<string, string[]>
21
26
  /** True after a local grading: the paper stays graded across refresh. */
22
27
  locked?: boolean
23
28
  /** field id → current value (input/textarea with an `id`). */
@@ -15,7 +15,8 @@
15
15
  * re-run render_ui) or through another /panel.
16
16
  */
17
17
  import type { InputTriggerSource } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
18
- import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
18
+ import type { Context as ClientContext } from '@deepseek-ai/cordis'
19
+ import type { SessionId } from '@deepseek-ai/dsh-session/types'
19
20
  import type { GenuiSpec } from './spec.ts'
20
21
  import { requestPanelExpand, setLocalPanel } from './panel-store.ts'
21
22