@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.
- package/CHANGELOG.md +18 -1
- package/README.md +1 -1
- package/README.zh-CN.md +9 -1
- package/SKILL.md +4 -0
- package/lib/client.js +261 -5
- package/lib/index.js +14 -12
- package/lib/types/client/asset-loader.d.ts +2 -0
- package/lib/types/client/blocks/basic.d.ts +0 -5
- package/lib/types/client/blocks/diagram/theme.d.ts +3 -1
- package/lib/types/client/blocks/state.d.ts +4 -0
- package/lib/types/client/embed.d.ts +8 -0
- package/lib/types/client/inline.d.ts +3 -27
- package/lib/types/plugin/index.d.ts +2 -1
- package/package.json +63 -2
- package/src/client/EChartNode.tsx +3 -2
- package/src/client/GenuiBlock.module.css +25 -14
- package/src/client/GenuiBlock.tsx +24 -16
- package/src/client/PlotBlock.tsx +2 -1
- package/src/client/asset-loader.ts +7 -0
- package/src/client/blocks/advanced.tsx +15 -15
- package/src/client/blocks/basic.tsx +3 -2
- package/src/client/blocks/charts.tsx +18 -19
- package/src/client/blocks/checkbox.tsx +2 -1
- package/src/client/blocks/diagram/index.tsx +33 -24
- package/src/client/blocks/diagram/layout.ts +4 -1
- package/src/client/blocks/diagram/theme.ts +22 -3
- package/src/client/blocks/forms.tsx +15 -14
- package/src/client/blocks/image.tsx +2 -1
- package/src/client/blocks/render-node.tsx +18 -18
- package/src/client/blocks/state.ts +4 -0
- package/src/client/dom-fence.tsx +19 -10
- package/src/client/embed.ts +9 -0
- package/src/client/gallery.ts +6 -0
- package/src/client/guard.ts +9 -3
- package/src/client/inline.ts +36 -44
- package/src/plugin/index.ts +8 -6
- 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
|
-
//
|
|
323
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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={
|
|
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 *
|
|
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={
|
|
184
|
-
<text x={x + 20} y={
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
/**
|
|
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
|