@changfenhuang/dsh-genui 0.9.9 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +1 -1
  3. package/README.zh-CN.md +1 -1
  4. package/SKILL.md +128 -6
  5. package/lib/assets/echarts-core.js +43 -0
  6. package/lib/assets/{echarts.js → echarts-full.js} +1 -1
  7. package/lib/assets/mermaid.js +1 -1
  8. package/lib/client.js +5 -3
  9. package/lib/index.js +230 -69
  10. package/lib/types/client/EChartNode.d.ts +9 -0
  11. package/lib/types/client/GenuiBlock.d.ts +3 -4
  12. package/lib/types/client/asset-echarts-core.d.ts +1 -0
  13. package/lib/types/client/asset-echarts.d.ts +3 -2
  14. package/lib/types/client/asset-loader.d.ts +2 -2
  15. package/lib/types/client/blocks/advanced.d.ts +0 -3
  16. package/lib/types/client/blocks/charts.d.ts +23 -12
  17. package/lib/types/client/blocks/state.d.ts +5 -3
  18. package/lib/types/client/echarts-lazy.d.ts +4 -1
  19. package/lib/types/client/genui-runtime/schema.d.ts +11 -1
  20. package/lib/types/client/inline.d.ts +29 -0
  21. package/lib/types/client/spec.d.ts +76 -6
  22. package/lib/types/plugin/index.d.ts +1 -1
  23. package/package.json +4 -3
  24. package/src/client/EChartNode.tsx +233 -19
  25. package/src/client/GenuiBlock.module.css +895 -145
  26. package/src/client/GenuiBlock.tsx +31 -23
  27. package/src/client/PlotBlock.module.css +17 -1
  28. package/src/client/PlotBlock.tsx +33 -1
  29. package/src/client/asset-echarts-core.ts +39 -0
  30. package/src/client/asset-echarts.ts +4 -3
  31. package/src/client/asset-loader.ts +5 -3
  32. package/src/client/blocks/advanced.tsx +60 -16
  33. package/src/client/blocks/charts.tsx +761 -144
  34. package/src/client/blocks/render-node.tsx +252 -11
  35. package/src/client/blocks/state.ts +5 -3
  36. package/src/client/dom-fence.tsx +60 -6
  37. package/src/client/echarts-lazy.ts +11 -2
  38. package/src/client/fence-render.tsx +6 -5
  39. package/src/client/gallery.ts +78 -2
  40. package/src/client/genui-runtime/schema.ts +45 -11
  41. package/src/client/guard.ts +161 -23
  42. package/src/client/index.tsx +14 -3
  43. package/src/client/inline.ts +73 -0
  44. package/src/client/mermaid-core.ts +42 -5
  45. package/src/client/spec.ts +79 -6
  46. package/src/plugin/index.ts +33 -45
  47. package/src/plugin/tool.ts +5 -1
  48. package/tsdown.config.ts +2 -1
@@ -75,7 +75,20 @@ export const DIAGRAM_NODE_TYPES = ['focal', 'backend', 'store', 'external', 'inp
75
75
  export const DIAGRAM_VARIANTS = ['light', 'dark', 'editorial'] as const
76
76
  export const DIAGRAM_EDGE_KINDS = ['solid', 'dashed', 'accent', 'link'] as const
77
77
  export const DIAGRAM_ROUTES = ['auto', 'orthogonal', 'straight'] as const
78
- export const ECHART_PRESETS = ['bar', 'line', 'area', 'pie', 'scatter'] as const
78
+ export const ECHART_PRESETS = [
79
+ 'bar', 'line', 'area', 'pie', 'scatter',
80
+ 'radar', 'gauge', 'funnel', 'treemap', 'sankey', 'graph', 'heatmap', 'bigline',
81
+ ] as const
82
+ /** Oversized single-number stat (one per fence as the visual anchor). */
83
+ export const STAT_SIZES = ['hero'] as const
84
+ /** Progress shapes: a track (default) or a circular gauge. */
85
+ export const PROGRESS_VARIANTS = ['bar', 'ring'] as const
86
+ /** Semantic card surfaces. */
87
+ export const CARD_TONES = ['info', 'success', 'warning', 'danger'] as const
88
+ /** Hero cover tones. */
89
+ export const HERO_TONES = ['accent', 'success', 'warning', 'danger'] as const
90
+ /** Table cell renderers (`table.types`, one entry per column). */
91
+ export const TABLE_CELL_TYPES = ['text', 'num', 'delta', 'bar', 'badge', 'spark', 'ring', 'index', 'group'] as const
79
92
 
80
93
  const schema = (
81
94
  required: readonly string[],
@@ -119,7 +132,7 @@ const recordSchema = (
119
132
  enums: Readonly<Record<string, readonly string[]>> = {},
120
133
  ): ComponentRecordSchema => ({ required, fields, enums, nested })
121
134
 
122
- const nodeFields = { type: 'string' } as const
135
+ const nodeFields = { type: 'string', span: 'number' } as const
123
136
 
124
137
  const chartDatumSchema = recordSchema(['label', 'value'], {
125
138
  label: 'string',
@@ -233,11 +246,21 @@ export const COMPONENT_SCHEMAS: Readonly<Record<string, ComponentSchema>> = {
233
246
  breadcrumb: schema(['items'], { ...nodeFields, items: 'array' }),
234
247
  button: schema(['label'], { ...nodeFields, label: 'string', tone: 'string', full: 'boolean', small: 'boolean', icon: 'string', action: 'string' }, {}, { enums: { tone: BUTTON_TONES } }),
235
248
  callout: schema(['content'], { ...nodeFields, title: 'string', content: 'string', tone: 'string' }, { kind: 'tone' }, { enums: { tone: CALLOUT_TONES } }),
236
- card: schema(['items'], { ...nodeFields, title: 'string', items: 'nodes' }, { label: 'title', content: 'items' }),
237
- chart: schema([], { ...nodeFields, kind: 'string', data: 'array', series: 'array' }, {}, {
249
+ card: schema(['items'], { ...nodeFields, title: 'string', items: 'nodes', tone: 'string', accent: 'string' }, { label: 'title', content: 'items' }, { enums: { tone: CARD_TONES } }),
250
+ chart: schema([], {
251
+ ...nodeFields,
252
+ kind: 'string',
253
+ data: 'array',
254
+ series: 'array',
255
+ horizontal: 'boolean',
256
+ stacked: 'boolean',
257
+ filter: 'string',
258
+ palette: 'array',
259
+ }, {}, {
238
260
  oneOfRequired: [['data', 'series']],
261
+ // `line` may carry its points in `series` (multi-series line); only the
262
+ // donut is a single-series shape that always needs `data`.
239
263
  conditionalRequired: [
240
- { kind: 'required-if', when: { field: 'kind', equals: 'line' }, required: ['data'] },
241
264
  { kind: 'required-if', when: { field: 'kind', equals: 'donut' }, required: ['data'] },
242
265
  ],
243
266
  nested: { data: chartDatumSchema, series: chartSeriesSchema },
@@ -254,8 +277,9 @@ export const COMPONENT_SCHEMAS: Readonly<Record<string, ComponentSchema>> = {
254
277
  }),
255
278
  diff: schema(['diffs'], { ...nodeFields, diffs: 'array' }, {}, { nested: { diffs: diffRecordSchema } }),
256
279
  divider: schema([], nodeFields),
257
- echart: schema([], { ...nodeFields, title: 'string', height: 'number', preset: 'string', data: 'array', series: 'array', option: 'object' }, {}, {
258
- oneOfRequired: [['option', 'data', 'series']],
280
+ echart: schema([], { ...nodeFields, title: 'string', height: 'number', preset: 'string', data: 'array', series: 'array', links: 'array', palette: 'array', option: 'object' }, {}, {
281
+ // `links` alone is valid: the sankey/graph presets are edge-driven.
282
+ oneOfRequired: [['option', 'data', 'series', 'links']],
259
283
  enums: { preset: ECHART_PRESETS },
260
284
  }),
261
285
  'file-tree': schema(['items'], { ...nodeFields, items: 'array' }, {}, { nested: { items: fileTreeNodeSchema } }),
@@ -265,10 +289,10 @@ export const COMPONENT_SCHEMAS: Readonly<Record<string, ComponentSchema>> = {
265
289
  json: schema(['value'], { ...nodeFields, value: 'unknown' }),
266
290
  keyvalue: schema(['pairs'], { ...nodeFields, pairs: 'array' }, {}, { nested: { pairs: keyValueRecordSchema } }),
267
291
  link: schema(['label'], { ...nodeFields, label: 'string', href: 'string' }),
268
- list: schema(['items'], { ...nodeFields, items: 'array' }),
292
+ list: schema(['items'], { ...nodeFields, items: 'array', filter: 'string' }),
269
293
  mermaid: schema(['code'], { ...nodeFields, code: 'string' }),
270
294
  plot: schema(['series'], { ...nodeFields, series: 'array', xMin: 'number', xMax: 'number', yMin: 'number', yMax: 'number', title: 'string' }, {}, { nested: { series: plotSeriesSchema } }),
271
- progress: schema(['value'], { ...nodeFields, value: 'number', label: 'string', valueLabel: 'string' }),
295
+ progress: schema(['value'], { ...nodeFields, value: 'number', label: 'string', valueLabel: 'string', variant: 'string', target: 'number' }, {}, { enums: { variant: PROGRESS_VARIANTS } }),
272
296
  quiz: schema(['question', 'options'], { ...nodeFields, question: 'string', options: 'array', explanation: 'string', id: 'string', action: 'string' }),
273
297
  radio: schema(['options'], { ...nodeFields, label: 'string', options: 'array', selected: 'number', action: 'string', group: 'string', answer: 'unknown', explanation: 'string' }),
274
298
  row: schema(['items'], { ...nodeFields, items: 'nodes', wrap: 'boolean', spacer: 'boolean' }),
@@ -276,11 +300,21 @@ export const COMPONENT_SCHEMAS: Readonly<Record<string, ComponentSchema>> = {
276
300
  select: schema(['options'], { ...nodeFields, label: 'string', options: 'array', action: 'string', selected: 'number', id: 'string' }),
277
301
  slider: schema([], { ...nodeFields, label: 'string', min: 'number', max: 'number', step: 'number', value: 'number', action: 'string', id: 'string' }),
278
302
  spacer: schema([], nodeFields),
279
- stat: schema(['label', 'value'], { ...nodeFields, label: 'string', value: 'string', delta: 'string' }),
303
+ hero: schema(['title'], {
304
+ ...nodeFields,
305
+ title: 'string',
306
+ subtitle: 'string',
307
+ value: 'string',
308
+ label: 'string',
309
+ delta: 'string',
310
+ spark: 'array',
311
+ tone: 'string',
312
+ }, {}, { enums: { tone: HERO_TONES } }),
313
+ stat: schema(['label', 'value'], { ...nodeFields, label: 'string', value: 'string', delta: 'string', spark: 'array', size: 'string' }, {}, { enums: { size: STAT_SIZES } }),
280
314
  steps: schema(['steps'], { ...nodeFields, steps: 'array', current: 'number' }, { items: 'steps' }, { nested: { steps: stepsRecordSchema } }),
281
315
  submit: schema(['label'], { ...nodeFields, label: 'string', action: 'string', resetAction: 'string', groups: 'array' }),
282
316
  switch: schema(['label'], { ...nodeFields, label: 'string', checked: 'boolean', action: 'string' }),
283
- table: schema(['columns', 'rows'], { ...nodeFields, columns: 'array', rows: 'array' }, { headers: 'columns', data: 'rows' }),
317
+ table: schema(['columns', 'rows'], { ...nodeFields, columns: 'array', rows: 'array', types: 'array', total: 'boolean', details: 'array', filter: 'string', filterColumn: 'number', sortField: 'string', export: 'boolean' }, { headers: 'columns', data: 'rows' }),
284
318
  tabs: schema(['tabs'], { ...nodeFields, tabs: 'array' }, {}, { nested: { tabs: tabHolderSchema } }),
285
319
  text: schema(['content'], { ...nodeFields, content: 'string', size: 'string', center: 'boolean' }, { text: 'content' }, { enums: { size: TEXT_SIZES } }),
286
320
  textarea: schema([], { ...nodeFields, label: 'string', placeholder: 'string', rows: 'number', value: 'string', action: 'string', id: 'string' }),
@@ -21,10 +21,10 @@
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
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,
24
+ BADGE_TONES, BUTTON_TONES, CALLOUT_TONES, CARD_TONES, CHART_KINDS, COMPONENT_SCHEMAS, HERO_TONES,
25
+ DIAGRAM_EDGE_KINDS, DIAGRAM_KINDS, DIAGRAM_NODE_TYPES, DIAGRAM_ROUTES, DIAGRAM_VARIANTS,
26
+ ECHART_PRESETS, FILE_TYPES, GENUI_NATIVE_TYPES, GENUI_SPEC_SCHEMA, INPUT_TYPES,
27
+ MEDIA_ASPECT_RATIOS, MESH_SHAPES, PLOT_KINDS, PROGRESS_VARIANTS, TABLE_CELL_TYPES, TEXT_SIZES,
28
28
  } from './genui-runtime/schema.ts'
29
29
  import type { ComponentFieldKind, ComponentRecordSchema, ComponentSchema } from './genui-runtime/schema.ts'
30
30
  import { normalizeGenuiSpec } from './genui-runtime/normalize.ts'
@@ -209,7 +209,41 @@ function repairItems(list: unknown, ctx: RepairCtx, depth: number): GenuiNode[]
209
209
  return out
210
210
  }
211
211
 
212
+ /** Optional explicit palette: up to 12 validated colour values. */
213
+ function paletteValues(v: unknown): string[] | undefined {
214
+ if (!Array.isArray(v)) return undefined
215
+ const out: string[] = []
216
+ for (const item of v.slice(0, 12)) {
217
+ const value = color(item)
218
+ if (value === undefined) continue
219
+ out.push(value)
220
+ }
221
+ return out.length > 0 ? out : undefined
222
+ }
223
+
224
+ /** Optional `stat.spark` series: finite numbers only, 2..60 points. */
225
+ function sparkValues(v: unknown): number[] | undefined {
226
+ if (!Array.isArray(v)) return undefined
227
+ const out: number[] = []
228
+ for (const item of v.slice(0, 60)) {
229
+ const n = typeof item === 'number' ? item : Number(item)
230
+ if (!Number.isFinite(n)) return undefined
231
+ out.push(n)
232
+ }
233
+ return out.length >= 2 ? out : undefined
234
+ }
235
+
236
+ /** Layout hints are component-agnostic: `span` survives repair on ANY node
237
+ * (the renderer applies it inside a grid). Kept out of the per-case switches
238
+ * so a new component type cannot forget it. */
212
239
  function repairNode(value: unknown, ctx: RepairCtx, depth: number): GenuiNode | null {
240
+ const node = repairNodeFields(value, ctx, depth)
241
+ if (node === null) return null
242
+ const span = int(obj(value)?.span, 2, 12)
243
+ return span === undefined ? node : ({ ...node, span } as GenuiNode)
244
+ }
245
+
246
+ function repairNodeFields(value: unknown, ctx: RepairCtx, depth: number): GenuiNode | null {
213
247
  if (depth > GENUI_LIMITS.maxDepth) return null
214
248
  const v = obj(value)
215
249
  if (v === undefined) return null
@@ -231,7 +265,13 @@ function repairNode(value: unknown, ctx: RepairCtx, depth: number): GenuiNode |
231
265
  return { type: 'grid', cols: int(v.cols, 1, GENUI_LIMITS.maxGridCols) ?? 1, items: repairItems(v.items, ctx, depth + 1) }
232
266
  }
233
267
  case 'card': {
234
- return { type: 'card', items: repairItems(v.items, ctx, depth + 1), ...opt('title', str(v.title, GENUI_LIMITS.maxString)) }
268
+ return {
269
+ type: 'card',
270
+ items: repairItems(v.items, ctx, depth + 1),
271
+ ...opt('title', str(v.title, GENUI_LIMITS.maxString)),
272
+ ...opt('tone', enu(v.tone, CARD_TONES)),
273
+ ...opt('accent', color(v.accent)),
274
+ }
235
275
  }
236
276
  case 'button': {
237
277
  const label = str(v.label, GENUI_LIMITS.maxString)
@@ -316,16 +356,40 @@ function repairNode(value: unknown, ctx: RepairCtx, depth: number): GenuiNode |
316
356
  if (label === undefined) return null
317
357
  return { type: 'badge', label, ...opt('tone', enu(v.tone, BADGE_TONES)), ...opt('icon', str(v.icon, 64)) }
318
358
  }
359
+ case 'hero': {
360
+ const title = str(v.title, GENUI_LIMITS.maxString)
361
+ if (title === undefined) return null
362
+ return {
363
+ type: 'hero', title,
364
+ ...opt('subtitle', str(v.subtitle, GENUI_LIMITS.maxString)),
365
+ ...opt('value', str(v.value, 128)),
366
+ ...opt('label', str(v.label, GENUI_LIMITS.maxString)),
367
+ ...opt('delta', str(v.delta, 64)),
368
+ ...opt('spark', sparkValues(v.spark)),
369
+ ...opt('tone', enu(v.tone, HERO_TONES)),
370
+ }
371
+ }
319
372
  case 'stat': {
320
373
  const label = str(v.label, GENUI_LIMITS.maxString)
321
374
  const value = str(v.value, 128)
322
375
  if (label === undefined || value === undefined) return null
323
- return { type: 'stat', label, value, ...opt('delta', str(v.delta, 64)) }
376
+ return {
377
+ type: 'stat', label, value,
378
+ ...opt('delta', str(v.delta, 64)),
379
+ ...opt('spark', sparkValues(v.spark)),
380
+ ...opt('size', v.size === 'hero' ? 'hero' as const : undefined),
381
+ }
324
382
  }
325
383
  case 'progress': {
326
384
  const value = num(v.value, 0, 100)
327
385
  if (value === undefined) return null
328
- return { type: 'progress', value, ...opt('label', str(v.label, GENUI_LIMITS.maxString)), ...opt('valueLabel', str(v.valueLabel, 64)) }
386
+ return {
387
+ type: 'progress', value,
388
+ ...opt('label', str(v.label, GENUI_LIMITS.maxString)),
389
+ ...opt('valueLabel', str(v.valueLabel, 64)),
390
+ ...opt('variant', enu(v.variant, PROGRESS_VARIANTS)),
391
+ ...opt('target', num(v.target, 0, 100)),
392
+ }
329
393
  }
330
394
  case 'divider': return { type: 'divider' }
331
395
  case 'spacer': return { type: 'spacer' }
@@ -337,7 +401,7 @@ function repairNode(value: unknown, ctx: RepairCtx, depth: number): GenuiNode |
337
401
  case 'list': {
338
402
  const items = repairListItems(v.items, GENUI_LIMITS.maxListItems, ctx, depth + 1)
339
403
  if (items === undefined) return null
340
- return { type: 'list', items }
404
+ return { type: 'list', items, ...opt('filter', str(v.filter, 64)) }
341
405
  }
342
406
  case 'table': {
343
407
  let rawCols = v.columns as unknown
@@ -359,7 +423,36 @@ function repairNode(value: unknown, ctx: RepairCtx, depth: number): GenuiNode |
359
423
  const columns = repairStrings(rawCols, GENUI_LIMITS.maxTableCols, 128)
360
424
  const rows = repairRows(rawRows, GENUI_LIMITS.maxTableRows, GENUI_LIMITS.maxTableCols)
361
425
  if (columns === undefined || rows === undefined) return null
362
- return { type: 'table', columns, rows }
426
+ // Optional per-column cell types; unknown entries degrade to 'text'.
427
+ const rawTypes = Array.isArray(v.types) ? v.types : undefined
428
+ const types = rawTypes === undefined
429
+ ? undefined
430
+ : columns.map((_c, i) => {
431
+ const raw = rawTypes[i]
432
+ return typeof raw === 'string' && (TABLE_CELL_TYPES as readonly string[]).includes(raw)
433
+ ? raw as typeof TABLE_CELL_TYPES[number]
434
+ : 'text'
435
+ })
436
+ // Master-detail payload: positionally aligned with `rows`. Entries that
437
+ // repair to nothing stay null so the renderer never shows an empty
438
+ // expander; extra entries beyond the row count are dropped.
439
+ const rawDetails = Array.isArray(v.details) ? v.details : undefined
440
+ const details = rawDetails === undefined
441
+ ? undefined
442
+ : rows.map((_row, i) => {
443
+ const entry = repairItems(rawDetails[i], ctx, depth + 1)
444
+ return entry.length === 0 ? null : entry
445
+ })
446
+ return {
447
+ type: 'table', columns, rows,
448
+ ...opt('types', types),
449
+ ...opt('total', v.total === true ? true : undefined),
450
+ ...opt('export', v.export === true ? true : undefined),
451
+ ...opt('filter', str(v.filter, 64)),
452
+ ...opt('filterColumn', int(v.filterColumn, 0, GENUI_LIMITS.maxTableCols - 1)),
453
+ ...opt('sortField', str(v.sortField, 64)),
454
+ ...opt('details', details !== undefined && details.some(d => d !== null) ? details : undefined),
455
+ }
363
456
  }
364
457
  case 'chart': {
365
458
  const data = repairChartData(v.data, GENUI_LIMITS.maxChartPoints)
@@ -368,7 +461,15 @@ function repairNode(value: unknown, ctx: RepairCtx, depth: number): GenuiNode |
368
461
  // alone; a series-only chart gets an empty data array (the renderer
369
462
  // reads `series` in that case).
370
463
  if (data === undefined && series === undefined) return null
371
- return { type: 'chart', data: data ?? [], ...opt('kind', enu(v.kind, CHART_KINDS)), ...opt('series', series) }
464
+ return {
465
+ type: 'chart', data: data ?? [],
466
+ ...opt('kind', enu(v.kind, CHART_KINDS)),
467
+ ...opt('series', series),
468
+ ...opt('horizontal', v.horizontal === true ? true : undefined),
469
+ ...opt('stacked', v.stacked === true ? true : undefined),
470
+ ...opt('filter', str(v.filter, 64)),
471
+ ...opt('palette', paletteValues(v.palette)),
472
+ }
372
473
  }
373
474
  case 'tabs': {
374
475
  const tabs = repairTabs(v.tabs, ctx, depth)
@@ -541,6 +642,16 @@ function repairNode(value: unknown, ctx: RepairCtx, depth: number): GenuiNode |
541
642
  const series = v.series !== undefined && Array.isArray(v.series)
542
643
  ? repairSeries(v.series, GENUI_LIMITS.maxPlotSeries, GENUI_LIMITS.maxChartPoints)
543
644
  : undefined
645
+ // sankey / graph edges: `from`/`to` must be strings, `value` a number.
646
+ const links = Array.isArray(v.links)
647
+ ? v.links.slice(0, GENUI_LIMITS.maxChartPoints).flatMap(entry => {
648
+ const e = obj(entry)
649
+ const from = e === undefined ? undefined : str(e.from, 64)
650
+ const to = e === undefined ? undefined : str(e.to, 64)
651
+ if (from === undefined || to === undefined) return []
652
+ return [{ from, to, ...opt('value', num(e!.value, 0, 1e9)) }]
653
+ })
654
+ : undefined
544
655
  // Full option: depth-bounded pass-through (the model writes the ECharts
545
656
  // option object; the guard walks it to cap nesting but does not
546
657
  // validate ECharts semantics — that is echarts' own job).
@@ -553,8 +664,9 @@ function repairNode(value: unknown, ctx: RepairCtx, depth: number): GenuiNode |
553
664
  sanitized === undefined || typeof sanitized !== 'object' || sanitized === null || Array.isArray(sanitized)
554
665
  ? undefined
555
666
  : sanitized as Record<string, unknown>
556
- // At least one of preset+data or option must be present.
557
- if (option === undefined && data === undefined && series === undefined) return null
667
+ // At least one of preset+data, links or option must be present.
668
+ if (option === undefined && data === undefined && series === undefined
669
+ && (links === undefined || links.length === 0)) return null
558
670
  return {
559
671
  type: 'echart',
560
672
  ...opt('title', str(v.title, GENUI_LIMITS.maxString)),
@@ -562,6 +674,8 @@ function repairNode(value: unknown, ctx: RepairCtx, depth: number): GenuiNode |
562
674
  ...opt('preset', enu(v.preset, ECHART_PRESETS)),
563
675
  ...opt('data', data),
564
676
  ...opt('series', series),
677
+ ...opt('links', links !== undefined && links.length > 0 ? links : undefined),
678
+ ...opt('palette', paletteValues(v.palette)),
565
679
  ...opt('option', option),
566
680
  }
567
681
  }
@@ -1480,19 +1594,29 @@ function validateChartNode(v: Record<string, unknown>, at: string, errors: strin
1480
1594
  errors.push(`${at}.kind must be bars, line, or donut`)
1481
1595
  }
1482
1596
  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) {
1597
+ const series = Array.isArray(v.series) ? v.series : undefined
1598
+ // Grouped bars legitimately carry `data: []` and keep every point in
1599
+ // `series`; only an empty data array with NO series is undrawable.
1600
+ if (Array.isArray(v.data) && v.data.length === 0 && (series === undefined || series.length === 0)) {
1601
+ errors.push(`${at}.data must not be empty`)
1602
+ }
1603
+ if (series !== undefined) {
1604
+ if (series.length === 0) errors.push(`${at}.series must not be empty`)
1605
+ // A line chart accepts `series` (one line per entry); the donut is a
1606
+ // share-of-total shape and stays single-series.
1607
+ if (kind === 'donut') errors.push(`${at}.series is only supported for bars and line`)
1608
+ for (let index = 0; index < series.length; index++) {
1609
+ const entry = obj(series[index])
1610
+ if (entry !== undefined && Array.isArray(entry.data) && entry.data.length === 0) {
1490
1611
  errors.push(`${at}.series[${index}].data must not be empty`)
1491
1612
  }
1492
1613
  }
1493
1614
  }
1494
- if ((kind === 'line' || kind === 'donut') && v.data === undefined) {
1495
- errors.push(`${at}.data is required for ${kind}`)
1615
+ if (kind === 'donut' && v.data === undefined) {
1616
+ errors.push(`${at}.data is required for donut`)
1617
+ }
1618
+ if (kind === 'line' && v.data === undefined && (series === undefined || series.length === 0)) {
1619
+ errors.push(`${at}.data is required for line (or provide series)`)
1496
1620
  }
1497
1621
  validateChartData(v.data, `${at}.data`, errors)
1498
1622
  validateChartSeries(v.series, `${at}.series`, errors)
@@ -1581,6 +1705,10 @@ function validateNode(value: unknown, depth: number, at: string, errors: string[
1581
1705
  isStr('text')
1582
1706
  isStr('value')
1583
1707
  break
1708
+ case 'hero':
1709
+ if (typeof v.title !== 'string') errors.push(`${at}: type 'hero' requires title (string)`)
1710
+ isStr('subtitle')
1711
+ break
1584
1712
  case 'stat':
1585
1713
  if (typeof v.label !== 'string') errors.push(`${at}: type 'stat' requires label (string)`)
1586
1714
  if (typeof v.value !== 'string') errors.push(`${at}: type 'stat' requires value (string)`)
@@ -1612,6 +1740,12 @@ function validateNode(value: unknown, depth: number, at: string, errors: string[
1612
1740
  case 'table':
1613
1741
  if (!Array.isArray(v.columns)) errors.push(`${at}: type 'table' requires columns (array)`)
1614
1742
  if (!Array.isArray(v.rows)) errors.push(`${at}: type 'table' requires rows (array)`)
1743
+ if (v.types !== undefined && !Array.isArray(v.types)) {
1744
+ errors.push(`${at}.types must be an array of column cell types`)
1745
+ }
1746
+ if (v.details !== undefined && !Array.isArray(v.details)) {
1747
+ errors.push(`${at}.details must be an array aligned with rows`)
1748
+ }
1615
1749
  validateTableRows(v.rows, `${at}.rows`, errors)
1616
1750
  break
1617
1751
  case 'chart':
@@ -1690,8 +1824,12 @@ function validateNode(value: unknown, depth: number, at: string, errors: string[
1690
1824
  break
1691
1825
 
1692
1826
  case 'echart':
1693
- if (v.option === undefined && v.data === undefined && v.series === undefined) {
1694
- errors.push(`${at}: type 'echart' requires option, data, or series`)
1827
+ // `links` alone is a valid payload: the sankey/graph presets are driven
1828
+ // by edges only. Missing it here rejected the whole fence as
1829
+ // un-renderable (the render gate turns any error into "render nothing").
1830
+ if (v.option === undefined && v.data === undefined && v.series === undefined
1831
+ && (!Array.isArray(v.links) || v.links.length === 0)) {
1832
+ errors.push(`${at}: type 'echart' requires option, data, series, or links`)
1695
1833
  }
1696
1834
  isNum('height')
1697
1835
  break
@@ -124,6 +124,16 @@ function sendInlineGenuiAction(ctx: Context, sessionId: SessionId, action: strin
124
124
  })
125
125
  }
126
126
 
127
+ /** Visual-regression flag: `scripts/e2e-visual.mts` sets
128
+ * `window.__DSH_GENUI_E2E__ = true` via an init script BEFORE this bundle
129
+ * boots, which pins the DOM channel even on hosts that ship the registry
130
+ * extension point. The DOM channel is what lets the harness inject a gallery
131
+ * fence into an empty scratch profile and screenshot the real React tree
132
+ * without a model round trip; production pages never set the flag. */
133
+ function forcedDomChannel(): boolean {
134
+ return (globalThis as { __DSH_GENUI_E2E__?: boolean }).__DSH_GENUI_E2E__ === true
135
+ }
136
+
127
137
  /** Cordis client entry: register the fence renderer on boot, the keyed
128
138
  * toolview for the render_ui tool, and the session panel dock; returning the
129
139
  * disposers lets cordis tear all registrations down on plugin unload. */
@@ -132,10 +142,11 @@ export function apply(ctx: Context): () => void {
132
142
  // ships it (contract line), the DOM observer otherwise (pristine line).
133
143
  // One plugin build serves both deployments.
134
144
  const registerFn = (primitives as unknown as HostFenceExt).registerFenceRenderer
135
- const channel = typeof registerFn === 'function' ? 'registry' : 'dom'
145
+ const useRegistry = typeof registerFn === 'function' && !forcedDomChannel()
146
+ const channel = useRegistry ? 'registry' : 'dom'
136
147
  console.info(`[genui] client active; fence-channel=${channel}`)
137
- const disposers: Array<() => void> = typeof registerFn === 'function'
138
- ? [registerFn('dsh-ui', renderGenuiFence)]
148
+ const disposers: Array<() => void> = useRegistry
149
+ ? [registerFn!('dsh-ui', renderGenuiFence)]
139
150
  : [installDomFenceRenderer(ctx, (sessionId, action, payload) => sendInlineGenuiAction(ctx, sessionId, action, payload))]
140
151
  // Idle prefetch of the lazy engine assets: the browser downloads them at
141
152
  // LOW priority whenever the page is idle, so the first mermaid/3D node in
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Minimal inline markup for text-bearing fields.
3
+ *
4
+ * Prose-heavy answers need emphasis INSIDE a sentence: a command, a term, a key
5
+ * phrase, a link. Until now every field was a plain string, so emphasis meant
6
+ * splitting one sentence into several nodes and the paragraph read as broken.
7
+ *
8
+ * Supported (deliberately tiny, no nesting):
9
+ * `code` inline code chip
10
+ * **bold** emphasis that does not wrap or become a block
11
+ * ==mark== highlight
12
+ * [text](https://…) inline link (http/https/mailto only)
13
+ *
14
+ * Safety: this NEVER produces HTML. Each token becomes a React element, so the
15
+ * host's HTML parser is never involved (the whole client builds elements, the
16
+ * only innerHTML in the project is mermaid's sanitized SVG). An unterminated or
17
+ * unknown marker is rendered literally rather than erroring, and a link whose
18
+ * href fails {@link safeHref} degrades to its label text.
19
+ * @module @changfenhuang/dsh-genui/client/inline
20
+ */
21
+ import { createElement, type ReactNode } from 'react'
22
+ import css from './GenuiBlock.module.css'
23
+ import { safeHref } from './genui-runtime/value-utils.ts'
24
+
25
+ /** One pass, no nesting: each alternative is a self-contained token. */
26
+ // Links match ANY target here and are validated by safeHref afterwards, so a
27
+ // `javascript:` target degrades to its label text instead of showing as raw
28
+ // markup the reader has to parse themselves.
29
+ const INLINE = /`[^`\n]+`|\*\*[^*\n]+\*\*|==[^=\n]+==|\[[^\]\n]+\]\([^)\s]+\)/g
30
+
31
+ /** True when the string contains anything the parser understands. */
32
+ export function hasInlineMarkup(text: string): boolean {
33
+ return typeof text === 'string' && /[`*=]|\[/.test(text)
34
+ }
35
+
36
+ /**
37
+ * Render one string as React nodes with inline markup applied.
38
+ * @param text - the raw field value.
39
+ * @returns the original string when nothing matched, else the node list.
40
+ */
41
+ export function renderInline(text: string): ReactNode {
42
+ if (typeof text !== 'string' || text === '' || !hasInlineMarkup(text)) return text
43
+ const out: ReactNode[] = []
44
+ let last = 0
45
+ let key = 0
46
+ for (const match of text.matchAll(INLINE)) {
47
+ const index = match.index ?? 0
48
+ const token = match[0]
49
+ if (index > last) out.push(text.slice(last, index))
50
+ if (token.startsWith('`')) {
51
+ out.push(createElement('code', { key: key++, className: css.inlineCode }, token.slice(1, -1)))
52
+ } else if (token.startsWith('**')) {
53
+ out.push(createElement('strong', { key: key++, className: css.inlineStrong }, token.slice(2, -2)))
54
+ } else if (token.startsWith('==')) {
55
+ out.push(createElement('mark', { key: key++, className: css.inlineMark }, token.slice(2, -2)))
56
+ } else {
57
+ const parts = /^\[([^\]\n]+)\]\(([^)\s]+)\)$/.exec(token)
58
+ if (parts === null) {
59
+ out.push(token)
60
+ } else {
61
+ const href = safeHref(parts[2])
62
+ out.push(href === undefined
63
+ ? parts[1]
64
+ : createElement('a', {
65
+ key: key++, className: css.inlineLink, href, target: '_blank', rel: 'noreferrer noopener',
66
+ }, parts[1]))
67
+ }
68
+ }
69
+ last = index + token.length
70
+ }
71
+ if (last < text.length) out.push(text.slice(last))
72
+ return out.length === 0 ? text : out
73
+ }
@@ -25,15 +25,52 @@ let renderSeq = 0
25
25
  function loadMermaid(): Promise<typeof import('mermaid')> {
26
26
  mermaidPromise ??= import('mermaid').then(async m => {
27
27
  const api = m.default
28
- // Follow the host theme: boot-theme sets colorScheme on <html>; a
29
- // dark-forced diagram on a light chat looked broken.
30
- const dark = typeof document !== 'undefined'
31
- && document.documentElement.style.colorScheme === 'dark'
28
+ // Theme the diagram from the HOST tokens instead of a built-in mermaid
29
+ // palette: the stock themes render grey boxes with sharp corners, dark
30
+ // strokes and their own font, which sat next to our components as a
31
+ // visibly foreign object. `base` + themeVariables is the supported way to
32
+ // drive every colour (the dark/light branch is gone with it).
33
+ const token = (name: string, fallback: string): string => {
34
+ if (typeof document === 'undefined') return fallback
35
+ const value = getComputedStyle(document.body).getPropertyValue(name).trim()
36
+ return value === '' ? fallback : value
37
+ }
32
38
  api.initialize({
33
39
  startOnLoad: false,
34
40
  // Strict default: mermaid escapes/sanitizes; we never enable htmlLabels.
35
41
  securityLevel: 'strict',
36
- theme: dark ? 'dark' : 'neutral',
42
+ theme: 'base',
43
+ themeVariables: {
44
+ background: 'transparent',
45
+ fontFamily: 'inherit',
46
+ fontSize: '13px',
47
+ primaryColor: token('--dsw-alias-bg-layer-2', '#2c2c2e'),
48
+ primaryTextColor: token('--dsw-alias-label-primary', '#e6e6e6'),
49
+ primaryBorderColor: token('--dsw-alias-border-l2', 'rgba(255,255,255,0.12)'),
50
+ secondaryColor: token('--dsw-alias-bg-layer-2', '#2c2c2e'),
51
+ secondaryTextColor: token('--dsw-alias-label-primary', '#e6e6e6'),
52
+ secondaryBorderColor: token('--dsw-alias-border-l2', 'rgba(255,255,255,0.12)'),
53
+ tertiaryColor: token('--dsw-alias-bg-layer-2', '#2c2c2e'),
54
+ tertiaryTextColor: token('--dsw-alias-label-primary', '#e6e6e6'),
55
+ tertiaryBorderColor: token('--dsw-alias-border-l2', 'rgba(255,255,255,0.12)'),
56
+ mainBkg: token('--dsw-alias-bg-layer-2', '#2c2c2e'),
57
+ nodeBorder: token('--dsw-alias-border-l2', 'rgba(255,255,255,0.12)'),
58
+ nodeTextColor: token('--dsw-alias-label-primary', '#e6e6e6'),
59
+ lineColor: token('--dsw-alias-label-tertiary', '#8a8a92'),
60
+ textColor: token('--dsw-alias-label-primary', '#e6e6e6'),
61
+ edgeLabelBackground: 'transparent',
62
+ clusterBkg: 'transparent',
63
+ clusterBorder: token('--dsw-alias-border-l2', 'rgba(255,255,255,0.12)'),
64
+ titleColor: token('--dsw-alias-label-primary', '#e6e6e6'),
65
+ },
66
+ // Sharp corners and heavy strokes were the loudest mismatch; the radius
67
+ // is set here because mermaid emits plain <rect> geometry.
68
+ themeCSS: [
69
+ '.node rect, .node polygon, .node circle, .node ellipse { rx: 8px; ry: 8px; stroke-width: 1px; }',
70
+ '.cluster rect { rx: 10px; ry: 10px; }',
71
+ '.edgePath .path { stroke-width: 1.4px; }',
72
+ '.label { color: inherit; }',
73
+ ].join(' '),
37
74
  // Fail loudly: with suppressErrorRendering false (the default) mermaid
38
75
  // renders an "error" diagram on parse/draw failure — the caller then
39
76
  // receives a normal-looking SVG whose text is the raw engine error