@changfenhuang/dsh-genui 0.9.1

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 (106) hide show
  1. package/CHANGELOG.md +367 -0
  2. package/LICENSE +21 -0
  3. package/README.md +261 -0
  4. package/README.zh-CN.md +261 -0
  5. package/SKILL.md +115 -0
  6. package/cordis.patch.yml +9 -0
  7. package/demo-prompts.md +118 -0
  8. package/lib/assets/echarts.js +64 -0
  9. package/lib/assets/mermaid.js +3591 -0
  10. package/lib/assets/three.js +3844 -0
  11. package/lib/client.js +5 -0
  12. package/lib/index.js +1895 -0
  13. package/lib/invariant.js +19 -0
  14. package/lib/types/client/EChartNode.d.ts +4 -0
  15. package/lib/types/client/ErrorBoundary.d.ts +30 -0
  16. package/lib/types/client/GenuiBlock.d.ts +7 -0
  17. package/lib/types/client/PlotBlock.d.ts +44 -0
  18. package/lib/types/client/action-context.d.ts +7 -0
  19. package/lib/types/client/asset-echarts.d.ts +15 -0
  20. package/lib/types/client/asset-loader.d.ts +31 -0
  21. package/lib/types/client/asset-mermaid.d.ts +1 -0
  22. package/lib/types/client/asset-three.d.ts +1 -0
  23. package/lib/types/client/blocks/advanced.d.ts +104 -0
  24. package/lib/types/client/blocks/basic.d.ts +27 -0
  25. package/lib/types/client/blocks/charts.d.ts +38 -0
  26. package/lib/types/client/blocks/diagram/geometry.d.ts +73 -0
  27. package/lib/types/client/blocks/diagram/index.d.ts +5 -0
  28. package/lib/types/client/blocks/diagram/layout.d.ts +41 -0
  29. package/lib/types/client/blocks/diagram/theme.d.ts +35 -0
  30. package/lib/types/client/blocks/forms.d.ts +83 -0
  31. package/lib/types/client/blocks/render-node.d.ts +10 -0
  32. package/lib/types/client/blocks/state.d.ts +58 -0
  33. package/lib/types/client/dom-fence.d.ts +14 -0
  34. package/lib/types/client/echarts-lazy.d.ts +17 -0
  35. package/lib/types/client/fence-render.d.ts +58 -0
  36. package/lib/types/client/gallery.d.ts +9 -0
  37. package/lib/types/client/guard.d.ts +126 -0
  38. package/lib/types/client/index.d.ts +36 -0
  39. package/lib/types/client/interaction-store.d.ts +43 -0
  40. package/lib/types/client/mermaid-core.d.ts +8 -0
  41. package/lib/types/client/mermaid-lazy.d.ts +9 -0
  42. package/lib/types/client/mermaid-safe.d.ts +52 -0
  43. package/lib/types/client/panel-command.d.ts +29 -0
  44. package/lib/types/client/panel-store.d.ts +94 -0
  45. package/lib/types/client/panel.d.ts +15 -0
  46. package/lib/types/client/parse-partial.d.ts +37 -0
  47. package/lib/types/client/safe-math.d.ts +43 -0
  48. package/lib/types/client/scene3d-core.d.ts +21 -0
  49. package/lib/types/client/scene3d-lazy.d.ts +8 -0
  50. package/lib/types/client/spec.d.ts +610 -0
  51. package/lib/types/client/toolview.d.ts +7 -0
  52. package/lib/types/index.d.ts +1 -0
  53. package/lib/types/plugin/index.d.ts +25 -0
  54. package/lib/types/plugin/invariant.d.ts +15 -0
  55. package/lib/types/plugin/tool.d.ts +27 -0
  56. package/lib/types/shared/fence-repair.d.ts +67 -0
  57. package/package.json +121 -0
  58. package/src/client/EChartNode.tsx +242 -0
  59. package/src/client/ErrorBoundary.tsx +66 -0
  60. package/src/client/GenuiBlock.module.css +1263 -0
  61. package/src/client/GenuiBlock.tsx +166 -0
  62. package/src/client/PlotBlock.module.css +155 -0
  63. package/src/client/PlotBlock.tsx +431 -0
  64. package/src/client/action-context.ts +32 -0
  65. package/src/client/asset-echarts.ts +25 -0
  66. package/src/client/asset-loader.ts +79 -0
  67. package/src/client/asset-mermaid.ts +13 -0
  68. package/src/client/asset-three.ts +12 -0
  69. package/src/client/blocks/advanced.tsx +435 -0
  70. package/src/client/blocks/basic.tsx +116 -0
  71. package/src/client/blocks/charts.tsx +319 -0
  72. package/src/client/blocks/diagram/geometry.ts +151 -0
  73. package/src/client/blocks/diagram/index.tsx +284 -0
  74. package/src/client/blocks/diagram/layout.ts +199 -0
  75. package/src/client/blocks/diagram/theme.ts +108 -0
  76. package/src/client/blocks/forms.tsx +523 -0
  77. package/src/client/blocks/render-node.tsx +260 -0
  78. package/src/client/blocks/state.ts +61 -0
  79. package/src/client/dom-fence.tsx +641 -0
  80. package/src/client/echarts-lazy.ts +35 -0
  81. package/src/client/fence-render.tsx +230 -0
  82. package/src/client/gallery.ts +128 -0
  83. package/src/client/guard.ts +1450 -0
  84. package/src/client/index.tsx +195 -0
  85. package/src/client/interaction-store.ts +122 -0
  86. package/src/client/mermaid-core.ts +121 -0
  87. package/src/client/mermaid-lazy.ts +31 -0
  88. package/src/client/mermaid-safe.ts +122 -0
  89. package/src/client/panel-command.ts +119 -0
  90. package/src/client/panel-store.ts +471 -0
  91. package/src/client/panel.tsx +185 -0
  92. package/src/client/parse-partial.ts +162 -0
  93. package/src/client/safe-math.ts +270 -0
  94. package/src/client/scene3d-core.ts +192 -0
  95. package/src/client/scene3d-lazy.ts +26 -0
  96. package/src/client/spec.ts +767 -0
  97. package/src/client/toolview.tsx +66 -0
  98. package/src/css-modules.d.ts +5 -0
  99. package/src/index.ts +12 -0
  100. package/src/plugin/index.ts +173 -0
  101. package/src/plugin/invariant.ts +30 -0
  102. package/src/plugin/node-shims.d.ts +31 -0
  103. package/src/plugin/tool.ts +330 -0
  104. package/src/shared/fence-repair.ts +244 -0
  105. package/tsconfig.json +35 -0
  106. package/tsdown.config.ts +193 -0
@@ -0,0 +1,523 @@
1
+ /**
2
+ * Form family: radio aggregation + submit grading, switch, slider, IME-safe
3
+ * input/select/textarea. All state flows through the shared AnswersState.
4
+ * @module @changfenhuang/dsh-genui/client/blocks/forms
5
+ */
6
+ import { useEffect, useId, useRef, useState } from 'react'
7
+ import css from '../GenuiBlock.module.css'
8
+ import { GENUI_LIMITS } from '../guard.ts'
9
+ import type { AnswersState, GenuiBlockProps, QuestionMeta } from './state.ts'
10
+ import type { GenuiInput, GenuiRadio, GenuiSelect, GenuiSlider, GenuiSubmit, GenuiSwitch, GenuiTextarea } from '../spec.ts'
11
+
12
+ export function RadioNode({ node, onAction, answers }: {
13
+ node: GenuiRadio
14
+ onAction?: GenuiBlockProps['onAction']
15
+ answers?: AnswersState | undefined
16
+ }) {
17
+ const action = node.action
18
+ const group = node.group
19
+ const grouped = group !== undefined
20
+ const options = node.options.slice(0, GENUI_LIMITS.maxOptions)
21
+ // No default selection unless the model explicitly sets `selected` — a
22
+ // pre-checked first option silently swallows the user's "keep the default"
23
+ // answer (the registry only records real change events). A DURABLE answer
24
+ // (restored from localStorage) wins over both. The parent key includes the
25
+ // reset round, so 重新作答 remounts this radio with a clean selection —
26
+ // no sync effect needed.
27
+ const restoredIndex = group !== undefined && answers?.answers[group] !== undefined
28
+ ? options.indexOf(answers!.answers[group]!)
29
+ : -1
30
+ const [selected, setSelected] = useState<number | null>(restoredIndex >= 0 ? restoredIndex : (node.selected ?? null))
31
+ const uid = useId()
32
+ const locked = grouped && answers?.locked === true
33
+ // Register question metadata for local grading (mount + when the question
34
+ // changes). `answers` is deliberately NOT a dep: the callback identity is
35
+ // stable and re-registering on every answers update is needless churn.
36
+ useEffect(() => {
37
+ if (group === undefined) return
38
+ answers?.registerMeta(group, {
39
+ label: node.label ?? group,
40
+ options,
41
+ answer: node.answer,
42
+ explanation: node.explanation,
43
+ })
44
+ // A model-provided default selection IS the answer — but only when the
45
+ // group has no durable answer yet (a restored user choice must win).
46
+ if (node.selected !== undefined && options[node.selected] !== undefined && answers?.answers[group] === undefined) {
47
+ answers?.setAnswer(group, options[node.selected]!)
48
+ }
49
+ // eslint-disable-next-line react-hooks/exhaustive-deps
50
+ }, [group, node.label, node.answer, node.explanation, node.options, node.selected])
51
+ return (
52
+ <div className={css.fieldGroup} role="radiogroup" aria-label={node.label}>
53
+ {node.label !== undefined && <span className={css.fieldLabel}>{node.label}</span>}
54
+ {options.map((opt, i) => (
55
+ <label key={i} className={css.radio}>
56
+ <input
57
+ type="radio"
58
+ name={`genui-radio-${uid}`}
59
+ checked={selected === i}
60
+ disabled={locked}
61
+ onChange={() => {
62
+ setSelected(i)
63
+ if (grouped) {
64
+ // Aggregation mode: record, do NOT round-trip per click.
65
+ answers?.setAnswer(group, opt)
66
+ } else if (action !== undefined && onAction !== undefined) {
67
+ onAction(action, { type: 'radio', value: opt })
68
+ }
69
+ }}
70
+ />
71
+ <span>{opt}</span>
72
+ </label>
73
+ ))}
74
+ </div>
75
+ )
76
+ }
77
+
78
+ /** Resolve a question's correct label from its metadata. */
79
+ export function correctLabelOf(m: QuestionMeta): string | undefined {
80
+ if (m.answer === undefined) return undefined
81
+ if (typeof m.answer === 'number') return m.options[m.answer]
82
+ return m.answer
83
+ }
84
+
85
+ /** Submit: the "交卷" control of a grouped-radio block. LOCAL-FIRST (v2.6):
86
+ * when at least one question carries `answer` data the click grades IN PLACE
87
+ * — score, per-question right/wrong, explanations — with zero model round
88
+ * trip, and locks the questions until 重新作答 resets them. Only when NO
89
+ * question has answers does it fall back to firing ONE action
90
+ * (`{type:'submit', answers, total, answered}`). Disabled until the
91
+ * selection criteria are met (all listed groups answered, or ≥1 answer
92
+ * without a group list); the hint shows the progress. */
93
+ export function SubmitNode({ node, onAction, answers }: {
94
+ node: GenuiSubmit
95
+ onAction?: GenuiBlockProps['onAction']
96
+ answers?: AnswersState | undefined
97
+ }) {
98
+ const recorded = answers?.answers ?? {}
99
+ const fields = answers?.fields ?? {}
100
+ const meta = answers?.meta ?? {}
101
+ const expected = node.groups
102
+ // One shared notion of "filled fields" for answered/ready/payload: non-blank
103
+ // values only, secrets (password inputs) never collected into submit.
104
+ const filledFields = Object.fromEntries(
105
+ Object.entries(fields).filter(([id, v]) => v.trim() !== '' && !answers?.secretFields.has(id)),
106
+ )
107
+ // Without an explicit group list, the submit counts radio answers AND
108
+ // filled fields — a fields-only form (inputs with id + submit) enables
109
+ // once any field has a value.
110
+ const answered = expected === undefined
111
+ ? Math.max(Object.keys(recorded).length, Object.keys(filledFields).length)
112
+ : expected.filter(g => recorded[g] !== undefined).length
113
+ const total = expected?.length ?? answered
114
+ const scope = expected ?? Object.keys(recorded)
115
+ // Local grading is possible when ANY in-scope question carries answers.
116
+ const canGradeLocally = scope.some(g => meta[g]?.answer !== undefined)
117
+ const submitted = answers?.locked === true
118
+ // Ready = enough answers AND the click can do something: either local
119
+ // grading, or a real action name + provider. A submit with neither is a
120
+ // display-only control — honest disabled affordance (action is optional:
121
+ // local grading needs no round trip).
122
+ const ready = answered > 0 && answered >= total
123
+ && (canGradeLocally || (node.action !== undefined && onAction !== undefined))
124
+
125
+ if (submitted) {
126
+ // ── local grading result ──
127
+ const graded = scope.filter(g => recorded[g] !== undefined && meta[g]?.answer !== undefined)
128
+ const score = graded.filter(g => recorded[g] === correctLabelOf(meta[g]!)).length
129
+ return (
130
+ <div className={css.gradeWrap} data-genui-grade>
131
+ <div className={css.gradeScore}>
132
+ <span className={css.gradeScoreValue}>{score} / {graded.length}</span>
133
+ <span className={css.gradeScoreLabel}>得分{graded.length < scope.length ? `(${scope.length - graded.length} 题无答案未计分)` : ''}</span>
134
+ </div>
135
+ <div className={css.gradeList}>
136
+ {scope.map(g => {
137
+ const entry = recorded[g]
138
+ const m = meta[g]
139
+ if (entry === undefined || m === undefined) return null
140
+ const correct = correctLabelOf(m)
141
+ if (correct === undefined) {
142
+ return (
143
+ <div key={g} className={css.gradeItem}>
144
+ <span className={css.gradeQ}>{m.label}</span>
145
+ <span className={css.gradeAns}>你的答案:{entry}</span>
146
+ </div>
147
+ )
148
+ }
149
+ const isCorrect = entry === correct
150
+ return (
151
+ <div key={g} className={`${css.gradeItem} ${isCorrect ? css.gradeItemOk : css.gradeItemNo}`}>
152
+ <span className={css.gradeQ}>{m.label}</span>
153
+ <span className={css.gradeTag}>{isCorrect ? '✓' : '✗'}</span>
154
+ <span className={css.gradeAns}>
155
+ 你的答案:{entry}
156
+ {!isCorrect && <span className={css.gradeRight}> 正确答案:{correct}</span>}
157
+ </span>
158
+ {m.explanation !== undefined && <span className={css.gradeExp}>{m.explanation}</span>}
159
+ </div>
160
+ )
161
+ })}
162
+ </div>
163
+ <button
164
+ type="button"
165
+ className={`${css.button} ${css.ghost} ${css.submit}`}
166
+ onClick={() => {
167
+ answers?.clear()
168
+ if (node.resetAction !== undefined && onAction !== undefined) {
169
+ onAction(node.resetAction, { type: 'submit-reset', groups: expected ?? Object.keys(recorded) })
170
+ }
171
+ }}
172
+ >
173
+ 重新作答
174
+ </button>
175
+ </div>
176
+ )
177
+ }
178
+
179
+ return (
180
+ <div className={css.submitRow}>
181
+ <button
182
+ type="button"
183
+ className={`${css.button} ${css.primary} ${css.submit}`}
184
+ disabled={!ready}
185
+ onClick={ready ? () => {
186
+ if (canGradeLocally) {
187
+ // Local grading: immediate in-place result, no model round trip.
188
+ answers?.setLocked(true)
189
+ } else if (node.action !== undefined && onAction !== undefined) {
190
+ // `ready` already guarantees this branch, but the narrow keeps
191
+ // the optional-action type honest.
192
+ onAction(node.action, {
193
+ type: 'submit',
194
+ answers: recorded,
195
+ ...(Object.keys(filledFields).length > 0 ? { fields: filledFields } : {}),
196
+ total,
197
+ answered,
198
+ })
199
+ }
200
+ } : undefined}
201
+ >
202
+ {node.label}
203
+ </button>
204
+ {total > 0 && <span className={css.submitHint} aria-live="polite">已选 {answered}/{total}</span>}
205
+ </div>
206
+ )
207
+ }
208
+
209
+ /** Switch: toggle with local state. */
210
+ export function SwitchNode({ node, onAction }: { node: GenuiSwitch; onAction?: GenuiBlockProps['onAction'] }) {
211
+ const [on, setOn] = useState(node.checked === true)
212
+ const action = node.action
213
+ return (
214
+ <label className={css.switchRow}>
215
+ <span className={css.switchLabel}>{node.label}</span>
216
+ <button
217
+ type="button"
218
+ role="switch"
219
+ aria-checked={on}
220
+ className={`${css.switch} ${on ? css.switchOn : ''}`}
221
+ onClick={() => {
222
+ const next = !on
223
+ setOn(next)
224
+ if (action !== undefined && onAction !== undefined) onAction(action, { type: 'switch', checked: next })
225
+ }}
226
+ >
227
+ <span className={css.switchKnob} />
228
+ </button>
229
+ </label>
230
+ )
231
+ }
232
+
233
+ /** Slider: range input for numeric form values (v2.9). Field-aligned: with an
234
+ * `id` the value persists across refresh and joins the sibling submit's
235
+ * `fields` collection (stored as the numeric string); a model-provided
236
+ * default registers at mount; a restored durable value wins. Dragging fires
237
+ * the action (block-level debounce collapses the drag into one delivery). */
238
+ export function SliderNode({ node, onAction, answers }: {
239
+ node: GenuiSlider
240
+ onAction?: GenuiBlockProps['onAction']
241
+ answers?: AnswersState | undefined
242
+ }) {
243
+ const action = node.action
244
+ const id = node.id
245
+ const restored = id !== undefined && answers?.fields[id] !== undefined
246
+ ? Number(answers!.fields[id]!)
247
+ : NaN
248
+ const initial = Number.isFinite(restored) ? restored : node.value ?? node.min ?? 0
249
+ const [value, setValue] = useState<number>(initial)
250
+ const mounted = useRef(false)
251
+ useEffect(() => {
252
+ if (mounted.current) return
253
+ mounted.current = true
254
+ if (id !== undefined) answers?.setField(id, String(initial))
255
+ // eslint-disable-next-line react-hooks/exhaustive-deps
256
+ }, [])
257
+ const send = (v: number): void => {
258
+ if (action !== undefined && onAction !== undefined) {
259
+ onAction(action, { type: 'slider', value: v, ...(id !== undefined ? { id } : {}) })
260
+ }
261
+ }
262
+ return (
263
+ <label className={css.sliderRow}>
264
+ {node.label !== undefined && <span className={css.fieldLabel}>{node.label}</span>}
265
+ <input
266
+ type="range"
267
+ className={css.sliderInput}
268
+ min={node.min}
269
+ max={node.max}
270
+ step={node.step ?? 1}
271
+ value={value}
272
+ aria-label={node.label}
273
+ onChange={e => {
274
+ const v = Number(e.currentTarget.value)
275
+ setValue(v)
276
+ if (id !== undefined) answers?.setField(id, String(v))
277
+ send(v)
278
+ }}
279
+ />
280
+ <span className={css.sliderValue}>{Math.round(value * 100) / 100}</span>
281
+ </label>
282
+ )
283
+ }
284
+
285
+ /** Reuse the DSH main input's three-layer IME protection (verified in the
286
+ * host InputBar): composition start arms a ref, composition end clears it
287
+ * 10ms later (Safari sends the closing keydown BEFORE compositionend), and
288
+ * every submit keydown re-checks the ref, the native `isComposing` flag,
289
+ * and `keyCode === 229`. A Chinese selection Enter must never submit. */
290
+ export function useImeComposing(): {
291
+ isComposing: () => boolean
292
+ onCompositionStart: () => void
293
+ onCompositionEnd: () => void
294
+ } {
295
+ const composing = useRef(false)
296
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
297
+ useEffect(() => {
298
+ return () => {
299
+ if (timer.current !== null) clearTimeout(timer.current)
300
+ }
301
+ }, [])
302
+ return {
303
+ isComposing: () => composing.current,
304
+ onCompositionStart: () => {
305
+ composing.current = true
306
+ if (timer.current !== null) {
307
+ clearTimeout(timer.current)
308
+ timer.current = null
309
+ }
310
+ },
311
+ onCompositionEnd: () => {
312
+ if (timer.current !== null) clearTimeout(timer.current)
313
+ timer.current = setTimeout(() => {
314
+ composing.current = false
315
+ }, 10)
316
+ },
317
+ }
318
+ }
319
+
320
+ export function isImeSubmitKeydown(e: React.KeyboardEvent): boolean {
321
+ const native = e.nativeEvent
322
+ return native.isComposing === true || native.keyCode === 229
323
+ }
324
+
325
+ /** Select: single choice from a dropdown, field-aligned (v2.8). With an `id`
326
+ * the chosen option persists across refresh and joins the sibling submit's
327
+ * `fields` collection; a model-provided `selected` default registers at
328
+ * mount; a restored durable value wins over both. Without any default a
329
+ * placeholder option shows — nothing is silently pre-registered (same
330
+ * philosophy as radio). */
331
+ export function SelectNode({ node, onAction, answers }: {
332
+ node: GenuiSelect
333
+ onAction?: GenuiBlockProps['onAction']
334
+ answers?: AnswersState | undefined
335
+ }) {
336
+ const action = node.action
337
+ const id = node.id
338
+ const options = node.options.slice(0, GENUI_LIMITS.maxOptions)
339
+ const restored = id !== undefined && answers?.fields[id] !== undefined
340
+ ? options.indexOf(answers!.fields[id]!)
341
+ : -1
342
+ const defaultValue = restored >= 0
343
+ ? options[restored]!
344
+ : node.selected !== undefined && options[node.selected] !== undefined
345
+ ? options[node.selected]!
346
+ : null
347
+ const [value, setValue] = useState<string | null>(defaultValue)
348
+ // Field invariant: a spec-provided default registers at mount.
349
+ const mounted = useRef(false)
350
+ useEffect(() => {
351
+ if (mounted.current) return
352
+ mounted.current = true
353
+ if (id !== undefined && defaultValue !== null) {
354
+ answers?.setField(id, defaultValue)
355
+ }
356
+ // eslint-disable-next-line react-hooks/exhaustive-deps
357
+ }, [])
358
+ const send = (v: string): void => {
359
+ if (action !== undefined && onAction !== undefined) {
360
+ onAction(action, { type: 'select', value: v, ...(id !== undefined ? { id } : {}) })
361
+ }
362
+ }
363
+ return (
364
+ <label className={css.field}>
365
+ {node.label !== undefined && <span>{node.label}</span>}
366
+ <select
367
+ className={css.select}
368
+ value={value ?? ''}
369
+ onChange={e => {
370
+ const v = e.currentTarget.value
371
+ setValue(v)
372
+ if (id !== undefined) answers?.setField(id, v)
373
+ send(v)
374
+ }}
375
+ >
376
+ {value === null && <option value="" hidden disabled>请选择…</option>}
377
+ {options.map((o, i) => <option key={i} value={o}>{o}</option>)}
378
+ </select>
379
+ </label>
380
+ )
381
+ }
382
+
383
+ /** Input: single-line field. Controlled (value tracked for persistence and
384
+ * submit collection when `id` is set). With `action`: Enter submits
385
+ * immediately (`{type:'input', value, submit:true}`), blur sends too —
386
+ * the user never has to click elsewhere for the value to reach the model.
387
+ * Enter during IME composition never submits. `inputType: 'password'`
388
+ * stays masked; its value is never persisted and never joins submit
389
+ * collection (secrets stay out of localStorage), while its own `action`
390
+ * still delivers on explicit user submit. */
391
+ export function InputNode({ node, onAction, answers }: {
392
+ node: GenuiInput
393
+ onAction?: GenuiBlockProps['onAction']
394
+ answers?: AnswersState | undefined
395
+ }) {
396
+ const action = node.action
397
+ const id = node.id
398
+ const secret = node.inputType === 'password'
399
+ // Initial value: spec default, else durable state (restored after refresh).
400
+ // Secrets restore as blank: a password that survives a refresh would be a
401
+ // stored secret, which is exactly what the boundary forbids.
402
+ const [value, setValue] = useState<string>(() =>
403
+ secret ? '' : (node.value ?? (id !== undefined ? answers?.fields[id] ?? '' : '')))
404
+ // Last value actually DELIVERED to the model: blur only sends when the
405
+ // value changed since the last delivery (a focus-in/focus-out with no edit
406
+ // used to fire a pointless action round trip). Seeded with the mount value
407
+ // so the very first unedited blur also stays silent.
408
+ const lastSent = useRef<string | null>(value)
409
+ const send = (submit: boolean): void => {
410
+ if (action !== undefined && onAction !== undefined) {
411
+ lastSent.current = value
412
+ onAction(action, { type: 'input', value, ...(id !== undefined ? { id } : {}), ...(submit ? { submit: true } : {}) })
413
+ }
414
+ }
415
+ const ime = useImeComposing()
416
+ // Field invariant: a spec-provided non-blank default registers at mount.
417
+ const mounted = useRef(false)
418
+ useEffect(() => {
419
+ if (mounted.current) return
420
+ mounted.current = true
421
+ if (!secret && id !== undefined && node.value !== undefined && node.value.trim() !== '') {
422
+ answers?.setField(id, node.value)
423
+ }
424
+ // eslint-disable-next-line react-hooks/exhaustive-deps
425
+ }, [])
426
+ // Secret fields are filtered from persistence and submit collection.
427
+ useEffect(() => {
428
+ if (secret && id !== undefined) answers?.registerSecretField(id)
429
+ // eslint-disable-next-line react-hooks/exhaustive-deps
430
+ }, [secret, id])
431
+ return (
432
+ <label className={css.field}>
433
+ {node.label !== undefined && <span>{node.label}</span>}
434
+ <input
435
+ className={css.input}
436
+ type={node.inputType ?? 'text'}
437
+ placeholder={node.placeholder}
438
+ value={value}
439
+ onChange={e => {
440
+ const v = e.currentTarget.value
441
+ setValue(v)
442
+ if (id !== undefined) answers?.setField(id, v)
443
+ }}
444
+ onBlur={() => {
445
+ if (value !== lastSent.current) send(false)
446
+ }}
447
+ onCompositionStart={ime.onCompositionStart}
448
+ onCompositionEnd={ime.onCompositionEnd}
449
+ onKeyDown={e => {
450
+ if (e.key !== 'Enter') return
451
+ if (ime.isComposing() || isImeSubmitKeydown(e)) return
452
+ e.preventDefault()
453
+ send(true)
454
+ }}
455
+ />
456
+ </label>
457
+ )
458
+ }
459
+
460
+ /** Textarea: multi-line input; with `action`, blurring sends the value and
461
+ * Ctrl/Cmd+Enter submits immediately. Controlled when `id` is set (durable
462
+ * value + submit collection). Ctrl/Cmd+Enter during IME composition never
463
+ * submits. */
464
+ export function TextareaNode({ node, onAction, answers }: {
465
+ node: GenuiTextarea
466
+ onAction?: GenuiBlockProps['onAction']
467
+ answers?: AnswersState | undefined
468
+ }) {
469
+ const action = node.action
470
+ const id = node.id
471
+ const [value, setValue] = useState<string>(() =>
472
+ node.value ?? (id !== undefined ? answers?.fields[id] ?? '' : ''))
473
+ // Last value delivered to the model: blur sends only on change. Seeded
474
+ // with the mount value so an unedited blur stays silent.
475
+ const lastSent = useRef<string | null>(value)
476
+ const send = (submit: boolean): void => {
477
+ if (action !== undefined && onAction !== undefined) {
478
+ lastSent.current = value
479
+ onAction(action, { type: 'textarea', value, ...(id !== undefined ? { id } : {}), ...(submit ? { submit: true } : {}) })
480
+ }
481
+ }
482
+ const ime = useImeComposing()
483
+ // Field invariant: a spec-provided non-blank default registers at mount.
484
+ const mounted = useRef(false)
485
+ useEffect(() => {
486
+ if (mounted.current) return
487
+ mounted.current = true
488
+ if (id !== undefined && node.value !== undefined && node.value.trim() !== '') {
489
+ answers?.setField(id, node.value)
490
+ }
491
+ // eslint-disable-next-line react-hooks/exhaustive-deps
492
+ }, [])
493
+ return (
494
+ <label className={css.field}>
495
+ {node.label !== undefined && <span>{node.label}</span>}
496
+ <textarea
497
+ className={css.textarea}
498
+ placeholder={node.placeholder}
499
+ rows={node.rows ?? 4}
500
+ value={value}
501
+ onChange={e => {
502
+ const v = e.currentTarget.value
503
+ setValue(v)
504
+ if (id !== undefined) answers?.setField(id, v)
505
+ }}
506
+ onBlur={() => {
507
+ if (value !== lastSent.current) send(false)
508
+ }}
509
+ onCompositionStart={ime.onCompositionStart}
510
+ onCompositionEnd={ime.onCompositionEnd}
511
+ onKeyDown={e => {
512
+ if (!(e.metaKey || e.ctrlKey) || e.key !== 'Enter') return
513
+ if (ime.isComposing() || isImeSubmitKeydown(e)) return
514
+ e.preventDefault()
515
+ send(true)
516
+ }}
517
+ />
518
+ </label>
519
+ )
520
+ }
521
+
522
+ /** Accordion: collapsible sections with local open state. Headings and
523
+ * bodies are wired via useId (`aria-controls`/`aria-labelledby`). */