@changfenhuang/dsh-genui 0.11.3 → 0.12.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 (60) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +21 -6
  3. package/README.zh-CN.md +21 -6
  4. package/SKILL.md +18 -6
  5. package/lib/assets/mermaid.js +325 -325
  6. package/lib/assets/standalone-runtime.js +10 -9
  7. package/lib/client.js +31 -33
  8. package/lib/index.js +1060 -371
  9. package/lib/types/client/blocks/forms.d.ts +5 -6
  10. package/lib/types/client/blocks/state.d.ts +2 -11
  11. package/lib/types/client/dom-fence.d.ts +2 -1
  12. package/lib/types/client/file-address.d.ts +4 -0
  13. package/lib/types/client/file-link-context.d.ts +41 -0
  14. package/lib/types/client/file-link.d.ts +17 -0
  15. package/lib/types/client/i18n/en.d.ts +5 -1
  16. package/lib/types/client/inline.d.ts +1 -1
  17. package/lib/types/client/mermaid-colors.d.ts +2 -0
  18. package/lib/types/client/panel.d.ts +6 -1
  19. package/lib/types/client/spec.d.ts +8 -11
  20. package/lib/types/client/submission-registry.d.ts +51 -0
  21. package/lib/types/client/table-details.d.ts +20 -0
  22. package/lib/types/client/walk-spec.d.ts +3 -0
  23. package/lib/types/plugin/fence-feedback.d.ts +55 -12
  24. package/lib/types/plugin/index.d.ts +7 -3
  25. package/lib/types/shared/fence-repair.d.ts +19 -7
  26. package/package.json +1 -12
  27. package/src/client/GenuiBlock.module.css +100 -4
  28. package/src/client/GenuiBlock.tsx +7 -14
  29. package/src/client/artifact/ArtifactExport.module.css +34 -3
  30. package/src/client/artifact/ExportableGenuiBlock.tsx +23 -2
  31. package/src/client/blocks/charts.tsx +63 -14
  32. package/src/client/blocks/forms.tsx +26 -48
  33. package/src/client/blocks/render-node.tsx +11 -8
  34. package/src/client/blocks/state.ts +2 -12
  35. package/src/client/dom-fence.tsx +190 -50
  36. package/src/client/file-address.ts +37 -0
  37. package/src/client/file-link-context.tsx +114 -0
  38. package/src/client/file-link.ts +46 -0
  39. package/src/client/genui-runtime/diagnostics.ts +20 -0
  40. package/src/client/genui-runtime/normalize.ts +28 -9
  41. package/src/client/guard.ts +98 -153
  42. package/src/client/i18n/en.ts +5 -1
  43. package/src/client/i18n/zh.ts +5 -1
  44. package/src/client/index.tsx +23 -5
  45. package/src/client/inline.ts +28 -10
  46. package/src/client/mermaid-colors.ts +39 -0
  47. package/src/client/mermaid-core.ts +7 -3
  48. package/src/client/panel.tsx +18 -5
  49. package/src/client/spec.ts +8 -11
  50. package/src/client/submission-registry.ts +150 -0
  51. package/src/client/table-details.ts +151 -0
  52. package/src/client/walk-spec.ts +43 -0
  53. package/src/plugin/fence-feedback.ts +384 -53
  54. package/src/plugin/index.ts +20 -9
  55. package/src/plugin/tool.ts +8 -2
  56. package/src/shared/fence-repair.ts +345 -128
  57. package/tsdown.config.ts +3 -7
  58. package/lib/invariant.js +0 -19
  59. package/lib/types/plugin/invariant.d.ts +0 -15
  60. package/src/plugin/invariant.ts +0 -30
@@ -191,7 +191,7 @@ body[data-ds-dark-theme] .panel {
191
191
 
192
192
  /* ---------- text ---------- */
193
193
 
194
- .text { color: var(--dsw-alias-label-primary); }
194
+ .text { color: var(--dsw-alias-label-primary); white-space: pre-line; }
195
195
  .text.h1 {
196
196
  font-size: var(--dsl-g-font-h1);
197
197
  font-weight: 800;
@@ -229,6 +229,7 @@ body[data-ds-dark-theme] .panel {
229
229
  cursor: pointer;
230
230
  transition: border-color 0.15s, filter 0.15s, background 0.15s, transform 0.1s;
231
231
  text-align: center;
232
+ white-space: pre-line;
232
233
  /* A button in a `col` must hug its label; only `full: true` stretches.
233
234
  (Default flex `align-items: stretch` used to make every stacked button a
234
235
  full-width slab.) */
@@ -420,6 +421,9 @@ img.mediaPlayer { aspect-ratio: 16 / 9; object-fit: cover; background: var(--dsl
420
421
  }
421
422
  .inlineLink { color: var(--dsl-g-accent); text-decoration: underline; text-underline-offset: 2px; }
422
423
  .inlineLink:hover { text-decoration-thickness: 2px; }
424
+ .inlineFileLink { padding: 0; border: 0; background: none; color: var(--dsl-g-accent); font: inherit; text-decoration: underline; text-underline-offset: 2px; cursor: pointer; }
425
+ .inlineFileLink:hover { text-decoration-thickness: 2px; }
426
+ .inlineFileLink:focus-visible { outline: 2px solid var(--dsl-g-accent); outline-offset: 2px; border-radius: 3px; }
423
427
 
424
428
  /* ---------- badges / stat / progress ---------- */
425
429
 
@@ -628,6 +632,81 @@ img.mediaPlayer { aspect-ratio: 16 / 9; object-fit: cover; background: var(--dsl
628
632
  .tdDelta { font-weight: 600; }
629
633
  .tdDeltaUp { color: var(--dsw-alias-state-success-secondary, #1f9d63); }
630
634
  .tdDeltaDown { color: var(--dsw-alias-state-error-primary, #d94848); }
635
+ /* A cell carrying real line breaks opts out of the `nowrap` data voice.
636
+ Prose keeps `pre-line` (newlines paint, spaces collapse as everywhere else);
637
+ a code-shaped cell uses `pre-wrap` so leading indentation survives too.
638
+ Either way the newline characters stay in the DOM, which is what makes the
639
+ cell selectable and copyable as the multi-line snippet the model wrote. */
640
+ .table th.tdMultiline,
641
+ .table td.tdMultiline {
642
+ white-space: pre-line;
643
+ overflow-wrap: anywhere;
644
+ word-break: normal;
645
+ vertical-align: top;
646
+ }
647
+ .table th.tdCode,
648
+ .table td.tdCode {
649
+ white-space: pre-wrap;
650
+ overflow-wrap: anywhere;
651
+ word-break: normal;
652
+ vertical-align: top;
653
+ }
654
+ /* An inline code span keeps its own spacing too (a pasted snippet often lives
655
+ inside backticks), matching the block-level code surface. */
656
+ .inlineCode { white-space: pre-wrap; }
657
+ /* Table cells are single-line by default, so a chip inside them must not
658
+ re-open line breaking. Host shells render message bodies inside `.markdown`,
659
+ which makes every chip an atomic `inline-flex` box and inherits
660
+ `overflow-wrap: anywhere` into the cell; together with `pre-wrap` the chip's
661
+ automatic minimum size collapses, the column can then be sized below its
662
+ content, and the still-unbroken text is painted over the next column (#280).
663
+ Keep the cell's own line-breaking contract for every table section. */
664
+ .table > thead > tr > th:not(.tdCode):not(.tdMultiline) .inlineCode,
665
+ .table > tbody > tr:not(.detailRow) > td:not(.tdCode):not(.tdMultiline) .inlineCode,
666
+ .table > tfoot > tr > td:not(.tdCode):not(.tdMultiline) .inlineCode {
667
+ white-space: pre;
668
+ }
669
+ /* Rich-text owners must paint the real newlines kept by renderInline. Scope
670
+ this to prose/labels: table data keeps nowrap and code keeps pre-wrap. For
671
+ flex controls, target the label span so emphasis and plain text share one
672
+ line-formatting context (rather than becoming separate flex items). */
673
+ .calloutBody,
674
+ .liTitle,
675
+ .liDesc,
676
+ .kvValue,
677
+ .tlDesc,
678
+ .detailBody,
679
+ .accBody,
680
+ .banner,
681
+ .cardTitle,
682
+ .mediaLabel,
683
+ .heroLabel,
684
+ .heroTitle,
685
+ .heroSubtitle,
686
+ .statLabel,
687
+ .ringLabel,
688
+ .ringSub,
689
+ .calloutTitle,
690
+ .stepTitle,
691
+ .stepDesc,
692
+ .kvKey,
693
+ .tlTitle,
694
+ .tlTime,
695
+ .accTitle,
696
+ .field > span,
697
+ .fieldLabel,
698
+ .switchLabel,
699
+ .radio > span,
700
+ .checkbox > span,
701
+ .tab,
702
+ .badgeLabel,
703
+ .gradeQ,
704
+ .gradeAns,
705
+ .gradeRight,
706
+ .gradeExp,
707
+ .quizQuestion,
708
+ .quizFeedback,
709
+ .quizExplanation { white-space: pre-line; }
631
710
 
632
711
  /* sortable header button (v2.9) */
633
712
  .thSort {
@@ -1063,11 +1142,17 @@ img.mediaPlayer { aspect-ratio: 16 / 9; object-fit: cover; background: var(--dsl
1063
1142
  /* Master-detail rows (table.details): a chevron in the first cell opens a
1064
1143
  full-width panel underneath, so a table can drill down without leaving the
1065
1144
  page or opening a modal. */
1066
- .detailCell { padding-left: var(--dsl-g-gap-sm); }
1145
+ .detailCell { position: relative; padding-left: var(--dsl-g-gap-sm); }
1067
1146
  .detailToggle {
1147
+ position: relative;
1148
+ z-index: 1;
1068
1149
  display: inline-flex;
1069
1150
  align-items: center;
1070
- gap: 6px;
1151
+ justify-content: center;
1152
+ vertical-align: middle;
1153
+ width: 24px;
1154
+ height: 24px;
1155
+ margin-right: 4px;
1071
1156
  padding: 0;
1072
1157
  border: none;
1073
1158
  background: none;
@@ -1078,7 +1163,9 @@ img.mediaPlayer { aspect-ratio: 16 / 9; object-fit: cover; background: var(--dsl
1078
1163
  text-align: left;
1079
1164
  }
1080
1165
  .detailToggle:hover { color: var(--dsl-g-accent); }
1166
+ .detailCell:hover .detailToggle { color: var(--dsl-g-accent); }
1081
1167
  .detailToggle:focus-visible { outline: 2px solid var(--dsl-g-accent); outline-offset: 2px; border-radius: 4px; }
1168
+ .detailContent { position: relative; z-index: 1; vertical-align: middle; cursor: pointer; }
1082
1169
  .detailChevron {
1083
1170
  flex-shrink: 0;
1084
1171
  font-size: 10px;
@@ -1118,10 +1205,17 @@ img.mediaPlayer { aspect-ratio: 16 / 9; object-fit: cover; background: var(--dsl
1118
1205
  .table tbody tr.groupRow:first-child td { box-shadow: none; padding-top: 10px; }
1119
1206
  .table tbody tr.groupRow:hover { background: none; }
1120
1207
  .table tbody tr.groupRow:nth-child(even) { background: none; }
1208
+ .groupRow td { position: relative; }
1121
1209
  .groupToggle {
1210
+ position: relative;
1211
+ z-index: 1;
1122
1212
  display: inline-flex;
1123
1213
  align-items: center;
1124
- gap: 7px;
1214
+ justify-content: center;
1215
+ vertical-align: middle;
1216
+ width: 24px;
1217
+ height: 24px;
1218
+ margin-right: 7px;
1125
1219
  padding: 0;
1126
1220
  border: none;
1127
1221
  background: none;
@@ -1133,7 +1227,9 @@ img.mediaPlayer { aspect-ratio: 16 / 9; object-fit: cover; background: var(--dsl
1133
1227
  cursor: pointer;
1134
1228
  }
1135
1229
  .groupToggle:hover { color: var(--dsl-g-accent); }
1230
+ .groupLabel:hover { color: var(--dsl-g-accent); }
1136
1231
  .groupToggle:focus-visible { outline: 2px solid var(--dsl-g-accent); outline-offset: 2px; border-radius: 4px; }
1232
+ .groupLabel { position: relative; z-index: 1; display: inline-flex; vertical-align: middle; align-items: center; gap: 7px; cursor: pointer; }
1137
1233
  .groupChevron { color: var(--dsw-alias-label-tertiary); font-size: 11px; width: 10px; }
1138
1234
  .groupCount {
1139
1235
  padding: 0 7px;
@@ -13,8 +13,9 @@ import css from './GenuiBlock.module.css'
13
13
  import { loadBlockState, saveBlockState } from './interaction-store.ts'
14
14
  import { recordFence, recordInteraction } from './achievement-store.ts'
15
15
  import { renderNode } from './blocks/render-node.tsx'
16
- import type { AnswersState, GenuiBlockProps, QuestionMeta } from './blocks/state.ts'
16
+ import type { AnswersState, GenuiBlockProps } from './blocks/state.ts'
17
17
  import type { GenuiSpec } from './spec.ts'
18
+ import { compileSubmissionRegistry } from './submission-registry.ts'
18
19
 
19
20
  export const GENUI_ACTION_DEBOUNCE_MS = 300
20
21
 
@@ -96,7 +97,7 @@ function GenuiBlockInstance({ spec, stateKey, animateEntrance = true, initialSta
96
97
  const [answers, setAnswers] = useState<Record<string, string>>(persisted?.answers ?? {})
97
98
  const [multiAnswers, setMultiAnswers] = useState<Record<string, string[]>>(persisted?.multiAnswers ?? {})
98
99
  const [fields, setFields] = useState<Record<string, string>>(persisted?.fields ?? {})
99
- const [meta, setMeta] = useState<Record<string, QuestionMeta>>({})
100
+ const submissionRegistry = useMemo(() => compileSubmissionRegistry(spec), [spec])
100
101
  const [locked, setLocked] = useState(persisted?.locked === true)
101
102
  const [round, setRound] = useState(0)
102
103
  // Secret (password) field ids: their values never persist and never join
@@ -123,7 +124,7 @@ function GenuiBlockInstance({ spec, stateKey, animateEntrance = true, initialSta
123
124
  })
124
125
  }, [])
125
126
  const setField = useCallback((id: string, value: string) => {
126
- // Registry presence means "the user has touched this field" — a blank
127
+ // Field state presence means "the user has touched this field" — a blank
127
128
  // value is stored as '' instead of deleting the entry, so a user who
128
129
  // CLEARS a model-provided default does not get the default back on the
129
130
  // next mount. Blank values are still excluded from submit collection by
@@ -134,14 +135,6 @@ function GenuiBlockInstance({ spec, stateKey, animateEntrance = true, initialSta
134
135
  const registerSecretField = useCallback((id: string) => {
135
136
  setSecretFields(prev => (prev.has(id) ? prev : new Set(prev).add(id)))
136
137
  }, [])
137
- const registerMeta = useCallback((group: string, m: QuestionMeta) => {
138
- setMeta(prev => {
139
- const existing = prev[group]
140
- if (existing !== undefined && existing.label === m.label && existing.answer === m.answer
141
- && existing.explanation === m.explanation) return prev
142
- return { ...prev, [group]: m }
143
- })
144
- }, [])
145
138
  const clear = useCallback(() => {
146
139
  setAnswers({})
147
140
  setMultiAnswers({})
@@ -150,10 +143,10 @@ function GenuiBlockInstance({ spec, stateKey, animateEntrance = true, initialSta
150
143
  }, [])
151
144
  const answersState = useMemo<AnswersState>(
152
145
  () => ({
153
- answers, multiAnswers, fields, secretFields, meta, locked, round,
154
- setAnswer, setMultiAnswer, setField, registerSecretField, registerMeta, clear, setLocked,
146
+ answers, multiAnswers, fields, secretFields, registry: submissionRegistry, locked, round,
147
+ setAnswer, setMultiAnswer, setField, registerSecretField, clear, setLocked,
155
148
  }),
156
- [answers, multiAnswers, fields, secretFields, meta, locked, round, setAnswer, setMultiAnswer, setField, registerSecretField, registerMeta, clear],
149
+ [answers, multiAnswers, fields, secretFields, submissionRegistry, locked, round, setAnswer, setMultiAnswer, setField, registerSecretField, clear],
157
150
  )
158
151
  const durableState = useMemo(() => {
159
152
  const safeFields = Object.fromEntries(Object.entries(fields).filter(([id]) => !secretFields.has(id)))
@@ -8,7 +8,37 @@
8
8
  font: 12px/1.4 system-ui, sans-serif;
9
9
  }
10
10
 
11
- .trigger,
11
+ /* 导出入口:无边框图标按钮。形状照项目里的 chrome 图标按钮 .panelClose
12
+ (26px 命中区 / 6px 圆角 / 透明底 / 无边框)。 */
13
+ .trigger {
14
+ display: inline-flex;
15
+ align-items: center;
16
+ justify-content: center;
17
+ width: 26px;
18
+ height: 26px;
19
+ padding: 0;
20
+ border: 0;
21
+ border-radius: 6px;
22
+ background: transparent;
23
+ color: var(--dsw-alias-label-tertiary);
24
+ cursor: pointer;
25
+ }
26
+
27
+ /* 悬浮沿用区块内表格行 / .tab / .accHead 那条配方(--dsl-g-hover):主题文字色
28
+ 4% 淡洗 + 文字色升到 label-primary。不用 rgba(255,255,255,…) 与没带兜底的
29
+ --dsw-alias-fill-hover —— 浅色主题下两者都看不见。 */
30
+ .trigger:hover,
31
+ .trigger:focus-visible {
32
+ background: color-mix(in srgb, var(--dsw-alias-label-primary) 4%, transparent);
33
+ color: var(--dsw-alias-label-primary);
34
+ }
35
+
36
+ /* 聚焦轮廓同 .panelClose。 */
37
+ .trigger:focus-visible {
38
+ outline: 1px solid var(--dsw-alias-state-business-primary, #4f8ef7);
39
+ outline-offset: 1px;
40
+ }
41
+
12
42
  .item {
13
43
  border: 1px solid var(--dsw-alias-border-l1);
14
44
  border-radius: 7px;
@@ -18,9 +48,10 @@
18
48
  cursor: pointer;
19
49
  }
20
50
 
21
- .trigger:hover,
51
+ /* 菜单项沿用宿主的 fill-hover;第三方主题可能没有定义该 token(例如浅色
52
+ dream-skin),此时回退到区块内那条 4% 文字色淡洗,悬浮不会没有反馈。 */
22
53
  .item:hover {
23
- background: var(--dsw-alias-fill-hover);
54
+ background: var(--dsw-alias-fill-hover, color-mix(in srgb, var(--dsw-alias-label-primary) 4%, transparent));
24
55
  color: var(--dsw-alias-label-primary);
25
56
  }
26
57
 
@@ -13,6 +13,17 @@ interface ArtifactExportMenuProps {
13
13
  getArtifact: () => ReturnType<typeof createGenuiArtifact>
14
14
  }
15
15
 
16
+ /** 导出入口的图标字形:托盘 + 下箭头(“导出为文件”),14px 描边、随文字取色。 */
17
+ function ExportGlyph() {
18
+ return (
19
+ <svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
20
+ <path d="M8 2.2v7.1" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
21
+ <path d="M5 6.4 8 9.4l3-3" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" />
22
+ <path d="M2.9 10.9v1.5a1 1 0 0 0 1 1h8.2a1 1 0 0 0 1-1v-1.5" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
23
+ </svg>
24
+ )
25
+ }
26
+
16
27
  /** 提供 JSON 和 HTML 的本地化导出菜单。 */
17
28
  function ArtifactExportMenu({ getArtifact }: ArtifactExportMenuProps) {
18
29
  const t = useT()
@@ -65,8 +76,18 @@ function ArtifactExportMenu({ getArtifact }: ArtifactExportMenuProps) {
65
76
  <div className={css.chrome} ref={rootRef} data-genui-export>
66
77
  {report.externalMedia.length > 0 && <span className={css.notice}>{t('artifact.externalMediaNotice')}</span>}
67
78
  {errorMessage !== '' && <span className={css.statusError} aria-live="polite">{errorMessage}</span>}
68
- <button type="button" className={css.trigger} aria-haspopup="menu" aria-expanded={open} onClick={() => setOpen(value => !value)}>
69
- {t('artifact.export')}
79
+ {/* 无边框图标入口:完成态内容不再多出一个描边按钮。图标按钮的 aria-label
80
+ + title 组合与项目里的 panelTpl / panelToggle 一致。 */}
81
+ <button
82
+ type="button"
83
+ className={css.trigger}
84
+ aria-label={t('artifact.export')}
85
+ title={t('artifact.export')}
86
+ aria-haspopup="menu"
87
+ aria-expanded={open}
88
+ onClick={() => setOpen(value => !value)}
89
+ >
90
+ <ExportGlyph />
70
91
  </button>
71
92
  {open && (
72
93
  <div className={css.menu} role="menu">
@@ -20,6 +20,7 @@ import { t as tr, useT } from '../i18n/index.ts'
20
20
  import css from '../GenuiBlock.module.css'
21
21
  import { GENUI_LIMITS } from '../genui-runtime/index.ts'
22
22
  import type { GenuiChart, GenuiTable } from '../spec.ts'
23
+ import { isTableGroupHeaderRow, tableRowsForDetails } from '../table-details.ts'
23
24
 
24
25
  export const CHART_COLORS = [
25
26
  'var(--dsw-static-deepseek-400)',
@@ -85,6 +86,33 @@ function numericColumns(rows: GenuiTable['rows'], nCols: number): boolean[] {
85
86
  })
86
87
  }
87
88
 
89
+ /**
90
+ * Does this cell carry real line breaks? Table cells default to `nowrap` (the
91
+ * data voice), which also collapses the leading whitespace of a pasted code
92
+ * block — indentation is lost and the snippet no longer runs. Cells that do
93
+ * contain a line break get `pre-line` (see `.tdMultiline`), so the line
94
+ * structure survives and the text still copies back as multiple lines.
95
+ */
96
+ function hasLineBreak(value: string | number): boolean {
97
+ return typeof value === 'string' && /[\n\r]/.test(value)
98
+ }
99
+
100
+ /**
101
+ * Is this cell code rather than prose? Only then is leading indentation
102
+ * significant (`pre-wrap`), while a prose line break stays `pre-line` so its
103
+ * surrounding spaces collapse exactly like the rest of the UI.
104
+ */
105
+ function isCodeCell(value: string | number): boolean {
106
+ if (typeof value !== 'string') return false
107
+ return /(^|\n)[ \t]/.test(value) || value.includes('```')
108
+ }
109
+
110
+ /** The whitespace class a multi-line cell needs, or undefined for single-line. */
111
+ function cellWrapClass(value: string | number): string | undefined {
112
+ if (!hasLineBreak(value)) return undefined
113
+ return isCodeCell(value) ? css.tdCode : css.tdMultiline
114
+ }
115
+
88
116
  /** Signed cell text (`+12.4%`, `-3`, `−2.1k`) reads as a delta without any
89
117
  * new spec field — the renderer classifies the string. */
90
118
  function deltaTone(value: unknown): 'up' | 'down' | null {
@@ -206,9 +234,8 @@ export const TableNode = memo(function TableNode({ node, renderDetail, filterVal
206
234
  }) {
207
235
  useT()
208
236
  const columns = node.columns.slice(0, GENUI_LIMITS.maxTableCols)
209
- const rows = node.rows.slice(0, GENUI_LIMITS.maxTableRows)
237
+ const rows = tableRowsForDetails<GenuiTable['rows'][number]>(node)
210
238
  const types = node.types ?? []
211
- const groupMode = types[0] === 'group'
212
239
  const [sort, setSort] = useState<{ col: number; dir: 1 | -1 } | null>(null)
213
240
  const [collapsed, setCollapsed] = useState<ReadonlySet<number>>(() => new Set())
214
241
  const [expanded, setExpanded] = useState<ReadonlySet<number>>(() => new Set())
@@ -227,9 +254,7 @@ export const TableNode = memo(function TableNode({ node, renderDetail, filterVal
227
254
  // filled and every other cell empty opens a section. Data rows after it are
228
255
  // its CHILDREN — indented, counted, and collapsible — so the relationship is
229
256
  // unmistakable instead of "one more row at the same level".
230
- const isGroupRow = (row: GenuiTable['rows'][number]): boolean =>
231
- groupMode && String(row[0] ?? '').trim() !== ''
232
- && row.slice(1).every(cell => String(cell ?? '').trim() === '')
257
+ const isGroupRow = (row: GenuiTable['rows'][number]): boolean => isTableGroupHeaderRow(row, types)
233
258
 
234
259
  // Local filtering (bound control): the model ships the full data set once and
235
260
  // the reader narrows it live — no round trip, no re-generation.
@@ -308,13 +333,19 @@ export const TableNode = memo(function TableNode({ node, renderDetail, filterVal
308
333
 
309
334
  const renderCell = (cell: string | number, j: number, rowIndex: number): ReactNode => {
310
335
  const type = types[j]
336
+ const wrap = cellWrapClass(cell)
311
337
  const tone = type === 'delta'
312
338
  ? (String(cell).trim().startsWith('-') ? 'down' : 'up')
313
339
  : deltaTone(cell)
314
340
  return (
315
- <td key={j} className={numeric[j] || type === 'num' ? css.tdNum : undefined}>
341
+ <td
342
+ key={j}
343
+ className={[numeric[j] || type === 'num' ? css.tdNum : undefined, wrap]
344
+ .filter(part => part !== undefined)
345
+ .join(' ') || undefined}
346
+ >
316
347
  {type === 'badge'
317
- ? <span className={css.cellBadge}>{renderInline(String(cell), false)}</span>
348
+ ? <span className={css.cellBadge}>{renderInline(String(cell), 'file')}</span>
318
349
  : type === 'bar'
319
350
  ? <CellBar cell={cell} />
320
351
  : type === 'spark'
@@ -325,8 +356,8 @@ export const TableNode = memo(function TableNode({ node, renderDetail, filterVal
325
356
  ? <span className={css.cellIndex}>{rowIndex + 1}</span>
326
357
  : tone === null
327
358
  // Format displayed text without changing sorting or exported data.
328
- ? renderInline(String(cell), false)
329
- : <span className={`${css.tdDelta} ${tone === 'up' ? css.tdDeltaUp : css.tdDeltaDown}`}>{renderInline(String(cell), false)}</span>}
359
+ ? renderInline(String(cell), 'file')
360
+ : <span className={`${css.tdDelta} ${tone === 'up' ? css.tdDeltaUp : css.tdDeltaDown}`}>{renderInline(String(cell), 'file')}</span>}
330
361
  </td>
331
362
  )
332
363
  }
@@ -340,7 +371,9 @@ export const TableNode = memo(function TableNode({ node, renderDetail, filterVal
340
371
  {columns.map((c, i) => (
341
372
  <th
342
373
  key={i}
343
- className={numeric[i] ? css.thNum : undefined}
374
+ className={[numeric[i] ? css.thNum : undefined, cellWrapClass(c)]
375
+ .filter(part => part !== undefined)
376
+ .join(' ') || undefined}
344
377
  aria-sort={sort !== null && sort.col === i ? (sort.dir === 1 ? 'ascending' : 'descending') : 'none'}
345
378
  >
346
379
  <button type="button" className={css.thSort} onClick={() => clickHeader(i)}>
@@ -359,17 +392,25 @@ export const TableNode = memo(function TableNode({ node, renderDetail, filterVal
359
392
  <Fragment key={section.header === null ? `s-${si}` : `g-${section.header.index}`}>
360
393
  {section.header !== null && (
361
394
  <tr className={css.groupRow}>
362
- <td colSpan={columns.length}>
395
+ <td colSpan={columns.length} className={cellWrapClass(String(section.header.row[0]))}>
363
396
  <button
364
397
  type="button"
365
398
  className={css.groupToggle}
399
+ aria-label={tr(isCollapsed ? 'block.table.expandGroup' : 'block.table.collapseGroup', {
400
+ label: String(section.header.row[0]).replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'),
401
+ })}
366
402
  aria-expanded={!isCollapsed}
367
403
  onClick={() => toggleSection(headerIndex)}
368
404
  >
369
405
  <span className={css.groupChevron} aria-hidden>{isCollapsed ? '▸' : '▾'}</span>
370
- {renderInline(String(section.header.row[0]), false)}
371
- <span className={css.groupCount}>{section.children.length}</span>
372
406
  </button>
407
+ <span className={css.groupLabel} onClick={event => {
408
+ if (event.target instanceof Element && event.target.closest('button')) return
409
+ toggleSection(headerIndex)
410
+ }}>
411
+ {renderInline(String(section.header.row[0]), 'file')}
412
+ <span className={css.groupCount}>{section.children.length}</span>
413
+ </span>
373
414
  </td>
374
415
  </tr>
375
416
  )}
@@ -386,12 +427,20 @@ export const TableNode = memo(function TableNode({ node, renderDetail, filterVal
386
427
  <button
387
428
  type="button"
388
429
  className={css.detailToggle}
430
+ aria-label={tr(open ? 'block.table.collapseDetails' : 'block.table.expandDetails', {
431
+ label: String(cell).replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'),
432
+ })}
389
433
  aria-expanded={open}
390
434
  onClick={() => toggleDetail(child.index)}
391
435
  >
392
436
  <span className={css.detailChevron} data-open={open} aria-hidden>▸</span>
393
- {renderInline(String(cell), false)}
394
437
  </button>
438
+ <span className={css.detailContent} onClick={event => {
439
+ if (event.target instanceof Element && event.target.closest('button')) return
440
+ toggleDetail(child.index)
441
+ }}>
442
+ {renderInline(String(cell), 'file')}
443
+ </span>
395
444
  </td>
396
445
  )
397
446
  : renderCell(cell, j, child.index)
@@ -8,7 +8,9 @@ import { useEffect, useId, useRef, useState } from 'react'
8
8
  import css from '../GenuiBlock.module.css'
9
9
  import { GENUI_LIMITS } from '../genui-runtime/index.ts'
10
10
  import { useT } from '../i18n/index.ts'
11
- import type { AnswersState, GenuiBlockProps, QuestionMeta } from './state.ts'
11
+ import { resolveSubmitState } from '../submission-registry.ts'
12
+ import type { RadioSubmissionMember } from '../submission-registry.ts'
13
+ import type { AnswersState, GenuiBlockProps } from './state.ts'
12
14
  import type { GenuiInput, GenuiRadio, GenuiSelect, GenuiSlider, GenuiSubmit, GenuiSwitch, GenuiTextarea } from '../spec.ts'
13
15
 
14
16
  export function RadioNode({ node, onAction, answers }: {
@@ -22,7 +24,7 @@ export function RadioNode({ node, onAction, answers }: {
22
24
  const options = node.options.slice(0, GENUI_LIMITS.maxOptions)
23
25
  // No default selection unless the model explicitly sets `selected` — a
24
26
  // pre-checked first option silently swallows the user's "keep the default"
25
- // answer (the registry only records real change events). A DURABLE answer
27
+ // answer (the interaction state only records selected values). A DURABLE answer
26
28
  // (restored from localStorage) wins over both. The parent key includes the
27
29
  // reset round, so 重新作答 remounts this radio with a clean selection —
28
30
  // no sync effect needed.
@@ -32,17 +34,8 @@ export function RadioNode({ node, onAction, answers }: {
32
34
  const [selected, setSelected] = useState<number | null>(restoredIndex >= 0 ? restoredIndex : (node.selected ?? null))
33
35
  const uid = useId()
34
36
  const locked = grouped && answers?.locked === true
35
- // Register question metadata for local grading (mount + when the question
36
- // changes). `answers` is deliberately NOT a dep: the callback identity is
37
- // stable and re-registering on every answers update is needless churn.
38
37
  useEffect(() => {
39
38
  if (group === undefined) return
40
- answers?.registerMeta(group, {
41
- label: node.label ?? group,
42
- options,
43
- answer: node.answer,
44
- explanation: node.explanation,
45
- })
46
39
  // A model-provided default selection IS the answer — but only when the
47
40
  // group has no durable answer yet (a restored user choice must win).
48
41
  if (node.selected !== undefined && options[node.selected] !== undefined && answers?.answers[group] === undefined) {
@@ -77,16 +70,14 @@ export function RadioNode({ node, onAction, answers }: {
77
70
  )
78
71
  }
79
72
 
80
- /** Resolve a question's correct label from its metadata. */
81
- export function correctLabelOf(m: QuestionMeta): string | undefined {
73
+ /** 根据静态 radio member 获取正确选项的标签。 */
74
+ export function correctLabelOf(m: RadioSubmissionMember): string | undefined {
82
75
  if (m.answer === undefined) return undefined
83
76
  if (typeof m.answer === 'number') return m.options[m.answer]
84
77
  return m.answer
85
78
  }
86
79
 
87
- /** Submit: collect grouped radio and checkbox answers. Radio-only scopes keep
88
- * LOCAL-FIRST grading; when checkbox selections participate, the click falls
89
- * back to the aggregation action so multi-select data is never discarded. */
80
+ /** 汇总当前 block 的表单状态,并根据 resolver 的结果提交或本地判卷。 */
90
81
  export function SubmitNode({ node, onAction, answers }: {
91
82
  node: GenuiSubmit
92
83
  onAction?: GenuiBlockProps['onAction']
@@ -96,34 +87,21 @@ export function SubmitNode({ node, onAction, answers }: {
96
87
  const recorded = answers?.answers ?? {}
97
88
  const multiRecorded = answers?.multiAnswers ?? {}
98
89
  const fields = answers?.fields ?? {}
99
- const meta = answers?.meta ?? {}
100
90
  const expected = node.groups
101
91
  // One shared notion of "filled fields" for answered/ready/payload: non-blank
102
92
  // values only, secrets (password inputs) never collected into submit.
103
93
  const filledFields = Object.fromEntries(
104
94
  Object.entries(fields).filter(([id, v]) => v.trim() !== '' && !answers?.secretFields.has(id)),
105
95
  )
106
- const nonEmptyMultiGroups = Object.entries(multiRecorded)
107
- .filter(([, values]) => Array.isArray(values) && values.length > 0)
108
- .map(([group]) => group)
109
- const recordedGroups = new Set([...Object.keys(recorded), ...nonEmptyMultiGroups])
110
- const hasRecordedAnswer = (group: string): boolean =>
111
- recorded[group] !== undefined || (Array.isArray(multiRecorded[group]) && multiRecorded[group]!.length > 0)
112
-
113
- // Explicit groups require at least one selected checkbox (or a radio answer)
114
- // per group. Empty checkbox arrays remain durable state but are deliberately
115
- // not considered "answered".
116
- const answered = expected === undefined
117
- ? Math.max(recordedGroups.size, Object.keys(filledFields).length)
118
- : expected.filter(hasRecordedAnswer).length
119
- const total = expected?.length ?? answered
120
- const scope = expected ?? [...recordedGroups]
121
- const hasMultiInScope = scope.some(group =>
122
- Array.isArray(multiRecorded[group]) && multiRecorded[group]!.length > 0,
123
- )
124
- // Local grading must be radio-only. Otherwise grading would consume the
125
- // click and silently omit checkbox selections that need model delivery.
126
- const canGradeLocally = !hasMultiInScope && scope.some(group => meta[group]?.answer !== undefined)
96
+ const resolved = answers === undefined ? { scope: [], answered: 0, total: expected?.length ?? 0, localGradeEligible: false, hasOutOfScopePayload: false }
97
+ : resolveSubmitState({
98
+ registry: answers.registry,
99
+ ...(expected === undefined ? {} : { groups: expected }),
100
+ state: { answers: recorded, multiAnswers: multiRecorded, fields, secretFields: answers.secretFields },
101
+ })
102
+ const { scope, answered, total } = resolved
103
+ const canSendAction = node.action !== undefined && onAction !== undefined
104
+ const shouldGradeLocally = resolved.localGradeEligible && (!resolved.hasOutOfScopePayload || !canSendAction)
127
105
  const submitted = answers?.locked === true
128
106
  const collectedAnswers: Record<string, string | string[]> = {
129
107
  ...recorded,
@@ -133,12 +111,12 @@ export function SubmitNode({ node, onAction, answers }: {
133
111
  // grading, or a real action name + provider. A submit with neither is a
134
112
  // display-only control — honest disabled affordance.
135
113
  const ready = answered > 0 && answered >= total
136
- && (canGradeLocally || (node.action !== undefined && onAction !== undefined))
114
+ && (shouldGradeLocally || canSendAction)
137
115
 
138
116
  if (submitted) {
139
117
  // ── local grading result ──
140
- const graded = scope.filter(g => recorded[g] !== undefined && meta[g]?.answer !== undefined)
141
- const score = graded.filter(g => recorded[g] === correctLabelOf(meta[g]!)).length
118
+ const graded = scope.filter((member): member is RadioSubmissionMember => member.kind === 'radio' && recorded[member.key] !== undefined && member.answer !== undefined)
119
+ const score = graded.filter(member => recorded[member.key] === correctLabelOf(member)).length
142
120
  return (
143
121
  <div className={css.gradeWrap} data-genui-grade>
144
122
  <div className={css.gradeScore}>
@@ -146,14 +124,14 @@ export function SubmitNode({ node, onAction, answers }: {
146
124
  <span className={css.gradeScoreLabel}>{t('block.score')}{graded.length < scope.length ? t('block.scoreUngraded', { count: scope.length - graded.length }) : ''}</span>
147
125
  </div>
148
126
  <div className={css.gradeList}>
149
- {scope.map(g => {
150
- const entry = recorded[g]
151
- const m = meta[g]
152
- if (entry === undefined || m === undefined) return null
127
+ {scope.map(m => {
128
+ if (m.kind !== 'radio') return null
129
+ const entry = recorded[m.key]
130
+ if (entry === undefined) return null
153
131
  const correct = correctLabelOf(m)
154
132
  if (correct === undefined) {
155
133
  return (
156
- <div key={g} className={css.gradeItem}>
134
+ <div key={m.key} className={css.gradeItem}>
157
135
  <span className={css.gradeQ}>{renderInline(m.label)}</span>
158
136
  <span className={css.gradeAns}>{t('block.yourAnswer')}{renderInline(entry)}</span>
159
137
  </div>
@@ -161,7 +139,7 @@ export function SubmitNode({ node, onAction, answers }: {
161
139
  }
162
140
  const isCorrect = entry === correct
163
141
  return (
164
- <div key={g} className={`${css.gradeItem} ${isCorrect ? css.gradeItemOk : css.gradeItemNo}`}>
142
+ <div key={m.key} className={`${css.gradeItem} ${isCorrect ? css.gradeItemOk : css.gradeItemNo}`}>
165
143
  <span className={css.gradeQ}>{renderInline(m.label)}</span>
166
144
  <span className={css.gradeTag}>{isCorrect ? '✓' : '✗'}</span>
167
145
  <span className={css.gradeAns}>
@@ -196,7 +174,7 @@ export function SubmitNode({ node, onAction, answers }: {
196
174
  className={`${css.button} ${css.primary} ${css.submit}`}
197
175
  disabled={!ready}
198
176
  onClick={ready ? () => {
199
- if (canGradeLocally) {
177
+ if (shouldGradeLocally) {
200
178
  // Local grading: immediate in-place result, no model round trip.
201
179
  answers?.setLocked(true)
202
180
  } else if (node.action !== undefined && onAction !== undefined) {