@changfenhuang/dsh-genui 0.10.0 → 0.11.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 (37) hide show
  1. package/CHANGELOG.md +18 -1
  2. package/README.md +1 -1
  3. package/README.zh-CN.md +9 -1
  4. package/SKILL.md +4 -0
  5. package/lib/client.js +261 -5
  6. package/lib/index.js +14 -12
  7. package/lib/types/client/asset-loader.d.ts +2 -0
  8. package/lib/types/client/blocks/basic.d.ts +0 -5
  9. package/lib/types/client/blocks/diagram/theme.d.ts +3 -1
  10. package/lib/types/client/blocks/state.d.ts +4 -0
  11. package/lib/types/client/embed.d.ts +8 -0
  12. package/lib/types/client/inline.d.ts +3 -27
  13. package/lib/types/plugin/index.d.ts +2 -1
  14. package/package.json +63 -2
  15. package/src/client/EChartNode.tsx +3 -2
  16. package/src/client/GenuiBlock.module.css +25 -14
  17. package/src/client/GenuiBlock.tsx +24 -16
  18. package/src/client/PlotBlock.tsx +2 -1
  19. package/src/client/asset-loader.ts +7 -0
  20. package/src/client/blocks/advanced.tsx +15 -15
  21. package/src/client/blocks/basic.tsx +3 -2
  22. package/src/client/blocks/charts.tsx +18 -19
  23. package/src/client/blocks/checkbox.tsx +2 -1
  24. package/src/client/blocks/diagram/index.tsx +33 -24
  25. package/src/client/blocks/diagram/layout.ts +4 -1
  26. package/src/client/blocks/diagram/theme.ts +22 -3
  27. package/src/client/blocks/forms.tsx +15 -14
  28. package/src/client/blocks/image.tsx +2 -1
  29. package/src/client/blocks/render-node.tsx +18 -18
  30. package/src/client/blocks/state.ts +4 -0
  31. package/src/client/dom-fence.tsx +19 -10
  32. package/src/client/embed.ts +9 -0
  33. package/src/client/gallery.ts +6 -0
  34. package/src/client/guard.ts +9 -3
  35. package/src/client/inline.ts +36 -44
  36. package/src/plugin/index.ts +8 -6
  37. package/src/plugin/tool.ts +2 -2
@@ -47,8 +47,8 @@ export const StepsNode = memo(function StepsNode({ steps }: { steps: GenuiSteps
47
47
  <li key={i} className={`${css.step} ${done ? css.stepDone : ''} ${active ? css.stepActive : ''}`}>
48
48
  <span className={css.stepMarker}>{done ? '✓' : String(i + 1)}</span>
49
49
  <span className={css.stepContent}>
50
- <span className={css.stepTitle}>{step.title}</span>
51
- {step.desc !== undefined && <span className={css.stepDesc}>{step.desc}</span>}
50
+ <span className={css.stepTitle}>{renderInline(step.title)}</span>
51
+ {step.desc !== undefined && <span className={css.stepDesc}>{renderInline(step.desc)}</span>}
52
52
  </span>
53
53
  </li>
54
54
  )
@@ -64,7 +64,7 @@ export const KeyValueNode = memo(function KeyValueNode({ node }: { node: GenuiKe
64
64
  <dl className={css.keyvalue}>
65
65
  {pairs.map((pair, i) => (
66
66
  <div key={i} className={css.kvRow}>
67
- <dt className={css.kvKey}>{pair.key}</dt>
67
+ <dt className={css.kvKey}>{renderInline(pair.key)}</dt>
68
68
  <dd className={css.kvValue}>{renderInline(pair.value)}</dd>
69
69
  </div>
70
70
  ))}
@@ -159,7 +159,7 @@ export function TabsNode({ tabs, onAction, depth = 0, answers }: {
159
159
  className={`${css.tab} ${i === safeActive ? css.tabActive : ''}`}
160
160
  onClick={() => setActive(i)}
161
161
  >
162
- {tab.label}
162
+ {renderInline(tab.label, false)}
163
163
  </button>
164
164
  ))}
165
165
  </div>
@@ -202,7 +202,7 @@ export function AccordionNode({ node, onAction, depth = 0, answers }: {
202
202
  aria-controls={`${uid}-body-${i}`}
203
203
  onClick={() => setOpen(open === i ? null : i)}
204
204
  >
205
- <span className={css.accTitle}>{item.title}</span>
205
+ <span className={css.accTitle}>{renderInline(item.title, false)}</span>
206
206
  <span className={css.accChevron} data-open={open === i} aria-hidden>▸</span>
207
207
  </button>
208
208
  {open === i && (
@@ -264,7 +264,7 @@ export const CopyNode = memo(function CopyNode({ node }: { node: GenuiCopy }) {
264
264
  })
265
265
  }}
266
266
  >
267
- {copied ? '✓ 已复制' : (node.label ?? '复制')}
267
+ {copied ? '✓ 已复制' : renderInline(node.label ?? '复制', false)}
268
268
  </button>
269
269
  <span className={css.visuallyHidden} role="status">{copied ? '已复制到剪贴板' : ''}</span>
270
270
  </>
@@ -322,7 +322,7 @@ export const Scene3DNode = memo(function Scene3DNode({ node }: { node: GenuiScen
322
322
  }, [scene])
323
323
  return (
324
324
  <div className={css.scene3dWrap} data-genui-scene3d>
325
- {node.title !== undefined && <div className={css.scene3dTitle}>{node.title}</div>}
325
+ {node.title !== undefined && <div className={css.scene3dTitle}>{renderInline(node.title)}</div>}
326
326
  <div ref={ref} className={css.scene3dCanvas} />
327
327
  {status === 'loading' && <div className={css.scene3dHint}>加载 3D 场景…</div>}
328
328
  {status === 'error' && <div className={css.scene3dHint}>3D 渲染失败</div>}
@@ -343,10 +343,10 @@ export const TimelineNode = memo(function TimelineNode({ node }: { node: GenuiTi
343
343
  </div>
344
344
  <div className={css.tlBody}>
345
345
  <div className={css.tlHead}>
346
- <span className={css.tlTitle}>{item.title}</span>
347
- {item.time !== undefined && <span className={css.tlTime}>{item.time}</span>}
346
+ <span className={css.tlTitle}>{renderInline(item.title, false)}</span>
347
+ {item.time !== undefined && <span className={css.tlTime}>{renderInline(item.time)}</span>}
348
348
  </div>
349
- {item.desc !== undefined && <div className={css.tlDesc}>{item.desc}</div>}
349
+ {item.desc !== undefined && <div className={css.tlDesc}>{renderInline(item.desc)}</div>}
350
350
  </div>
351
351
  </div>
352
352
  ))}
@@ -445,7 +445,7 @@ export const QuizNode = memo(function QuizNode({ node, onAction }: {
445
445
  const action = node.action
446
446
  return (
447
447
  <div className={css.quiz} data-genui-quiz>
448
- <div className={css.quizQuestion}>{node.question}</div>
448
+ <div className={css.quizQuestion}>{renderInline(node.question)}</div>
449
449
  <div className={css.quizOptions}>
450
450
  {options.map((opt, i) => {
451
451
  const isChosen = selected === i
@@ -477,7 +477,7 @@ export const QuizNode = memo(function QuizNode({ node, onAction }: {
477
477
  <span className={css.quizMarker}>
478
478
  {answered ? (opt.correct === true ? '✓' : isChosen ? '✗' : '') : isChosen ? '●' : '○'}
479
479
  </span>
480
- {opt.label}
480
+ {renderInline(opt.label, false)}
481
481
  </button>
482
482
  )
483
483
  })}
@@ -486,9 +486,9 @@ export const QuizNode = memo(function QuizNode({ node, onAction }: {
486
486
  <div className={css.quizResult} aria-live="polite">
487
487
  <div className={correct ? css.quizCorrectMsg : css.quizWrongMsg}>
488
488
  {correct ? '✓ 回答正确!' : '✗ 再想想看'}
489
- {chosen?.feedback !== undefined && <div className={css.quizFeedback}>{chosen.feedback}</div>}
489
+ {chosen?.feedback !== undefined && <div className={css.quizFeedback}>{renderInline(chosen.feedback)}</div>}
490
490
  </div>
491
- {node.explanation !== undefined && <div className={css.quizExplanation}>{node.explanation}</div>}
491
+ {node.explanation !== undefined && <div className={css.quizExplanation}>{renderInline(node.explanation)}</div>}
492
492
  <button type="button" className={css.quizRetry} onClick={() => setSelected(null)}>重新作答</button>
493
493
  </div>
494
494
  )}
@@ -503,7 +503,7 @@ export const BreadcrumbNode = memo(function BreadcrumbNode({ node }: { node: Gen
503
503
  <nav className={css.breadcrumb} aria-label="breadcrumb">
504
504
  {items.map((item, i) => (
505
505
  <span key={i} className={css.bcItem}>
506
- <span className={`${css.bcText} ${i === items.length - 1 ? css.bcCurrent : ''}`}>{item}</span>
506
+ <span className={`${css.bcText} ${i === items.length - 1 ? css.bcCurrent : ''}`}>{renderInline(item)}</span>
507
507
  {i < items.length - 1 && <span className={css.bcSep}>/</span>}
508
508
  </span>
509
509
  ))}
@@ -3,6 +3,7 @@
3
3
  * (the actionable-button chip). Used by the render dispatcher.
4
4
  * @module @changfenhuang/dsh-genui/client/blocks/basic
5
5
  */
6
+ import { renderInline } from '../inline.ts'
6
7
  import { memo, useEffect, useRef, useState, type ReactNode } from 'react'
7
8
  import css from '../GenuiBlock.module.css'
8
9
  import type { GenuiAudio, GenuiVideo } from '../spec.ts'
@@ -75,7 +76,7 @@ export const AudioNode = memo(function AudioNode({ node }: { node: GenuiAudio })
75
76
  const [failed, setFailed] = useState(false)
76
77
  return (
77
78
  <figure className={css.media}>
78
- {node.alt !== undefined && <figcaption className={css.mediaLabel}>{node.alt}</figcaption>}
79
+ {node.alt !== undefined && <figcaption className={css.mediaLabel}>{renderInline(node.alt)}</figcaption>}
79
80
  {failed
80
81
  ? <div className={css.mediaError} role="alert">音频无法播放</div>
81
82
  : <audio
@@ -95,7 +96,7 @@ export const VideoNode = memo(function VideoNode({ node }: { node: GenuiVideo })
95
96
  const [failed, setFailed] = useState(false)
96
97
  return (
97
98
  <figure className={css.media}>
98
- {node.alt !== undefined && <figcaption className={css.mediaLabel}>{node.alt}</figcaption>}
99
+ {node.alt !== undefined && <figcaption className={css.mediaLabel}>{renderInline(node.alt)}</figcaption>}
99
100
  {failed
100
101
  ? <div className={css.mediaError} role="alert">视频无法播放</div>
101
102
  : <video
@@ -95,7 +95,7 @@ function deltaTone(value: unknown): 'up' | 'down' | null {
95
95
  * trend line (area wash + end dot, same geometry as stat.spark). */
96
96
  function CellSpark({ cell }: { cell: string | number }) {
97
97
  const values = String(cell).split(/[\s,;]+/).map(Number).filter(Number.isFinite)
98
- if (values.length < 2) return <>{String(cell)}</>
98
+ if (values.length < 2) return <>{renderInline(String(cell), false)}</>
99
99
  const W = 88
100
100
  const H = 22
101
101
  const pad = 2
@@ -131,7 +131,7 @@ function CellRing({ cell }: { cell: string | number }) {
131
131
  strokeDasharray={`${(pct / 100) * C} ${C}`} transform="rotate(-90 14 14)"
132
132
  />
133
133
  </svg>
134
- <span className={css.cellRingText}>{String(cell)}</span>
134
+ <span className={css.cellRingText}>{renderInline(String(cell), false)}</span>
135
135
  </span>
136
136
  )
137
137
  }
@@ -185,7 +185,7 @@ function CellBar({ cell }: { cell: string | number }) {
185
185
  return (
186
186
  <span className={css.cellBar}>
187
187
  <span className={css.cellBarFill} style={{ width: `${pct}%` }} />
188
- <span className={css.cellBarText}>{String(cell)}</span>
188
+ <span className={css.cellBarText}>{renderInline(String(cell), false)}</span>
189
189
  </span>
190
190
  )
191
191
  }
@@ -309,7 +309,7 @@ export const TableNode = memo(function TableNode({ node, renderDetail, filterVal
309
309
  return (
310
310
  <td key={j} className={numeric[j] || type === 'num' ? css.tdNum : undefined}>
311
311
  {type === 'badge'
312
- ? <span className={css.cellBadge}>{String(cell)}</span>
312
+ ? <span className={css.cellBadge}>{renderInline(String(cell), false)}</span>
313
313
  : type === 'bar'
314
314
  ? <CellBar cell={cell} />
315
315
  : type === 'spark'
@@ -319,10 +319,9 @@ export const TableNode = memo(function TableNode({ node, renderDetail, filterVal
319
319
  : type === 'index'
320
320
  ? <span className={css.cellIndex}>{rowIndex + 1}</span>
321
321
  : tone === null
322
- // Text cells honour inline markup; numeric/badge/spark
323
- // cells stay literal (a number has nothing to emphasise).
324
- ? renderInline(String(cell))
325
- : <span className={`${css.tdDelta} ${tone === 'up' ? css.tdDeltaUp : css.tdDeltaDown}`}>{String(cell)}</span>}
322
+ // Format displayed text without changing sorting or exported data.
323
+ ? renderInline(String(cell), false)
324
+ : <span className={`${css.tdDelta} ${tone === 'up' ? css.tdDeltaUp : css.tdDeltaDown}`}>{renderInline(String(cell), false)}</span>}
326
325
  </td>
327
326
  )
328
327
  }
@@ -340,7 +339,7 @@ export const TableNode = memo(function TableNode({ node, renderDetail, filterVal
340
339
  aria-sort={sort !== null && sort.col === i ? (sort.dir === 1 ? 'ascending' : 'descending') : 'none'}
341
340
  >
342
341
  <button type="button" className={css.thSort} onClick={() => clickHeader(i)}>
343
- {c}
342
+ {renderInline(c, false)}
344
343
  {sort !== null && sort.col === i && <span className={css.thSortMark} aria-hidden>{sort.dir === 1 ? ' ▲' : ' ▼'}</span>}
345
344
  </button>
346
345
  </th>
@@ -363,7 +362,7 @@ export const TableNode = memo(function TableNode({ node, renderDetail, filterVal
363
362
  onClick={() => toggleSection(headerIndex)}
364
363
  >
365
364
  <span className={css.groupChevron} aria-hidden>{isCollapsed ? '▸' : '▾'}</span>
366
- {String(section.header.row[0])}
365
+ {renderInline(String(section.header.row[0]), false)}
367
366
  <span className={css.groupCount}>{section.children.length}</span>
368
367
  </button>
369
368
  </td>
@@ -386,7 +385,7 @@ export const TableNode = memo(function TableNode({ node, renderDetail, filterVal
386
385
  onClick={() => toggleDetail(child.index)}
387
386
  >
388
387
  <span className={css.detailChevron} data-open={open} aria-hidden>▸</span>
389
- {String(cell)}
388
+ {renderInline(String(cell), false)}
390
389
  </button>
391
390
  </td>
392
391
  )
@@ -470,8 +469,8 @@ function ChartTip({ tip }: { tip: TipState | null }) {
470
469
  <div className={css.chartTip} style={{ left: `${tip.x}px`, top: `${tip.y}px` }} role="tooltip">
471
470
  {tip.rows.map(([label, value], i) => (
472
471
  <span key={`${label}-${i}`} className={css.chartTipRow}>
473
- <span>{label}</span>
474
- <span className={css.chartTipValue}>{value}</span>
472
+ <span>{renderInline(label)}</span>
473
+ <span className={css.chartTipValue}>{renderInline(String(value))}</span>
475
474
  </span>
476
475
  ))}
477
476
  </div>
@@ -594,7 +593,7 @@ export const BarsNode = memo(function BarsNode({ chart }: { chart: GenuiChart })
594
593
  {grouped.map((entry, si) => (
595
594
  <span key={si} className={css.legendItem}>
596
595
  <span className={css.legendSwatch} style={{ background: colors[si] }} />
597
- {entry.label}
596
+ {renderInline(entry.label)}
598
597
  </span>
599
598
  ))}
600
599
  </div>
@@ -605,7 +604,7 @@ export const BarsNode = memo(function BarsNode({ chart }: { chart: GenuiChart })
605
604
  <div className={css.hbars}>
606
605
  {labels.map((label, i) => (
607
606
  <div key={i} className={css.hbarRow}>
608
- <span className={css.hbarLabel} title={label}>{label}</span>
607
+ <span className={css.hbarLabel} title={label}>{renderInline(label)}</span>
609
608
  <div className={css.hbarTracks}>
610
609
  {stacked
611
610
  ? (
@@ -758,14 +757,14 @@ export const BarsNode = memo(function BarsNode({ chart }: { chart: GenuiChart })
758
757
  </div>
759
758
  </div>
760
759
  <div className={css.chartLabels}>
761
- {labels.map((label, i) => <span key={`${label}-${i}`} className={css.barLabel}>{label}</span>)}
760
+ {labels.map((label, i) => <span key={`${label}-${i}`} className={css.barLabel}>{renderInline(label)}</span>)}
762
761
  </div>
763
762
  {isGrouped && (
764
763
  <div className={css.chartLegend}>
765
764
  {grouped.map((entry, si) => (
766
765
  <span key={si} className={css.legendItem}>
767
766
  <span className={css.legendSwatch} style={{ background: colors[si] }} />
768
- {entry.label}
767
+ {renderInline(entry.label)}
769
768
  </span>
770
769
  ))}
771
770
  </div>
@@ -864,7 +863,7 @@ export const LineChartNode = memo(function LineChartNode({ chart }: { chart: Gen
864
863
  {grouped!.map((entry, si) => (
865
864
  <span key={si} className={css.legendItem}>
866
865
  <span className={css.legendSwatch} style={{ background: colors[si] }} />
867
- {entry.label}
866
+ {renderInline(entry.label)}
868
867
  </span>
869
868
  ))}
870
869
  </div>
@@ -926,7 +925,7 @@ export const DonutNode = memo(function DonutNode({ chart }: { chart: GenuiChart
926
925
  {clamped.map((d, i) => (
927
926
  <span key={i} className={css.legendItem}>
928
927
  <span className={css.legendSwatch} style={{ background: seriesColor(i, data.length, d.color, chart.palette) ?? 'var(--dsw-alias-state-business-primary, #4f8ef7)' }} />
929
- <span>{d.label}</span>
928
+ <span>{renderInline(d.label)}</span>
930
929
  <span className={css.donutPct}>{String(d.value)} · {(d.v / total * 100).toFixed(1)}%</span>
931
930
  </span>
932
931
  ))}
@@ -1,3 +1,4 @@
1
+ import { renderInline } from '../inline.ts'
1
2
  import { useEffect, useState } from 'react'
2
3
  import css from '../GenuiBlock.module.css'
3
4
  import type { GenuiCheckbox } from '../spec.ts'
@@ -54,7 +55,7 @@ export function CheckboxNode({ node, onAction, answers }: {
54
55
  }
55
56
  }}
56
57
  />
57
- <span>{node.label}</span>
58
+ <span>{renderInline(node.label, false)}</span>
58
59
  </label>
59
60
  )
60
61
  }
@@ -8,6 +8,7 @@
8
8
  * value is sanitized by the guard before this component ever sees it.
9
9
  * @module @changfenhuang/dsh-genui/client/blocks/diagram
10
10
  */
11
+ import { renderInline } from '../../inline.ts'
11
12
  import { useMemo } from 'react'
12
13
  import type { GenuiDiagram } from '../../spec.ts'
13
14
  import { GENUI_LIMITS } from '../../genui-runtime/index.ts'
@@ -20,7 +21,16 @@ const FONT_MONO = "'Geist Mono', ui-monospace, 'SF Mono', monospace"
20
21
 
21
22
  /** Editorial constants (diagram-design §5–7). */
22
23
  const NODE_H = 64 // node box height (48→64 for the editorial ramp)
23
- const LEGEND_H = 56
24
+ const LEGEND_COL_W = 150
25
+ const LEGEND_ROW_H = 22
26
+ const LEGEND_ITEMS: Array<{ label: string; type: string | undefined }> = [
27
+ { label: 'Focal', type: 'focal' },
28
+ { label: 'Backend', type: 'backend' },
29
+ { label: 'Store', type: 'store' },
30
+ { label: 'External', type: 'external' },
31
+ { label: 'Optional', type: 'optional' },
32
+ { label: 'Security', type: 'security' },
33
+ ]
24
34
 
25
35
  /**
26
36
  * Compute a deterministic parent map for tree-like kinds: the first edge
@@ -34,19 +44,18 @@ function buildParentMap(edges: Array<{ from: string; to: string }>): (id: string
34
44
  return id => parents.get(id)
35
45
  }
36
46
 
37
- /** Render one node box + its text content (tag / name / sublabel / index). */
47
+ /** Render one node box + its text content (tag / name / sublabel). */
38
48
  function renderNodeBox(
39
49
  box: Box,
40
50
  label: string,
41
51
  sub: string | undefined,
42
52
  type: string | undefined,
43
53
  tag: string | undefined,
44
- index: number,
45
54
  palette: ReturnType<typeof resolvePalette>,
46
55
  ): React.ReactNode {
47
56
  const treatment = nodeTreatment(type, palette)
48
57
  const cx = box.x + box.w / 2
49
- // Editorial 64px ramp: name centered, sublabel below, tag top-left, index bottom-right.
58
+ // Editorial 64px ramp: name centered, sublabel below, tag top-left.
50
59
  const nameY = box.y + (box.h - NODE_H) / 2 + 28
51
60
  const subY = box.y + (box.h - NODE_H) / 2 + 44
52
61
  const tagW = Math.min(44, Math.max(24, (tag ?? '').length * 7 + 8))
@@ -71,8 +80,6 @@ function renderNodeBox(
71
80
  <text x={box.x + 8 + tagW / 2} y={box.y + 15} fill={inkAt(treatment.stroke, 0.8)} fontSize={7} fontFamily={FONT_MONO} textAnchor="middle" letterSpacing={0.8}>{tag}</text>
72
81
  </>
73
82
  )}
74
- {/* Index number: bottom-right, half-transparent, editorial big numeral */}
75
- <text x={box.x + box.w - 6} y={box.y + box.h - 6} fill={inkAt(treatment.stroke, 0.12)} fontSize={24} fontWeight={600} fontFamily={FONT_MONO} textAnchor="end">{String(index).padStart(2, '0')}</text>
76
83
  <text x={cx} y={nameY} fill={palette.ink} fontSize={12} fontWeight={600} fontFamily={FONT_SANS} textAnchor="middle">{label}</text>
77
84
  {sub !== undefined && (
78
85
  <text x={cx} y={subY} fill={palette.soft} fontSize={9} fontFamily={FONT_MONO} textAnchor="middle">{sub}</text>
@@ -162,26 +169,19 @@ function renderZone(z: { label: string; x: number; y: number; w: number; h: numb
162
169
  }
163
170
 
164
171
  /** Legend strip at the bottom: hairline separator + treatment swatches. */
165
- function renderLegend(palette: ReturnType<typeof resolvePalette>): React.ReactNode {
166
- const items: Array<{ label: string; type: string | undefined }> = [
167
- { label: 'Focal', type: 'focal' },
168
- { label: 'Backend', type: 'backend' },
169
- { label: 'Store', type: 'store' },
170
- { label: 'External', type: 'external' },
171
- { label: 'Optional', type: 'optional' },
172
- { label: 'Security', type: 'security' },
173
- ]
172
+ function renderLegend(palette: ReturnType<typeof resolvePalette>, items: typeof LEGEND_ITEMS, width: number, cols: number): React.ReactNode {
174
173
  return (
175
174
  <g>
176
- <line x1={40} y1={0} x2={1000} y2={0} stroke={inkAt(palette.ink, 0.10)} strokeWidth={0.8} />
175
+ <line x1={40} y1={0} x2={Math.max(120, width - 40)} y2={0} stroke={inkAt(palette.ink, 0.10)} strokeWidth={0.8} />
177
176
  <text x={40} y={16} fill={palette.soft} fontSize={8} fontFamily={FONT_MONO} letterSpacing={1.6}>LEGEND</text>
178
177
  {items.map((item, i) => {
179
178
  const t = nodeTreatment(item.type, palette)
180
- const x = 40 + i * 170
179
+ const x = 40 + (i % cols) * LEGEND_COL_W
180
+ const y = 32 + Math.floor(i / cols) * LEGEND_ROW_H
181
181
  return (
182
182
  <g key={item.label}>
183
- <rect x={x} y={32} width={14} height={10} rx={2} fill={t.fill} stroke={t.stroke} strokeWidth={1} strokeDasharray={t.dashed === true ? '3,2' : undefined} />
184
- <text x={x + 20} y={41} fill={palette.soft} fontSize={8.5} fontFamily={FONT_SANS}>{item.label}</text>
183
+ <rect x={x} y={y} width={14} height={10} rx={2} fill={t.fill} stroke={t.stroke} strokeWidth={1} strokeDasharray={t.dashed === true ? '3,2' : undefined} />
184
+ <text x={x + 20} y={y + 9} fill={palette.soft} fontSize={8.5} fontFamily={FONT_SANS}>{item.label}</text>
185
185
  </g>
186
186
  )
187
187
  })}
@@ -205,6 +205,7 @@ function nextUid(): string {
205
205
  /** The `diagram` node renderer. */
206
206
  export function DiagramNode({ node }: { node: GenuiDiagram }) {
207
207
  const uid = useMemo(nextUid, [])
208
+ // CSS token references follow the surrounding theme without remounting SVG nodes.
208
209
  const palette = useMemo(() => resolvePalette(node.variant, node.theme), [node.variant, node.theme])
209
210
  const layout = useMemo(() => resolveLayout(node, buildParentMap(node.edges ?? [])), [node])
210
211
 
@@ -226,13 +227,21 @@ export function DiagramNode({ node }: { node: GenuiDiagram }) {
226
227
 
227
228
  // Canvas: content bounds + editorial chrome (bottom legend strip).
228
229
  const contentH = layout.height
229
- const canvasH = contentH + LEGEND_H + 24
230
+ // Legend: only the types this diagram actually uses, wrapped to the canvas
231
+ // width (the old fixed 170px pitch + hardcoded 1000px rule overflowed any
232
+ // narrow canvas and printed "Extern…").
233
+ const legendItems = LEGEND_ITEMS.filter(item => item.type !== undefined
234
+ && nodes.some(n => (n.node.type ?? 'backend') === item.type))
235
+ const legendCols = Math.max(1, Math.floor((layout.width - 40) / LEGEND_COL_W))
236
+ const legendRows = Math.max(1, Math.ceil(legendItems.length / legendCols))
237
+ const legendH = 24 + legendRows * LEGEND_ROW_H
238
+ const canvasH = contentH + legendH + 12
230
239
 
231
240
  return (
232
241
  <figure className="genui-diagram" data-genui-diagram>
233
242
  {node.title !== undefined && (
234
243
  <figcaption id={captionId} style={{ fontFamily: "Instrument Serif, 'Times New Roman', serif", fontSize: 20, marginBottom: 8, color: palette.ink }}>
235
- {node.title}
244
+ {renderInline(node.title)}
236
245
  </figcaption>
237
246
  )}
238
247
  <svg
@@ -264,19 +273,19 @@ export function DiagramNode({ node }: { node: GenuiDiagram }) {
264
273
  </g>
265
274
  {/* nodes after edges */}
266
275
  <g>
267
- {nodes.map((l, idx) => {
276
+ {nodes.map(l => {
268
277
  const effectiveType = l.node.type === 'focal' && focalSeen >= focalBudget ? undefined : l.node.type
269
278
  if (l.node.type === 'focal') focalSeen += 1
270
279
  return (
271
280
  <g key={l.node.id}>
272
- {renderNodeBox(l.box, l.node.label, l.node.sub, effectiveType, l.node.tag, idx + 1, palette)}
281
+ {renderNodeBox(l.box, l.node.label, l.node.sub, effectiveType, l.node.tag, palette)}
273
282
  </g>
274
283
  )
275
284
  })}
276
285
  </g>
277
286
  {/* legend strip */}
278
287
  <g transform={`translate(0, ${contentH + 8})`}>
279
- {renderLegend(palette)}
288
+ {renderLegend(palette, legendItems, layout.width, legendCols)}
280
289
  </g>
281
290
  </svg>
282
291
  </figure>
@@ -191,7 +191,10 @@ export function resolveLayout(
191
191
  height = Math.max(height, l.box.y + l.box.h)
192
192
  }
193
193
  width = Math.max(320, Math.ceil((width + MARGIN) / 4) * 4)
194
- height = Math.max(200, Math.ceil((height + MARGIN) / 4) * 4)
194
+ // Hug the content: the old 200px floor plus the legend strip made a 4-node
195
+ // single row render inside a canvas twice its needed height, and the SVG
196
+ // scales to the container width, so the empty band was amplified on screen.
197
+ height = Math.max(96, Math.ceil((height + MARGIN) / 4) * 4)
195
198
 
196
199
  const edgesOut: LayoutEdge[] = edges.map(e => ({ edge: e, fromId: e.from, toId: e.to }))
197
200
 
@@ -49,9 +49,27 @@ const DARK: DiagramPalette = {
49
49
  link: '#6a95d8',
50
50
  }
51
51
 
52
+ /** Native SVG paint values keep inherited host tokens live across theme changes. */
53
+ export function hostPalette(): DiagramPalette {
54
+ const accent = 'var(--dsw-alias-state-business-primary, #4f8ef7)'
55
+ return {
56
+ paper: 'var(--dsw-alias-bg-layer-2, #ffffff)',
57
+ paper2: 'var(--dsw-alias-bg-layer-3, #f5f5f5)',
58
+ ink: 'var(--dsw-alias-label-primary, #2d3142)',
59
+ muted: 'var(--dsw-alias-label-secondary, #4f5d75)',
60
+ soft: 'var(--dsw-alias-label-tertiary, #7a8399)',
61
+ rule: 'var(--dsw-alias-border-l2, rgba(45,49,66,0.12))',
62
+ accent,
63
+ accentTint: `color-mix(in srgb, ${accent} 10%, transparent)`,
64
+ link: accent,
65
+ }
66
+ }
67
+
52
68
  /** Resolve the active palette from variant + optional theme overrides. */
53
69
  export function resolvePalette(variant: GenuiDiagramVariant | undefined, theme: GenuiDiagramTheme | undefined): DiagramPalette {
54
- const base = variant === 'dark' ? DARK : LIGHT
70
+ // No explicit variant -> follow the host theme; an explicit variant keeps the
71
+ // editorial skin (that is what the field is for).
72
+ const base = variant === undefined ? hostPalette() : variant === 'dark' ? DARK : LIGHT
55
73
  if (theme === undefined) return base
56
74
  return {
57
75
  paper: theme.paper ?? base.paper,
@@ -75,7 +93,7 @@ export function nodeTreatment(type: string | undefined, p: DiagramPalette): { fi
75
93
  case 'input': return { fill: inkAt(p.muted, 0.10), stroke: p.soft }
76
94
  case 'optional': return { fill: inkAt(p.ink, 0.02), stroke: inkAt(p.ink, 0.20), dashed: true }
77
95
  case 'security': return { fill: inkAt(p.accent, 0.05), stroke: inkAt(p.accent, 0.50), dashed: true }
78
- default: return { fill: '#ffffff', stroke: p.ink }
96
+ default: return { fill: p.paper, stroke: p.ink }
79
97
  }
80
98
  }
81
99
 
@@ -88,8 +106,9 @@ export function edgeStroke(kind: string | undefined, p: DiagramPalette): string
88
106
  }
89
107
  }
90
108
 
91
- /** Helper: a color at a given opacity (accepts #hex and rgba() strings). */
109
+ /** Apply opacity while retaining live host-token references. */
92
110
  export function inkAt(color: string, opacity: number): string {
111
+ if (color.startsWith('var(')) return `color-mix(in srgb, ${color} ${opacity * 100}%, transparent)`
93
112
  if (color.startsWith('#')) {
94
113
  const hex = color.slice(1)
95
114
  const full = hex.length === 3 ? hex.split('').map(c => c + c).join('') : hex
@@ -3,6 +3,7 @@
3
3
  * input/select/textarea. All state flows through the shared AnswersState.
4
4
  * @module @changfenhuang/dsh-genui/client/blocks/forms
5
5
  */
6
+ import { renderInline } from '../inline.ts'
6
7
  import { useEffect, useId, useRef, useState } from 'react'
7
8
  import css from '../GenuiBlock.module.css'
8
9
  import { GENUI_LIMITS } from '../genui-runtime/index.ts'
@@ -50,7 +51,7 @@ export function RadioNode({ node, onAction, answers }: {
50
51
  }, [group, node.label, node.answer, node.explanation, node.options, node.selected])
51
52
  return (
52
53
  <div className={css.fieldGroup} role="radiogroup" aria-label={node.label}>
53
- {node.label !== undefined && <span className={css.fieldLabel}>{node.label}</span>}
54
+ {node.label !== undefined && <span className={css.fieldLabel}>{renderInline(node.label, false)}</span>}
54
55
  {options.map((opt, i) => (
55
56
  <label key={i} className={css.radio}>
56
57
  <input
@@ -68,7 +69,7 @@ export function RadioNode({ node, onAction, answers }: {
68
69
  }
69
70
  }}
70
71
  />
71
- <span>{opt}</span>
72
+ <span>{renderInline(opt, false)}</span>
72
73
  </label>
73
74
  ))}
74
75
  </div>
@@ -151,21 +152,21 @@ export function SubmitNode({ node, onAction, answers }: {
151
152
  if (correct === undefined) {
152
153
  return (
153
154
  <div key={g} className={css.gradeItem}>
154
- <span className={css.gradeQ}>{m.label}</span>
155
- <span className={css.gradeAns}>你的答案:{entry}</span>
155
+ <span className={css.gradeQ}>{renderInline(m.label)}</span>
156
+ <span className={css.gradeAns}>你的答案:{renderInline(entry)}</span>
156
157
  </div>
157
158
  )
158
159
  }
159
160
  const isCorrect = entry === correct
160
161
  return (
161
162
  <div key={g} className={`${css.gradeItem} ${isCorrect ? css.gradeItemOk : css.gradeItemNo}`}>
162
- <span className={css.gradeQ}>{m.label}</span>
163
+ <span className={css.gradeQ}>{renderInline(m.label)}</span>
163
164
  <span className={css.gradeTag}>{isCorrect ? '✓' : '✗'}</span>
164
165
  <span className={css.gradeAns}>
165
- 你的答案:{entry}
166
- {!isCorrect && <span className={css.gradeRight}> 正确答案:{correct}</span>}
166
+ 你的答案:{renderInline(entry)}
167
+ {!isCorrect && <span className={css.gradeRight}> 正确答案:{renderInline(correct ?? '')}</span>}
167
168
  </span>
168
- {m.explanation !== undefined && <span className={css.gradeExp}>{m.explanation}</span>}
169
+ {m.explanation !== undefined && <span className={css.gradeExp}>{renderInline(m.explanation)}</span>}
169
170
  </div>
170
171
  )
171
172
  })}
@@ -209,7 +210,7 @@ export function SubmitNode({ node, onAction, answers }: {
209
210
  }
210
211
  } : undefined}
211
212
  >
212
- {node.label}
213
+ {renderInline(node.label, false)}
213
214
  </button>
214
215
  {total > 0 && <span className={css.submitHint} aria-live="polite">已选 {answered}/{total}</span>}
215
216
  </div>
@@ -222,7 +223,7 @@ export function SwitchNode({ node, onAction }: { node: GenuiSwitch; onAction?: G
222
223
  const action = node.action
223
224
  return (
224
225
  <label className={css.switchRow}>
225
- <span className={css.switchLabel}>{node.label}</span>
226
+ <span className={css.switchLabel}>{renderInline(node.label, false)}</span>
226
227
  <button
227
228
  type="button"
228
229
  role="switch"
@@ -271,7 +272,7 @@ export function SliderNode({ node, onAction, answers }: {
271
272
  }
272
273
  return (
273
274
  <label className={css.sliderRow}>
274
- {node.label !== undefined && <span className={css.fieldLabel}>{node.label}</span>}
275
+ {node.label !== undefined && <span className={css.fieldLabel}>{renderInline(node.label, false)}</span>}
275
276
  <input
276
277
  type="range"
277
278
  className={css.sliderInput}
@@ -372,7 +373,7 @@ export function SelectNode({ node, onAction, answers }: {
372
373
  }
373
374
  return (
374
375
  <label className={css.field}>
375
- {node.label !== undefined && <span>{node.label}</span>}
376
+ {node.label !== undefined && <span>{renderInline(node.label, false)}</span>}
376
377
  <select
377
378
  className={css.select}
378
379
  value={value ?? ''}
@@ -445,7 +446,7 @@ export function InputNode({ node, onAction, answers }: {
445
446
  }, [secret, id])
446
447
  return (
447
448
  <label className={css.field}>
448
- {node.label !== undefined && <span>{node.label}</span>}
449
+ {node.label !== undefined && <span>{renderInline(node.label, false)}</span>}
449
450
  <input
450
451
  className={css.input}
451
452
  type={node.inputType ?? 'text'}
@@ -515,7 +516,7 @@ export function TextareaNode({ node, onAction, answers }: {
515
516
  }, [])
516
517
  return (
517
518
  <label className={css.field}>
518
- {node.label !== undefined && <span>{node.label}</span>}
519
+ {node.label !== undefined && <span>{renderInline(node.label, false)}</span>}
519
520
  <textarea
520
521
  className={css.textarea}
521
522
  placeholder={node.placeholder}
@@ -1,3 +1,4 @@
1
+ import { renderInline } from '../inline.ts'
1
2
  import { useState, type ReactNode } from 'react'
2
3
  import css from '../GenuiBlock.module.css'
3
4
  import type { GenuiImage } from '../spec.ts'
@@ -8,7 +9,7 @@ export function ImageNode({ node }: { node: GenuiImage }): ReactNode {
8
9
 
9
10
  return (
10
11
  <figure className={css.media}>
11
- {node.alt !== undefined && <figcaption className={css.mediaLabel}>{node.alt}</figcaption>}
12
+ {node.alt !== undefined && <figcaption className={css.mediaLabel}>{renderInline(node.alt)}</figcaption>}
12
13
  {failed
13
14
  ? <div className={css.mediaError} role="alert">图片无法加载</div>
14
15
  : <img