@changfenhuang/dsh-genui 0.9.9 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +1 -1
  3. package/README.zh-CN.md +1 -1
  4. package/SKILL.md +128 -6
  5. package/lib/assets/echarts-core.js +43 -0
  6. package/lib/assets/{echarts.js → echarts-full.js} +1 -1
  7. package/lib/assets/mermaid.js +1 -1
  8. package/lib/client.js +5 -3
  9. package/lib/index.js +230 -69
  10. package/lib/types/client/EChartNode.d.ts +9 -0
  11. package/lib/types/client/GenuiBlock.d.ts +3 -4
  12. package/lib/types/client/asset-echarts-core.d.ts +1 -0
  13. package/lib/types/client/asset-echarts.d.ts +3 -2
  14. package/lib/types/client/asset-loader.d.ts +2 -2
  15. package/lib/types/client/blocks/advanced.d.ts +0 -3
  16. package/lib/types/client/blocks/charts.d.ts +23 -12
  17. package/lib/types/client/blocks/state.d.ts +5 -3
  18. package/lib/types/client/echarts-lazy.d.ts +4 -1
  19. package/lib/types/client/genui-runtime/schema.d.ts +11 -1
  20. package/lib/types/client/inline.d.ts +29 -0
  21. package/lib/types/client/spec.d.ts +76 -6
  22. package/lib/types/plugin/index.d.ts +1 -1
  23. package/package.json +4 -3
  24. package/src/client/EChartNode.tsx +233 -19
  25. package/src/client/GenuiBlock.module.css +895 -145
  26. package/src/client/GenuiBlock.tsx +31 -23
  27. package/src/client/PlotBlock.module.css +17 -1
  28. package/src/client/PlotBlock.tsx +33 -1
  29. package/src/client/asset-echarts-core.ts +39 -0
  30. package/src/client/asset-echarts.ts +4 -3
  31. package/src/client/asset-loader.ts +5 -3
  32. package/src/client/blocks/advanced.tsx +60 -16
  33. package/src/client/blocks/charts.tsx +761 -144
  34. package/src/client/blocks/render-node.tsx +252 -11
  35. package/src/client/blocks/state.ts +5 -3
  36. package/src/client/dom-fence.tsx +60 -6
  37. package/src/client/echarts-lazy.ts +11 -2
  38. package/src/client/fence-render.tsx +6 -5
  39. package/src/client/gallery.ts +78 -2
  40. package/src/client/genui-runtime/schema.ts +45 -11
  41. package/src/client/guard.ts +161 -23
  42. package/src/client/index.tsx +14 -3
  43. package/src/client/inline.ts +73 -0
  44. package/src/client/mermaid-core.ts +42 -5
  45. package/src/client/spec.ts +79 -6
  46. package/src/plugin/index.ts +33 -45
  47. package/src/plugin/tool.ts +5 -1
  48. package/tsdown.config.ts +2 -1
@@ -67,11 +67,9 @@ function specEquivalent(a: GenuiSpec, b: GenuiSpec): boolean {
67
67
  return JSON.stringify(a) === JSON.stringify(b)
68
68
  }
69
69
 
70
- /** Stateful implementation. Its mount lifetime is the interaction-state
71
- * lifetime: useState seeds durable data exactly once, and every later save
72
- * belongs to the same stateKey. The exported shell below owns the React key
73
- * that enforces this invariant for every caller. */
74
- function GenuiBlockInstance({ spec, stateKey }: GenuiBlockProps) {
70
+ /** Stateful implementation. Streaming state adopts its first durable key
71
+ * when the reply settles; switching an existing durable key starts fresh. */
72
+ function GenuiBlockInstance({ spec, stateKey, animateEntrance = true }: GenuiBlockProps) {
75
73
  const gap = spec.gap ?? 16
76
74
  const onAction = useDebouncedAction(useGenuiAction())
77
75
  // Grouped radios and grouped checkboxes record their local selections here;
@@ -109,16 +107,13 @@ function GenuiBlockInstance({ spec, stateKey }: GenuiBlockProps) {
109
107
  })
110
108
  }, [])
111
109
  const setField = useCallback((id: string, value: string) => {
112
- // Field invariant: a blank (trim-empty) value leaves the shared registry.
113
- setFields(prev => {
114
- if (value.trim() === '') {
115
- if (!(id in prev)) return prev
116
- const next = { ...prev }
117
- delete next[id]
118
- return next
119
- }
120
- return prev[id] === value ? prev : { ...prev, [id]: value }
121
- })
110
+ // Registry presence means "the user has touched this field" — a blank
111
+ // value is stored as '' instead of deleting the entry, so a user who
112
+ // CLEARS a model-provided default does not get the default back on the
113
+ // next mount. Blank values are still excluded from submit collection by
114
+ // SubmitNode's filledFields filter (and from persistence for secrets), so
115
+ // this does not change what the model receives.
116
+ setFields(prev => (prev[id] === value ? prev : { ...prev, [id]: value }))
122
117
  }, [])
123
118
  const registerSecretField = useCallback((id: string) => {
124
119
  setSecretFields(prev => (prev.has(id) ? prev : new Set(prev).add(id)))
@@ -187,7 +182,14 @@ function GenuiBlockInstance({ spec, stateKey }: GenuiBlockProps) {
187
182
  <div
188
183
  key={i}
189
184
  className={css.reveal}
190
- style={{ animationDelay: `${Math.min(i * 90, 720)}ms` }}
185
+ style={{
186
+ animationDelay: `${Math.min(i * 90, 720)}ms`,
187
+ ...(!animateEntrance || persisted !== null ? { animation: 'none' } : {}),
188
+ }}
189
+ onAnimationEnd={event => {
190
+ // Reattaching this DOM node must not replay its completed entrance.
191
+ if (event.target === event.currentTarget) event.currentTarget.style.animation = 'none'
192
+ }}
191
193
  >
192
194
  {renderNode(c, i, trackedAction, 0, answersState)}
193
195
  </div>
@@ -199,12 +201,18 @@ function GenuiBlockInstance({ spec, stateKey }: GenuiBlockProps) {
199
201
 
200
202
  /**
201
203
  * Render a GenUI spec as an inline block. `stateKey` is also the durable
202
- * component identity: changing it remounts the stateful implementation so
203
- * state loaded for one block can never leak into or be saved under another
204
- * key. Identity-less streaming renders deliberately share one stable
205
- * volatile instance, preserving local interaction state as the spec grows.
204
+ * component identity. A streaming instance adopts its first durable key so
205
+ * inputs and pending actions survive settling. Leaving an existing durable
206
+ * key remounts, keeping different blocks' interaction state isolated.
206
207
  */
207
208
  export const GenuiBlock = memo(function GenuiBlock(props: GenuiBlockProps) {
208
- const instanceKey = props.stateKey === undefined ? 'volatile' : `durable:${props.stateKey}`
209
- return <GenuiBlockInstance key={instanceKey} {...props} />
210
- }, (prev, next) => prev.stateKey === next.stateKey && specEquivalent(prev.spec, next.spec))
209
+ const [identity, setIdentity] = useState({ stateKey: props.stateKey, generation: 0 })
210
+ if (identity.stateKey !== props.stateKey) {
211
+ setIdentity({
212
+ stateKey: props.stateKey,
213
+ generation: identity.generation + (identity.stateKey === undefined ? 0 : 1),
214
+ })
215
+ }
216
+ return <GenuiBlockInstance key={identity.generation} {...props} />
217
+ }, (prev, next) => prev.stateKey === next.stateKey
218
+ && prev.animateEntrance === next.animateEntrance && specEquivalent(prev.spec, next.spec))
@@ -105,7 +105,23 @@
105
105
  font-family: var(--dsl-g-font-mono);
106
106
  }
107
107
 
108
- .surface { cursor: grab; touch-action: none; }
108
+ .surface { cursor: grab; touch-action: pan-y; }
109
+ /* The current x-domain only shows when it differs from the initial one, next
110
+ to the way back. Without this, an accidental zoom left the reader with a
111
+ domain nobody asked for and no visible affordance to restore it. */
112
+ .plotTools { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 0 0 4px; }
113
+ .plotHint { font-size: 12.5px; color: var(--dsw-alias-label-tertiary); font-variant-numeric: tabular-nums; }
114
+ .plotReset {
115
+ padding: 2px 9px;
116
+ border: 1px solid var(--dsw-alias-border-l2, rgba(255, 255, 255, 0.12));
117
+ border-radius: 999px;
118
+ background: transparent;
119
+ color: var(--dsw-alias-label-secondary);
120
+ font-family: inherit;
121
+ font-size: 12.5px;
122
+ cursor: pointer;
123
+ }
124
+ .plotReset:hover { color: var(--dsw-alias-label-primary); border-color: var(--dsw-alias-state-business-primary, #4f8ef7); }
109
125
  .surface:active { cursor: grabbing; }
110
126
 
111
127
  .sliders { display: flex; flex-direction: column; gap: var(--dsl-g-gap-sm); }
@@ -300,6 +300,9 @@ export const PlotBlock = memo(function PlotBlock({
300
300
  const onPointerMove = (e: React.PointerEvent): void => {
301
301
  const d = dragRef.current
302
302
  if (d === null) return
303
+ // A few pixels of slack: a plain click or a text-selection drag must not
304
+ // shift the domain.
305
+ if (Math.abs(e.clientX - d.startX) < 4) return
303
306
  const span = d.xMax - d.xMin
304
307
  const dx = ((d.startX - e.clientX) / plotW) * span
305
308
  setView(prev => ({ xMin: d.xMin + dx, xMax: d.xMax + dx, yMin: prev.yMin, yMax: prev.yMax }))
@@ -310,11 +313,18 @@ export const PlotBlock = memo(function PlotBlock({
310
313
  // onWheel as a passive listener, where preventDefault() is ignored and the
311
314
  // event bubbles up to the scroll container. Register natively with
312
315
  // { passive: false } instead — the same pattern as the 3D scene viewer.
316
+ // Wheel zoom requires a modifier (Cmd/Ctrl). Without one the wheel is left
317
+ // alone so the CONVERSATION scrolls: an unmodified wheel over the chart used
318
+ // to be swallowed by the plot, so scrolling the page silently rescaled the
319
+ // chart instead (a reader could end up looking at a domain nobody asked for,
320
+ // with no way back). The #63 requirement still holds for the modified case —
321
+ // a native non-passive listener keeps the event from reaching the host.
313
322
  const svgRef = useRef<SVGSVGElement | null>(null)
314
323
  useEffect(() => {
315
324
  const el = svgRef.current
316
325
  if (el === null) return
317
326
  const onWheel = (e: WheelEvent): void => {
327
+ if (!e.metaKey && !e.ctrlKey) return
318
328
  e.preventDefault()
319
329
  const span = xMax - xMin
320
330
  const factor = e.deltaY > 0 ? 1.15 : 1 / 1.15
@@ -326,13 +336,35 @@ export const PlotBlock = memo(function PlotBlock({
326
336
  }
327
337
  el.addEventListener('wheel', onWheel, { passive: false })
328
338
  return () => el.removeEventListener('wheel', onWheel)
329
- }, [xMin, xMax])
339
+ }, [xMin, xMax, fromX])
330
340
 
331
341
  const hasParams = series.some(s => (s.params?.length ?? 0) > 0)
332
342
 
343
+ // Zoom/pan move the x window; without a way back a reader who zoomed by
344
+ // accident was stuck with a domain nobody asked for. The header therefore
345
+ // shows the CURRENT domain whenever it differs from the initial one and
346
+ // offers a one-click return.
347
+ const initialX = useRef({ min: propXMin, max: propXMax })
348
+ const viewMoved = Math.abs(xMin - initialX.current.min) > 1e-6 || Math.abs(xMax - initialX.current.max) > 1e-6
349
+ const resetView = (): void => {
350
+ setView(prev => ({ ...prev, xMin: initialX.current.min, xMax: initialX.current.max }))
351
+ }
352
+
333
353
  return (
334
354
  <div className={css.block} data-genui-plot>
335
355
  {title !== undefined && <div className={css.title}>{title}</div>}
356
+ {hasData && hasValidRange && (
357
+ <div className={css.plotTools}>
358
+ <span className={css.plotHint}>
359
+ {viewMoved
360
+ ? `x ∈ [${formatTick(xMin)}, ${formatTick(xMax)}]`
361
+ : '⌘/Ctrl + 滚轮缩放 · 拖拽平移'}
362
+ </span>
363
+ {viewMoved && (
364
+ <button type="button" className={css.plotReset} onClick={resetView}>↺ 回到初始区间</button>
365
+ )}
366
+ </div>
367
+ )}
336
368
  {hasData && hasValidRange ? (
337
369
  <svg
338
370
  width="100%"
@@ -0,0 +1,39 @@
1
+ /**
2
+ * ECharts CORE asset bundle — the tree-shaken engine used by the common
3
+ * presets (bar / line / area / pie / scatter / bigline).
4
+ *
5
+ * Progressive disclosure for the engine: the full ECharts build is ~1 MB, and
6
+ * most answers only ever draw a bar or line chart. This bundle registers the
7
+ * same `createChart` surface with only the chart types and components those
8
+ * presets touch, so a basic chart downloads a fraction of the payload; the
9
+ * full engine (`echarts-full.js`) is fetched only when a spec needs a type
10
+ * outside this set or ships a raw `option`.
11
+ * @module @changfenhuang/dsh-genui/client/asset-echarts-core
12
+ */
13
+ import * as echarts from 'echarts/core'
14
+ import { BarChart, LineChart, PieChart, ScatterChart } from 'echarts/charts'
15
+ import {
16
+ DataZoomComponent, DatasetComponent, GridComponent, LegendComponent, MarkAreaComponent,
17
+ MarkLineComponent, MarkPointComponent, TitleComponent, TooltipComponent, TransformComponent,
18
+ } from 'echarts/components'
19
+ import { CanvasRenderer } from 'echarts/renderers'
20
+ import type { EChartsCoreOption, EChartsType } from 'echarts/core'
21
+
22
+ echarts.use([
23
+ BarChart, LineChart, PieChart, ScatterChart,
24
+ GridComponent, TooltipComponent, LegendComponent, TitleComponent,
25
+ DataZoomComponent, MarkLineComponent, MarkAreaComponent, MarkPointComponent,
26
+ DatasetComponent, TransformComponent,
27
+ CanvasRenderer,
28
+ ])
29
+
30
+ function createChart(el: HTMLElement, option: EChartsCoreOption, opts?: { height?: number }): EChartsType {
31
+ const initOpts = opts !== undefined && opts.height !== undefined ? { height: opts.height } : undefined
32
+ const instance = echarts.init(el, undefined, initOpts)
33
+ instance.setOption(option)
34
+ return instance
35
+ }
36
+
37
+ const win = globalThis as unknown as { __GenuiAssets__?: Record<string, unknown> }
38
+ const assets = win.__GenuiAssets__ ?? (win.__GenuiAssets__ = {})
39
+ assets.echartsCore = { createChart }
@@ -1,6 +1,7 @@
1
1
  /**
2
- * ECharts asset-bundle entry: registers the echarts engine on
3
- * `window.__GenuiAssets__.echarts`. Built as a standalone IIFE into
2
+ * ECharts FULL asset-bundle entry: the complete engine, fetched only when a
3
+ * spec needs a chart type outside the core set or ships a raw `option`.
4
+ * Registers on `window.__GenuiAssets__.echartsFull`. Built as a standalone IIFE into
4
5
  * `lib/assets/echarts.js` and served by the plugin's node-half route; loaded
5
6
  * on demand by echarts-lazy when a spec contains an `echart` node.
6
7
  * @module @changfenhuang/dsh-genui/client/asset-echarts
@@ -22,4 +23,4 @@ function createChart(el: HTMLElement, option: EChartsCoreOption, opts?: { height
22
23
 
23
24
  const win = globalThis as unknown as { __GenuiAssets__?: Record<string, unknown> }
24
25
  const assets = win.__GenuiAssets__ ?? (win.__GenuiAssets__ = {})
25
- assets.echarts = { createChart }
26
+ assets.echartsFull = { createChart }
@@ -49,10 +49,10 @@ const pending = new Map<string, Promise<Record<string, unknown>>>()
49
49
  * file: repeated requests (several mermaid nodes, re-renders) share one
50
50
  * script load; a failed load stays failed for the page (the component shows
51
51
  * its fallback).
52
- * @param name - 'mermaid', 'three', or 'echarts'.
52
+ * @param name - 'mermaid', 'three', 'echarts-core', or 'echarts-full'.
53
53
  * @returns the registered engine surface.
54
54
  */
55
- export function loadGenuiAsset<T>(name: 'mermaid' | 'three' | 'echarts'): Promise<T> {
55
+ export function loadGenuiAsset<T>(name: 'mermaid' | 'three' | 'echarts-core' | 'echarts-full'): Promise<T> {
56
56
  const file = `${name}.js`
57
57
  const existing = pending.get(file)
58
58
  if (existing !== undefined) return existing as Promise<T>
@@ -62,7 +62,9 @@ export function loadGenuiAsset<T>(name: 'mermaid' | 'three' | 'echarts'): Promis
62
62
  script.async = true
63
63
  script.onload = () => {
64
64
  const global = (window as unknown as AssetGlobal).__GenuiAssets__ ?? {}
65
- const api = global[name]
65
+ // Asset files are kebab-case; the keys they register are camelCase.
66
+ const key = name.replace(/-(\w)/g, (_m, ch: string) => ch.toUpperCase())
67
+ const api = global[key]
66
68
  if (api === undefined) {
67
69
  reject(new Error(`genui asset '${file}' loaded but registered no '${name}' engine`))
68
70
  return
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { memo, useEffect, useId, useMemo, useRef, useState, type ReactNode } from 'react'
8
8
  import { CodeBlock, DiffBlock, JsonTree, writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives'
9
+ import { renderInline } from '../inline.ts'
9
10
  import css from '../GenuiBlock.module.css'
10
11
  import { GENUI_LIMITS } from '../genui-runtime/index.ts'
11
12
  import { PlotBlock } from '../PlotBlock.tsx'
@@ -27,8 +28,8 @@ export const CalloutNode = memo(function CalloutNode({ node }: { node: GenuiCall
27
28
  const toneClass = CALLOUT_TONES[tone] ?? css.calloutInfo
28
29
  return (
29
30
  <div className={`${css.callout} ${toneClass}`} data-genui-callout>
30
- {node.title !== undefined && <div className={css.calloutTitle}>{node.title}</div>}
31
- <div className={css.calloutBody}>{node.content}</div>
31
+ {node.title !== undefined && <div className={css.calloutTitle}>{renderInline(node.title)}</div>}
32
+ <div className={css.calloutBody}>{renderInline(node.content)}</div>
32
33
  </div>
33
34
  )
34
35
  })
@@ -64,7 +65,7 @@ export const KeyValueNode = memo(function KeyValueNode({ node }: { node: GenuiKe
64
65
  {pairs.map((pair, i) => (
65
66
  <div key={i} className={css.kvRow}>
66
67
  <dt className={css.kvKey}>{pair.key}</dt>
67
- <dd className={css.kvValue}>{pair.value}</dd>
68
+ <dd className={css.kvValue}>{renderInline(pair.value)}</dd>
68
69
  </div>
69
70
  ))}
70
71
  </dl>
@@ -202,7 +203,7 @@ export function AccordionNode({ node, onAction, depth = 0, answers }: {
202
203
  onClick={() => setOpen(open === i ? null : i)}
203
204
  >
204
205
  <span className={css.accTitle}>{item.title}</span>
205
- <span className={css.accChevron}>{open === i ? '▾' : '▸'}</span>
206
+ <span className={css.accChevron} data-open={open === i} aria-hidden>▸</span>
206
207
  </button>
207
208
  {open === i && (
208
209
  <div className={css.accBody} id={`${uid}-body-${i}`} aria-labelledby={`${uid}-head-${i}`}>
@@ -356,6 +357,24 @@ export const TimelineNode = memo(function TimelineNode({ node }: { node: GenuiTi
356
357
  /** FileTree: indented tree of files and folders. Directory rows are LOCAL
357
358
  * collapsible (spec.ts promised "collapsible children"; this makes it true)
358
359
  * — click a dir to fold/unfold, default fully open. Zero model round trip. */
360
+ /** 14px folder / file glyphs: an emoji-free way to tell rows apart at a glance. */
361
+ function FolderGlyph() {
362
+ return (
363
+ <svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
364
+ <path d="M1.6 3.4h4.1l1.3 1.7h7.4v7.2a1 1 0 0 1-1 1H2.6a1 1 0 0 1-1-1z" fill="currentColor" opacity="0.85" />
365
+ </svg>
366
+ )
367
+ }
368
+
369
+ function FileGlyph() {
370
+ return (
371
+ <svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
372
+ <path d="M3.8 1.6h4.9L13 5.9v8.5a1 1 0 0 1-1 1H4.8a1 1 0 0 1-1-1z" fill="none" stroke="currentColor" strokeWidth="1.2" />
373
+ <path d="M8.7 1.7v4.3h4.2" fill="none" stroke="currentColor" strokeWidth="1.2" />
374
+ </svg>
375
+ )
376
+ }
377
+
359
378
  export const FileTreeNode = memo(function FileTreeNode({ node }: { node: GenuiFileTree }) {
360
379
  const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set())
361
380
  const toggle = (k: string): void => {
@@ -366,22 +385,43 @@ export const FileTreeNode = memo(function FileTreeNode({ node }: { node: GenuiFi
366
385
  return next
367
386
  })
368
387
  }
388
+ // Children live in their OWN container under the row. They used to be
389
+ // appended inside `.ftRow` (display: flex), so a nested tree rendered
390
+ // side-by-side on one line instead of stacking.
369
391
  const renderNode = (n: GenuiFileTreeNode, depth: number, path: string): ReactNode => {
370
392
  if (depth > GENUI_LIMITS.maxTreeDepth) return null
371
393
  const isDir = n.type === 'dir' || (n.children !== undefined && n.children.length > 0)
372
394
  const folded = isDir && collapsed.has(path)
373
395
  return (
374
- <div key={path} className={css.ftRow} style={{ paddingLeft: `${depth * 16}px` }}>
375
- <button
376
- type="button"
377
- className={css.ftNameBtn}
378
- aria-expanded={isDir ? !folded : undefined}
379
- onClick={isDir ? () => toggle(path) : undefined}
380
- >
381
- <span className={`${css.ftIcon} ${isDir ? css.ftIconDir : ''}`} aria-hidden>{isDir ? (folded ? '▸' : '▾') : '·'}</span>
382
- <span className={`${css.ftName} ${isDir ? css.ftDir : ''}`}>{n.name}</span>
383
- </button>
384
- {isDir && !folded && (n.children ?? []).map((c, ci) => renderNode(c, depth + 1, `${path}/${ci}`))}
396
+ <div key={path}>
397
+ <div className={css.ftRow} style={{ paddingLeft: `${depth * 14 + 4}px` }}>
398
+ {Array.from({ length: depth }, (_v, i) => (
399
+ <span key={i} className={css.ftGuide} style={{ left: `${4 + i * 14 + 6}px` }} aria-hidden />
400
+ ))}
401
+ <button
402
+ type="button"
403
+ className={css.ftNameBtn}
404
+ aria-expanded={isDir ? !folded : undefined}
405
+ onClick={isDir ? () => toggle(path) : undefined}
406
+ >
407
+ <span
408
+ className={css.ftChevron}
409
+ data-open={isDir ? String(!folded) : undefined}
410
+ aria-hidden
411
+ >
412
+ {isDir ? '▸' : ''}
413
+ </span>
414
+ <span className={`${css.ftGlyph} ${isDir ? css.ftGlyphDir : ''}`} aria-hidden>
415
+ {isDir ? <FolderGlyph /> : <FileGlyph />}
416
+ </span>
417
+ <span className={`${css.ftName} ${isDir ? css.ftDir : ''}`}>{n.name}</span>
418
+ </button>
419
+ </div>
420
+ {isDir && !folded && (
421
+ <div className={css.ftChildren}>
422
+ {(n.children ?? []).map((c, ci) => renderNode(c, depth + 1, `${path}/${ci}`))}
423
+ </div>
424
+ )}
385
425
  </div>
386
426
  )
387
427
  }
@@ -432,7 +472,11 @@ export const QuizNode = memo(function QuizNode({ node, onAction }: {
432
472
  }
433
473
  }}
434
474
  >
435
- <span className={css.quizMarker}>{answered && (opt.correct === true ? '✓' : isChosen ? '✗' : '')}</span>
475
+ {/* Before answering: a radio affordance so the row reads as a
476
+ choice. After: ✓ on the right answer, ✗ on a wrong pick. */}
477
+ <span className={css.quizMarker}>
478
+ {answered ? (opt.correct === true ? '✓' : isChosen ? '✗' : '') : isChosen ? '●' : '○'}
479
+ </span>
436
480
  {opt.label}
437
481
  </button>
438
482
  )