basalt-ui 1.10.0 → 1.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 (273) hide show
  1. package/AGENTS.md +21 -21
  2. package/CHANGELOG.md +25 -0
  3. package/README.md +36 -27
  4. package/agent/rules/basalt-content.md +16 -3
  5. package/dist/agent/adapter.d.ts +146 -0
  6. package/dist/agent/adapter.d.ts.map +1 -0
  7. package/dist/agent/adapter.js +473 -0
  8. package/dist/agent/adapter.test.d.ts +2 -0
  9. package/dist/agent/adapter.test.d.ts.map +1 -0
  10. package/dist/agent/ai-sdk-transport.d.ts +3 -52
  11. package/dist/agent/ai-sdk-transport.d.ts.map +1 -1
  12. package/dist/agent/ai-sdk-transport.js +108 -24
  13. package/dist/agent/ai-sdk-transport.test.d.ts +2 -0
  14. package/dist/agent/ai-sdk-transport.test.d.ts.map +1 -0
  15. package/dist/agent/coalesce.d.ts +42 -0
  16. package/dist/agent/coalesce.d.ts.map +1 -0
  17. package/dist/agent/coalesce.js +137 -0
  18. package/dist/agent/coalesce.test.d.ts +2 -0
  19. package/dist/agent/coalesce.test.d.ts.map +1 -0
  20. package/dist/agent/foreign.d.ts +97 -0
  21. package/dist/agent/foreign.d.ts.map +1 -0
  22. package/dist/agent/foreign.js +18 -0
  23. package/dist/agent/foreign.test.d.ts +2 -0
  24. package/dist/agent/foreign.test.d.ts.map +1 -0
  25. package/dist/agent/history.d.ts +2 -0
  26. package/dist/agent/history.d.ts.map +1 -1
  27. package/dist/agent/id.d.ts +65 -0
  28. package/dist/agent/id.d.ts.map +1 -0
  29. package/dist/agent/id.js +30 -0
  30. package/dist/agent/id.test.d.ts +2 -0
  31. package/dist/agent/id.test.d.ts.map +1 -0
  32. package/dist/agent/index.d.ts +11 -4
  33. package/dist/agent/index.d.ts.map +1 -1
  34. package/dist/agent/index.js +18 -3
  35. package/dist/agent/merge.d.ts +8 -0
  36. package/dist/agent/merge.d.ts.map +1 -0
  37. package/dist/agent/merge.js +35 -0
  38. package/dist/agent/merge.test.d.ts +2 -0
  39. package/dist/agent/merge.test.d.ts.map +1 -0
  40. package/dist/agent/part-list.d.ts +49 -34
  41. package/dist/agent/part-list.d.ts.map +1 -1
  42. package/dist/agent/part-list.js +53 -7
  43. package/dist/agent/part-list.test.d.ts +2 -0
  44. package/dist/agent/part-list.test.d.ts.map +1 -0
  45. package/dist/agent/parts.d.ts +135 -20
  46. package/dist/agent/parts.d.ts.map +1 -1
  47. package/dist/agent/parts.js +138 -11
  48. package/dist/agent/parts.test.d.ts +2 -0
  49. package/dist/agent/parts.test.d.ts.map +1 -0
  50. package/dist/agent/thread.d.ts +14 -0
  51. package/dist/agent/thread.d.ts.map +1 -1
  52. package/dist/agent/thread.js +8 -2
  53. package/dist/agent/thread.test.d.ts +2 -0
  54. package/dist/agent/thread.test.d.ts.map +1 -0
  55. package/dist/agent/transport.d.ts +39 -3
  56. package/dist/agent/transport.d.ts.map +1 -1
  57. package/dist/agent/transport.js +20 -17
  58. package/dist/agent/transport.test.d.ts +2 -0
  59. package/dist/agent/transport.test.d.ts.map +1 -0
  60. package/dist/agent/use-agent-stream.d.ts +2 -1
  61. package/dist/agent/use-agent-stream.d.ts.map +1 -1
  62. package/dist/agent/use-agent-stream.js +5 -2
  63. package/dist/agent/use-agent-stream.test.d.ts +2 -0
  64. package/dist/agent/use-agent-stream.test.d.ts.map +1 -0
  65. package/dist/agent/use-agent-thread-runs.d.ts +9 -3
  66. package/dist/agent/use-agent-thread-runs.d.ts.map +1 -1
  67. package/dist/agent/use-agent-thread-runs.foreign.test.d.ts +2 -0
  68. package/dist/agent/use-agent-thread-runs.foreign.test.d.ts.map +1 -0
  69. package/dist/agent/use-agent-thread-runs.js +75 -23
  70. package/dist/agent/use-agent-thread-runs.resume.test.d.ts +2 -0
  71. package/dist/agent/use-agent-thread-runs.resume.test.d.ts.map +1 -0
  72. package/dist/agent/use-agent-thread-runs.test.d.ts +2 -0
  73. package/dist/agent/use-agent-thread-runs.test.d.ts.map +1 -0
  74. package/dist/agent/use-agent-thread-runs.wedge.test.d.ts +2 -0
  75. package/dist/agent/use-agent-thread-runs.wedge.test.d.ts.map +1 -0
  76. package/dist/agent-chat/composer.d.ts +130 -8
  77. package/dist/agent-chat/composer.d.ts.map +1 -1
  78. package/dist/agent-chat/composer.js +189 -23
  79. package/dist/agent-chat/composer.test.d.ts +2 -0
  80. package/dist/agent-chat/composer.test.d.ts.map +1 -0
  81. package/dist/agent-chat/index.d.ts +3 -1
  82. package/dist/agent-chat/index.d.ts.map +1 -1
  83. package/dist/agent-chat/index.js +2 -0
  84. package/dist/agent-chat/thread-detail-panel.d.ts.map +1 -1
  85. package/dist/agent-chat/thread-detail-panel.js +1 -1
  86. package/dist/agent-chat/thread-message.d.ts +24 -7
  87. package/dist/agent-chat/thread-message.d.ts.map +1 -1
  88. package/dist/agent-chat/thread-message.js +115 -45
  89. package/dist/agent-chat/thread-message.test.d.ts +2 -0
  90. package/dist/agent-chat/thread-message.test.d.ts.map +1 -0
  91. package/dist/agent-chat/thread-workspace.js +1 -1
  92. package/dist/agent-chat/tool-chip.d.ts +21 -0
  93. package/dist/agent-chat/tool-chip.d.ts.map +1 -0
  94. package/dist/agent-chat/tool-chip.js +120 -0
  95. package/dist/agent-chat/tool-chip.test.d.ts +2 -0
  96. package/dist/agent-chat/tool-chip.test.d.ts.map +1 -0
  97. package/dist/charts/primitives/ChartFrame.test.d.ts +2 -0
  98. package/dist/charts/primitives/ChartFrame.test.d.ts.map +1 -0
  99. package/dist/charts/utils/domain.test.d.ts +2 -0
  100. package/dist/charts/utils/domain.test.d.ts.map +1 -0
  101. package/dist/cli/check-theme.test.d.ts +2 -0
  102. package/dist/cli/check-theme.test.d.ts.map +1 -0
  103. package/dist/cli/help.test.d.ts +2 -0
  104. package/dist/cli/help.test.d.ts.map +1 -0
  105. package/dist/cli/index.d.ts.map +1 -1
  106. package/dist/cli/init-sync.test.d.ts +2 -0
  107. package/dist/cli/init-sync.test.d.ts.map +1 -0
  108. package/dist/cli/placement-engine.test.d.ts +2 -0
  109. package/dist/cli/placement-engine.test.d.ts.map +1 -0
  110. package/dist/cli/tarball-parity.test.d.ts +2 -0
  111. package/dist/cli/tarball-parity.test.d.ts.map +1 -0
  112. package/dist/cli/template-format.test.d.ts +2 -0
  113. package/dist/cli/template-format.test.d.ts.map +1 -0
  114. package/dist/cli/template-ledger.test.d.ts +2 -0
  115. package/dist/cli/template-ledger.test.d.ts.map +1 -0
  116. package/dist/cli/tokens-css.test.d.ts +2 -0
  117. package/dist/cli/tokens-css.test.d.ts.map +1 -0
  118. package/dist/content/article-actions.test.d.ts +2 -0
  119. package/dist/content/article-actions.test.d.ts.map +1 -0
  120. package/dist/content/article-model.test.d.ts +2 -0
  121. package/dist/content/article-model.test.d.ts.map +1 -0
  122. package/dist/content/block-split.test.d.ts +2 -0
  123. package/dist/content/block-split.test.d.ts.map +1 -0
  124. package/dist/content/fence-block.d.ts +37 -10
  125. package/dist/content/fence-block.d.ts.map +1 -1
  126. package/dist/content/fence-block.js +80 -14
  127. package/dist/content/heading-components.d.ts.map +1 -1
  128. package/dist/content/heading-components.js +7 -4
  129. package/dist/content/heading-components.test.d.ts +2 -0
  130. package/dist/content/heading-components.test.d.ts.map +1 -0
  131. package/dist/content/index.d.ts +6 -3
  132. package/dist/content/index.d.ts.map +1 -1
  133. package/dist/content/index.js +5 -1
  134. package/dist/content/markdown.d.ts +101 -14
  135. package/dist/content/markdown.d.ts.map +1 -1
  136. package/dist/content/markdown.js +141 -28
  137. package/dist/content/markdown.test.d.ts +2 -0
  138. package/dist/content/markdown.test.d.ts.map +1 -0
  139. package/dist/content/prose.module.css.test.d.ts +2 -0
  140. package/dist/content/prose.module.css.test.d.ts.map +1 -0
  141. package/dist/content/sanitize.d.ts +170 -0
  142. package/dist/content/sanitize.d.ts.map +1 -0
  143. package/dist/content/sanitize.js +178 -0
  144. package/dist/content/sanitize.test.d.ts +2 -0
  145. package/dist/content/sanitize.test.d.ts.map +1 -0
  146. package/dist/content/slug.test.d.ts +2 -0
  147. package/dist/content/slug.test.d.ts.map +1 -0
  148. package/dist/dashboard/stat-card.test.d.ts +2 -0
  149. package/dist/dashboard/stat-card.test.d.ts.map +1 -0
  150. package/dist/guard/check-source.test.d.ts +2 -0
  151. package/dist/guard/check-source.test.d.ts.map +1 -0
  152. package/dist/guard/guard-hook.test.d.ts +2 -0
  153. package/dist/guard/guard-hook.test.d.ts.map +1 -0
  154. package/dist/guard/index.d.ts.map +1 -1
  155. package/dist/guard/index.js +1 -3
  156. package/dist/index.d.ts +4 -4
  157. package/dist/index.d.ts.map +1 -1
  158. package/dist/index.js +12 -2
  159. package/dist/provider/index.test.d.ts +2 -0
  160. package/dist/provider/index.test.d.ts.map +1 -0
  161. package/dist/provider/lab-theme.test.d.ts +2 -0
  162. package/dist/provider/lab-theme.test.d.ts.map +1 -0
  163. package/dist/router-tanstack/multi-search-param-store.test.d.ts +2 -0
  164. package/dist/router-tanstack/multi-search-param-store.test.d.ts.map +1 -0
  165. package/dist/styles.floor.test.d.ts +2 -0
  166. package/dist/styles.floor.test.d.ts.map +1 -0
  167. package/dist/surfaces.d.ts +5 -5
  168. package/dist/surfaces.d.ts.map +1 -1
  169. package/dist/surfaces.js +13 -4
  170. package/dist/theme/border-coverage.test.d.ts +18 -0
  171. package/dist/theme/border-coverage.test.d.ts.map +1 -0
  172. package/dist/theme/contrast.test.d.ts +2 -0
  173. package/dist/theme/contrast.test.d.ts.map +1 -0
  174. package/dist/theme/density-option.test.d.ts +2 -0
  175. package/dist/theme/density-option.test.d.ts.map +1 -0
  176. package/dist/theme/density-relations.test.d.ts +2 -0
  177. package/dist/theme/density-relations.test.d.ts.map +1 -0
  178. package/dist/theme/derive-theme.test.d.ts +2 -0
  179. package/dist/theme/derive-theme.test.d.ts.map +1 -0
  180. package/dist/theme/fonts-option.test.d.ts +2 -0
  181. package/dist/theme/fonts-option.test.d.ts.map +1 -0
  182. package/dist/theme/radius-option.test.d.ts +2 -0
  183. package/dist/theme/radius-option.test.d.ts.map +1 -0
  184. package/dist/theme/radius.test.d.ts +2 -0
  185. package/dist/theme/radius.test.d.ts.map +1 -0
  186. package/dist/theme/shadow-surfaces.test.d.ts +26 -0
  187. package/dist/theme/shadow-surfaces.test.d.ts.map +1 -0
  188. package/dist/theme/spacing.test.d.ts +2 -0
  189. package/dist/theme/spacing.test.d.ts.map +1 -0
  190. package/dist/theme/use-basalt-spacing.test.d.ts +2 -0
  191. package/dist/theme/use-basalt-spacing.test.d.ts.map +1 -0
  192. package/dist/theme-lab/derive-controls.test.d.ts +2 -0
  193. package/dist/theme-lab/derive-controls.test.d.ts.map +1 -0
  194. package/dist/theme-lab/derive-state.test.d.ts +2 -0
  195. package/dist/theme-lab/derive-state.test.d.ts.map +1 -0
  196. package/dist/theme-lab/theme-lab.test.d.ts +2 -0
  197. package/dist/theme-lab/theme-lab.test.d.ts.map +1 -0
  198. package/dist/tokens/build-fonts-css.test.d.ts +2 -0
  199. package/dist/tokens/build-fonts-css.test.d.ts.map +1 -0
  200. package/dist/tokens/build-radius-css.test.d.ts +2 -0
  201. package/dist/tokens/build-radius-css.test.d.ts.map +1 -0
  202. package/dist/tokens/density.test.d.ts +2 -0
  203. package/dist/tokens/density.test.d.ts.map +1 -0
  204. package/dist/tokens/derive-radius.test.d.ts +2 -0
  205. package/dist/tokens/derive-radius.test.d.ts.map +1 -0
  206. package/dist/tokens/derive.test.d.ts +2 -0
  207. package/dist/tokens/derive.test.d.ts.map +1 -0
  208. package/dist/tokens/palette.test.d.ts +2 -0
  209. package/dist/tokens/palette.test.d.ts.map +1 -0
  210. package/dist/vite-color-mix.test.d.ts +2 -0
  211. package/dist/vite-color-mix.test.d.ts.map +1 -0
  212. package/dist/vite.test.d.ts +2 -0
  213. package/dist/vite.test.d.ts.map +1 -0
  214. package/llms.txt +7 -7
  215. package/package.json +6 -1
  216. package/src/agent/adapter.test.ts +1403 -0
  217. package/src/agent/adapter.ts +887 -0
  218. package/src/agent/ai-sdk-transport.test.ts +380 -55
  219. package/src/agent/ai-sdk-transport.ts +206 -53
  220. package/src/agent/coalesce.test.ts +387 -0
  221. package/src/agent/coalesce.ts +229 -0
  222. package/src/agent/foreign.test.ts +51 -0
  223. package/src/agent/foreign.ts +140 -0
  224. package/src/agent/history.ts +2 -0
  225. package/src/agent/id.test.ts +75 -0
  226. package/src/agent/id.ts +91 -0
  227. package/src/agent/index.ts +34 -3
  228. package/src/agent/merge.test.ts +206 -0
  229. package/src/agent/merge.ts +114 -0
  230. package/src/agent/part-list.test.tsx +112 -0
  231. package/src/agent/part-list.tsx +108 -35
  232. package/src/agent/parts.test.ts +516 -57
  233. package/src/agent/parts.ts +323 -32
  234. package/src/agent/thread.test.ts +75 -2
  235. package/src/agent/thread.ts +21 -1
  236. package/src/agent/transport.test.ts +70 -0
  237. package/src/agent/transport.ts +72 -20
  238. package/src/agent/use-agent-stream.test.tsx +53 -19
  239. package/src/agent/use-agent-stream.ts +13 -3
  240. package/src/agent/use-agent-thread-runs.foreign.test.tsx +138 -0
  241. package/src/agent/use-agent-thread-runs.resume.test.tsx +44 -11
  242. package/src/agent/use-agent-thread-runs.test.tsx +418 -22
  243. package/src/agent/use-agent-thread-runs.ts +170 -26
  244. package/src/agent/use-agent-thread-runs.wedge.test.tsx +15 -7
  245. package/src/agent-chat/composer.test.tsx +924 -0
  246. package/src/agent-chat/composer.tsx +521 -52
  247. package/src/agent-chat/index.ts +5 -1
  248. package/src/agent-chat/thread-detail-panel.tsx +3 -1
  249. package/src/agent-chat/thread-message.test.tsx +351 -0
  250. package/src/agent-chat/thread-message.tsx +262 -64
  251. package/src/agent-chat/thread-workspace.tsx +1 -1
  252. package/src/agent-chat/tool-chip.test.tsx +139 -0
  253. package/src/agent-chat/tool-chip.tsx +219 -0
  254. package/src/cli/index.ts +0 -1
  255. package/src/cli/template-format.test.ts +2 -2
  256. package/src/content/fence-block.tsx +242 -15
  257. package/src/content/heading-components.test.tsx +65 -0
  258. package/src/content/heading-components.tsx +26 -5
  259. package/src/content/index.ts +19 -3
  260. package/src/content/markdown.test.tsx +603 -0
  261. package/src/content/markdown.tsx +355 -35
  262. package/src/content/sanitize.test.ts +659 -0
  263. package/src/content/sanitize.ts +510 -0
  264. package/src/dashboard/stat-card.test.tsx +6 -1
  265. package/src/guard/check-source.test.ts +8 -4
  266. package/src/guard/index.ts +1 -3
  267. package/src/index.ts +15 -1
  268. package/src/surfaces.ts +13 -4
  269. package/src/theme/contrast.test.ts +1 -4
  270. package/src/theme/density-relations.test.ts +4 -2
  271. package/src/theme/fonts-option.test.ts +7 -1
  272. package/src/theme/shadow-surfaces.test.ts +12 -6
  273. package/src/theme/use-basalt-spacing.test.tsx +2 -1
@@ -0,0 +1,219 @@
1
+ /**
2
+ * ToolChip — a one-line collapsed row for a tool-call part, expanding to its input/output JSON.
3
+ *
4
+ * Replaces `thread-message.tsx`'s old always-expanded `ToolRenderer`, which unconditionally
5
+ * rendered both input and output `<pre>` blocks. Collapsed, it shows a mono micro-label, the tool
6
+ * name, a state dot, and `durationMs` when present; expanded, it shows whatever the part's state
7
+ * actually carries — input, output, the surviving `rawInput` for a call whose input never
8
+ * validated, the error text, the denial reason, or the approve/deny affordances.
9
+ *
10
+ * @example
11
+ * import { ToolChip } from 'basalt-ui/agent-chat'
12
+ *
13
+ * <ToolChip
14
+ * part={part}
15
+ * onApprove={(id) => api.runs.approve.post({ approvalId: id })}
16
+ * onDeny={(id, reason) => api.runs.deny.post({ approvalId: id, reason })}
17
+ * />
18
+ */
19
+ import { Box, Button, Collapse, Group, Stack, Text, UnstyledButton } from '@mantine/core'
20
+ import { useDisclosure } from '@mantine/hooks'
21
+ import type { JSX } from 'react'
22
+ import { isToolCallSettled } from '../agent'
23
+ import type { ToolCallPart } from '../agent'
24
+ import { alpha, VX } from '../tokens'
25
+
26
+ // Mirrors thread-message.tsx's micro-label/rail/code-block idiom (docs/DESIGN-SPEC.md §3/§5) —
27
+ // duplicated locally rather than imported, matching the existing part-list.tsx/thread-message.tsx
28
+ // precedent of each Mantine-facing renderer file owning its own copy of these style objects.
29
+ const MICRO_LABEL_STYLE = {
30
+ fontFamily: 'var(--basalt-font-mono)',
31
+ fontSize: VX.text.micro,
32
+ fontWeight: 500,
33
+ letterSpacing: '0.06em',
34
+ textTransform: 'uppercase',
35
+ color: VX.faint,
36
+ } as const
37
+ const RAIL_STYLE = {
38
+ borderLeft: `2px solid ${VX.divider}`,
39
+ paddingLeft: 'var(--vx-space-agent-rail-inset-x)',
40
+ } as const
41
+ const CODE_BLOCK_STYLE = {
42
+ margin: 0,
43
+ marginTop: 'var(--vx-space-agent-part-gap-top)',
44
+ fontFamily: 'var(--basalt-font-mono)',
45
+ backgroundColor: alpha(VX.ink, 0.06),
46
+ borderRadius: 'var(--vx-radius-card)',
47
+ padding: 'var(--vx-space-agent-code-inset)',
48
+ whiteSpace: 'pre-wrap',
49
+ overflowWrap: 'anywhere',
50
+ } as const
51
+
52
+ /** Per-state (label, dot color) — the exhaustive switch that drives the collapsed row. */
53
+ function stateMeta(part: ToolCallPart): { readonly label: string; readonly dotToken: string } {
54
+ switch (part.state) {
55
+ case 'input-streaming':
56
+ return { label: 'streaming', dotToken: VX.status.neutral }
57
+ case 'input-available':
58
+ // No 'running' state on the wire — 'input-available' IS the running state (input complete,
59
+ // no output yet). This label is a UI-only gloss on that wire state, not a new state.
60
+ return { label: 'running', dotToken: VX.status.neutral }
61
+ case 'approval-requested':
62
+ return { label: 'needs approval', dotToken: VX.status.warn }
63
+ case 'approval-responded':
64
+ return part.approval.approved
65
+ ? { label: 'approved', dotToken: VX.status.neutral }
66
+ : { label: 'declined', dotToken: VX.status.bad }
67
+ case 'output-available':
68
+ // preliminary vs final must be visually distinguishable — the label carries that here; the
69
+ // expanded output body below is identical either way.
70
+ return part.preliminary === true
71
+ ? { label: 'preliminary', dotToken: VX.status.warn }
72
+ : { label: 'done', dotToken: VX.status.good }
73
+ case 'output-error':
74
+ return { label: 'error', dotToken: VX.status.bad }
75
+ case 'output-denied':
76
+ return { label: 'denied', dotToken: VX.status.bad }
77
+ default: {
78
+ // `part` is `never` here ONLY at compile time — this switch is genuinely exhaustive today,
79
+ // so adding an eighth ToolCallPart state without a case above makes this assignment a tsc
80
+ // error, the same gate `assertNever` provides. But `part` at RUNTIME can be a value this
81
+ // build never declared: `createThreadsStore` persists to localStorage with no shape
82
+ // validation, and `edenTransport` trusts the wire with zero validation, so a 1.10.0-shaped
83
+ // flat tool part (`state` undefined) or a future/foreign `state` string both reach here.
84
+ // That must never THROW during render — mirrors ThreadTranscript's fallbackRenderer
85
+ // doctrine (unknown data renders inert, it doesn't blank the transcript) — so, unlike a
86
+ // genuinely-exhaustive switch elsewhere, this default returns a neutral descriptor instead
87
+ // of calling assertNever.
88
+ const exhaustive: never = part
89
+ void exhaustive
90
+ return { label: 'unknown', dotToken: VX.status.neutral }
91
+ }
92
+ }
93
+ }
94
+
95
+ function formatDuration(ms: number): string {
96
+ return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms)}ms`
97
+ }
98
+
99
+ export type ToolChipProps = {
100
+ readonly part: ToolCallPart
101
+ readonly defaultExpanded?: boolean
102
+ /** Rendered only in 'approval-requested'. Omit both to render a read-only chip. */
103
+ readonly onApprove?: (approvalId: string) => void
104
+ readonly onDeny?: (approvalId: string, reason?: string) => void
105
+ }
106
+
107
+ /**
108
+ * A collapsed-by-default row for a single tool-call part. All seven wire states render something
109
+ * sane; the switch above still gates an eighth (compile-time-unhandled) state as a tsc error via a
110
+ * `const _: never = part` assignment, but unlike a truly-closed switch, its `default` branch never
111
+ * throws at RUNTIME — a value from persisted/wire data that reaches here with a `state` this build
112
+ * doesn't recognize renders an inert 'unknown' chip instead of blanking the transcript.
113
+ *
114
+ * @example
115
+ * <ToolChip part={part} />
116
+ */
117
+ export function ToolChip({
118
+ part,
119
+ defaultExpanded = false,
120
+ onApprove,
121
+ onDeny,
122
+ }: ToolChipProps): JSX.Element {
123
+ const [expanded, { toggle }] = useDisclosure(defaultExpanded)
124
+ const { label, dotToken } = stateMeta(part)
125
+ const settled = isToolCallSettled(part)
126
+
127
+ const input = 'input' in part ? part.input : undefined
128
+ const rawInput = part.state === 'output-error' ? part.rawInput : undefined
129
+
130
+ return (
131
+ <Box style={RAIL_STYLE}>
132
+ <Stack gap={4}>
133
+ <UnstyledButton onClick={toggle}>
134
+ <Group gap={6} align="center" wrap="nowrap">
135
+ <Box
136
+ aria-hidden
137
+ style={{
138
+ width: 6,
139
+ height: 6,
140
+ flexShrink: 0,
141
+ borderRadius: '50%', // theme-allow: a 6px state dot is a circle, not a surface corner — no radius token expresses it
142
+ backgroundColor: dotToken,
143
+ }}
144
+ />
145
+ <Text style={MICRO_LABEL_STYLE}>{part.toolName}</Text>
146
+ <Text size="xs" c="dimmed">
147
+ {label}
148
+ </Text>
149
+ {part.durationMs !== undefined && (
150
+ <Text
151
+ style={{
152
+ fontFamily: 'var(--basalt-font-mono)',
153
+ fontSize: VX.text.micro,
154
+ color: VX.faint,
155
+ }}
156
+ >
157
+ {formatDuration(part.durationMs)}
158
+ </Text>
159
+ )}
160
+ {!settled && (
161
+ <Text size="xs" c="dimmed">
162
+ …
163
+ </Text>
164
+ )}
165
+ </Group>
166
+ </UnstyledButton>
167
+ <Collapse expanded={expanded}>
168
+ <Stack gap={6}>
169
+ {input !== undefined && (
170
+ <Text component="pre" size="xs" style={CODE_BLOCK_STYLE}>
171
+ {JSON.stringify(input, null, 2)}
172
+ </Text>
173
+ )}
174
+ {rawInput !== undefined && (
175
+ <Text component="pre" size="xs" style={CODE_BLOCK_STYLE}>
176
+ {JSON.stringify(rawInput, null, 2)}
177
+ </Text>
178
+ )}
179
+ {part.state === 'output-available' && (
180
+ <Text component="pre" size="xs" style={CODE_BLOCK_STYLE}>
181
+ {JSON.stringify(part.output, null, 2)}
182
+ </Text>
183
+ )}
184
+ {part.state === 'output-error' && (
185
+ <Text size="xs" style={{ color: VX.status.bad }}>
186
+ {part.errorText}
187
+ </Text>
188
+ )}
189
+ {part.state === 'output-denied' && part.approval.reason !== undefined && (
190
+ <Text size="xs" c="dimmed">
191
+ {part.approval.reason}
192
+ </Text>
193
+ )}
194
+ {part.state === 'approval-requested' &&
195
+ (onApprove !== undefined || onDeny !== undefined) && (
196
+ <Group gap={6}>
197
+ {onApprove !== undefined && (
198
+ <Button size="compact-sm" onClick={() => onApprove(part.approval.id)}>
199
+ Approve
200
+ </Button>
201
+ )}
202
+ {onDeny !== undefined && (
203
+ <Button
204
+ size="compact-sm"
205
+ variant="light"
206
+ color="red"
207
+ onClick={() => onDeny(part.approval.id)}
208
+ >
209
+ Deny
210
+ </Button>
211
+ )}
212
+ </Group>
213
+ )}
214
+ </Stack>
215
+ </Collapse>
216
+ </Stack>
217
+ </Box>
218
+ )
219
+ }
package/src/cli/index.ts CHANGED
@@ -2240,7 +2240,6 @@ export async function guardHook(cwd: string = process.cwd()): Promise<number> {
2240
2240
  try {
2241
2241
  // Bun: Bun.stdin.text() drains stdin to a string; under Node fall back to manual drain.
2242
2242
  if (typeof (globalThis as Record<string, unknown>)['Bun'] !== 'undefined') {
2243
- // @ts-expect-error — Bun global, not in @types/bun for this import context
2244
2243
  raw = await globalThis['Bun'].stdin.text()
2245
2244
  } else {
2246
2245
  const chunks: Buffer[] = []
@@ -46,12 +46,12 @@ describe('shipped managed templates are oxfmt-clean', () => {
46
46
  expectOxfmtNoop('CLAUDE.md', filled)
47
47
  })
48
48
 
49
- it.each(RULE_NAMES)('agent/rules/basalt-%s.md (managed) round-trips unchanged', (name) => {
49
+ it.each([...RULE_NAMES])('agent/rules/basalt-%s.md (managed) round-trips unchanged', (name) => {
50
50
  const content = readFileSync(resolve(PKG_ROOT, `agent/rules/basalt-${name}.md`), 'utf8')
51
51
  expectOxfmtNoop(`basalt-${name}.md`, content)
52
52
  })
53
53
 
54
- it.each(SKILL_NAMES)('agent/skills/%s/SKILL.md (managed) round-trips unchanged', (name) => {
54
+ it.each([...SKILL_NAMES])('agent/skills/%s/SKILL.md (managed) round-trips unchanged', (name) => {
55
55
  const content = readFileSync(resolve(PKG_ROOT, `agent/skills/${name}/SKILL.md`), 'utf8')
56
56
  expectOxfmtNoop('SKILL.md', content)
57
57
  })
@@ -1,39 +1,266 @@
1
1
  /**
2
- * FenceBlock — renders a fenced code block's hast `pre` node as `CodeBlock` or `MermaidDiagram`
2
+ * FenceBlock — renders a fenced code block's hast `pre` node through the fence-renderer registry
3
3
  * (docs/CONTENT-SPEC.md §2 decision 4 / §6). Shared by every `pre` override `./markdown` builds
4
4
  * (one per streamed block, each with its own `settled` flag).
5
5
  *
6
- * `mermaid` fences render as a plain `CodeBlock` while `settled` is `false` (the in-flight
7
- * streaming tail) and upgrade to `MermaidDiagram` once the fence's block settles — no flicker, no
8
- * wasted parse pass on a still-changing diagram. Every other fenced language hides its copy
9
- * action while unsettled for the same reason (copying a still-streaming block is misleading).
6
+ * ONE mechanism, two sources: a consumer's `fenceRenderers[language]` is consulted first, then
7
+ * `BUILT_IN_FENCE_RENDERERS[language]`, then the plain `CodeBlock` fallback. `mermaid` is no longer
8
+ * a hardcoded branch — it is a built-in entry defined with `settledOnly`, exactly the helper a
9
+ * consumer uses for its own heavyweight renderer.
10
10
  *
11
- * Not part of the public surface.
11
+ * `settledOnly` renderers (mermaid included) render a plain `CodeBlock` while `settled` is `false`
12
+ * (the in-flight streaming tail) and upgrade once the fence's block settles — no flicker, no wasted
13
+ * parse pass on a still-changing diagram. Every fenced language hides its copy action while
14
+ * unsettled for the same reason (copying a still-streaming block is misleading).
15
+ *
16
+ * NEITHER a decline nor a throw may take the message down — a model-authored fence body (JSON for a
17
+ * `vega-lite` renderer, a payload for a `card` renderer, …) is routinely malformed, and this is a
18
+ * render path: the same invariant that made `spliceText` clamp instead of throw and dropped the
19
+ * runtime `assertNever` from `coalesceParts` applies here. `CodeBlock` is always a correct rendering
20
+ * of any fence, which is what makes it safe as the universal fallback:
21
+ * - A renderer returns `undefined` to DECLINE ("I cannot render this, use the default"). `null` is
22
+ * a different, deliberate result — "render nothing" — and is passed through unchanged.
23
+ * - A renderer that THROWS is contained two different ways depending on where the throw happens —
24
+ * see `renderFenceSafely` (a throw from the renderer function itself) and `FenceRenderBoundary` (a
25
+ * throw from a component the renderer merely returns) below — and both fall back to `CodeBlock`
26
+ * with a once-per-fence dev warning (`warnFenceRendererThrew`).
27
+ *
28
+ * `FenceBlock` itself is not part of the public surface; `FenceRenderer`/`FenceRenderContext`/
29
+ * `settledOnly` are, re-exported through `./markdown` (the props that carry them live on
30
+ * `MarkdownProps`).
12
31
  */
32
+ import { Component, useRef } from 'react'
33
+ import type { ReactNode } from 'react'
13
34
  import type { Element } from 'hast'
14
35
  import { CodeBlock } from './code-block'
15
36
  import { extractFenceInfo } from './markdown-hast'
16
37
  import { MermaidDiagram } from './mermaid'
17
38
 
39
+ // ── Public registry types ─────────────────────────────────────────────────────────────────────
40
+
41
+ export type FenceRenderContext = {
42
+ /** The fence body, without the surrounding fence markers. */
43
+ readonly code: string
44
+ /** The info string's first word (` ```ts title="x" ` → `'ts'`), absent on a bare fence. */
45
+ readonly language?: string
46
+ /** Raw meta string after the language on the opening fence line (`'title="x"'` above). */
47
+ readonly meta?: string
48
+ /** Parsed `title="…"` out of `meta` — the filename tab `CodeBlock` renders. */
49
+ readonly title?: string
50
+ /** `false` only for the in-flight tail block of a streaming message. Monotone: never true→false. */
51
+ readonly settled: boolean
52
+ }
53
+
54
+ /**
55
+ * `undefined` DECLINES — the fence falls back to the default `CodeBlock` rendering, no different
56
+ * from an unclaimed language. `null` is NOT a decline; it is a deliberate "render nothing" result
57
+ * and is used as-is. A renderer that THROWS is also contained and falls back to `CodeBlock` (see the
58
+ * module doc above) — no renderer needs its own try/parse/fallback scaffolding to survive malformed,
59
+ * model-authored fence bodies.
60
+ */
61
+ export type FenceRenderer = (ctx: FenceRenderContext) => ReactNode
62
+
63
+ /** Fence renderers keyed by language. */
64
+ export type FenceRenderers = Readonly<Record<string, FenceRenderer>>
65
+
66
+ /** The registry's terminal fallback — also what an unsettled `settledOnly` renderer shows. */
67
+ const defaultFenceRenderer: FenceRenderer = ({ code, language, title, settled }) => (
68
+ <CodeBlock
69
+ code={code}
70
+ showCopy={settled}
71
+ {...(language !== undefined && { language })}
72
+ {...(title !== undefined && { title })}
73
+ />
74
+ )
75
+
76
+ /**
77
+ * Wraps a renderer that is only worth running on finished text: renders the plain `CodeBlock`
78
+ * while the fence is still streaming, then `render` once its block settles. The built-in `mermaid`
79
+ * entry IS this — a consumer's own heavyweight fence (a chart spec, a diagram DSL) wants the same
80
+ * deal.
81
+ *
82
+ * @example
83
+ * import { settledOnly } from 'basalt-ui/content'
84
+ *
85
+ * <Markdown fenceRenderers={{ vega: settledOnly(({ code }) => <VegaChart spec={code} />) }}>
86
+ */
87
+ export function settledOnly(render: FenceRenderer): FenceRenderer {
88
+ return (ctx) => (ctx.settled ? render(ctx) : defaultFenceRenderer(ctx))
89
+ }
90
+
91
+ /** basalt's own fence renderers. A consumer entry for the same key wins over these. */
92
+ export const BUILT_IN_FENCE_RENDERERS: FenceRenderers = {
93
+ mermaid: settledOnly(({ code }) => <MermaidDiagram code={code} />),
94
+ }
95
+
96
+ // ── FenceBlock ────────────────────────────────────────────────────────────────────────────────
97
+
98
+ /**
99
+ * The raw meta string lives on the `code` child's `data.meta` (mdast-util-to-hast puts it there,
100
+ * never on the surrounding `pre`). `extractFenceInfo` parses `title="…"` out of it but does not
101
+ * surface the raw string, and widening `FenceInfo` is not this module's call — so read it here.
102
+ */
103
+ function extractFenceMeta(preNode: Element): string | undefined {
104
+ const codeNode = preNode.children.find(
105
+ (child): child is Element => child.type === 'element' && child.tagName === 'code',
106
+ )
107
+ const data = codeNode?.data as { meta?: string } | undefined
108
+ return data?.meta
109
+ }
110
+
18
111
  export type FenceBlockProps = {
19
112
  readonly node?: Element
20
113
  readonly settled: boolean
114
+ /** Consumer overrides, consulted BEFORE `BUILT_IN_FENCE_RENDERERS`. */
115
+ readonly renderers?: FenceRenderers
116
+ }
117
+
118
+ /**
119
+ * Registry lookup restricted to OWN keys — the only safe way to index either registry.
120
+ *
121
+ * `language` is the fence info string, so it is MODEL-CONTROLLED: `markdown-hast.ts` reads it off
122
+ * the `language-*` class, and `defaultSchema.attributes.code` (`[['className', /^language-./]]`)
123
+ * lets any such class through the sanitize pass. Both registries are plain object literals, so a
124
+ * bare `map[language]` resolves `Object.prototype` members and the `??` fallback never fires:
125
+ * ` ```valueOf `, ` ```hasOwnProperty ` and ` ```isPrototypeOf ` THREW when the resolved method was
126
+ * called unbound, ` ```__proto__ ` threw on a non-callable, and ` ```toString `/` ```constructor `
127
+ * rendered garbage. `Object.hasOwn` also rejects `__proto__` in a consumer literal (that key sets
128
+ * the prototype rather than defining an own property), and the `typeof` gate covers a consumer map
129
+ * that reached here through a cast with a non-function value.
130
+ */
131
+ function lookupFenceRenderer(
132
+ registry: FenceRenderers | undefined,
133
+ language: string,
134
+ ): FenceRenderer | undefined {
135
+ if (registry === undefined || !Object.hasOwn(registry, language)) return undefined
136
+ const candidate = registry[language]
137
+ return typeof candidate === 'function' ? candidate : undefined
21
138
  }
22
139
 
23
- export function FenceBlock({ node, settled }: FenceBlockProps) {
140
+ // ── Throw containment ────────────────────────────────────────────────────────────────────────────
141
+
142
+ /** A `useRef(false)` handle, mutated in place — same per-instance "warn once" idiom as
143
+ * `useHoverSync`'s `warnedRef` (`charts/hooks/useHoverSync.ts`) rather than a module-level
144
+ * singleton: a singleton would make the FIRST fence anywhere in the process to throw silence the
145
+ * warning for every OTHER, unrelated broken fence for the rest of the run (and, worse, for every
146
+ * later test in this suite — this file has module-singleton order-dependence history). Scoping the
147
+ * gate to one `FenceBlock`'s `useRef` means each rendered fence position gets its own warning, reset
148
+ * only on remount, and unrelated fences/tests never interact through it. */
149
+ type WarnedGate = { current: boolean }
150
+
151
+ /**
152
+ * Formats + emits the dev-only "a fence renderer threw" warning at most once per `warned` gate.
153
+ * Shared by BOTH containment paths below (`renderFenceSafely`'s try/catch and
154
+ * `FenceRenderBoundary.componentDidCatch`) so a fence that throws via either mechanism still only
155
+ * warns once. Mirrors the `process.env['NODE_ENV'] + console.warn('[basalt] …')` idiom used by
156
+ * `agent/merge.ts`'s `clampOffset` — including leaving the resulting oxlint `no-console` warning
157
+ * unsuppressed, the established convention in this package.
158
+ */
159
+ function warnFenceRendererThrew(warned: WarnedGate, error: unknown): void {
160
+ if (warned.current) return
161
+ warned.current = true
162
+ if (process.env['NODE_ENV'] !== 'production') {
163
+ console.warn(
164
+ '[basalt] FenceBlock: a fence renderer threw — falling back to the default CodeBlock rendering.',
165
+ error,
166
+ )
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Calls `render(ctx)` inside a try/catch — contains a SYNCHRONOUS throw from the renderer FUNCTION
172
+ * itself (e.g. `JSON.parse(code)` failing before the renderer ever returns anything). This catch
173
+ * sits in the same call stack as the invocation, so it only ever sees a throw that happens INSIDE
174
+ * this call — never one from a component the renderer merely returns as a descriptor (React invokes
175
+ * that component's own function body later, in a stack frame this function has already returned
176
+ * from — see `FenceRenderBoundary` for that case).
177
+ *
178
+ * Also resolves the decline convention here: `undefined` falls back to `defaultFenceRenderer`;
179
+ * `null` (or any other `ReactNode`) passes through unchanged.
180
+ */
181
+ function renderFenceSafely(
182
+ render: FenceRenderer,
183
+ ctx: FenceRenderContext,
184
+ warned: WarnedGate,
185
+ ): ReactNode {
186
+ let rendered: ReactNode
187
+ try {
188
+ rendered = render(ctx)
189
+ } catch (error) {
190
+ warnFenceRendererThrew(warned, error)
191
+ return defaultFenceRenderer(ctx)
192
+ }
193
+ return rendered === undefined ? defaultFenceRenderer(ctx) : rendered
194
+ }
195
+
196
+ type FenceRenderBoundaryProps = {
197
+ readonly ctx: FenceRenderContext
198
+ readonly warned: WarnedGate
199
+ readonly children: ReactNode
200
+ }
201
+ type FenceRenderBoundaryState = { hasError: boolean }
202
+
203
+ /**
204
+ * Contains a throw from the SUBTREE a `FenceRenderer` RETURNS — e.g.
205
+ * `settledOnly(({ code }) => <VegaChart spec={code} />)` where `VegaChart` itself throws while
206
+ * parsing `code` during ITS OWN render. `renderFenceSafely`'s try/catch cannot reach this case:
207
+ * `<VegaChart spec={code} />` is only a descriptor at the point the renderer returns it — React
208
+ * invokes `VegaChart`'s function body later, during its own render pass, outside the stack frame
209
+ * `renderFenceSafely` controls. An error boundary is the only mechanism that reaches a render-phase
210
+ * throw at that depth.
211
+ *
212
+ * `key={ctx.code}` at the call site (below) remounts this boundary — fresh `hasError: false` — every
213
+ * time the fence body changes, so a still-streaming fence that starts malformed and later becomes
214
+ * valid gets a genuine retry instead of staying permanently tripped.
215
+ *
216
+ * KNOWN GAP (standard React error-boundary limits): a throw from an event handler, from an effect
217
+ * running after an already-successful commit, or from asynchronous code is NOT caught here — only a
218
+ * throw during the RENDER of the returned subtree is contained.
219
+ */
220
+ class FenceRenderBoundary extends Component<FenceRenderBoundaryProps, FenceRenderBoundaryState> {
221
+ constructor(props: FenceRenderBoundaryProps) {
222
+ super(props)
223
+ this.state = { hasError: false }
224
+ }
225
+
226
+ static getDerivedStateFromError(): FenceRenderBoundaryState {
227
+ return { hasError: true }
228
+ }
229
+
230
+ override componentDidCatch(error: unknown): void {
231
+ warnFenceRendererThrew(this.props.warned, error)
232
+ }
233
+
234
+ override render(): ReactNode {
235
+ return this.state.hasError ? defaultFenceRenderer(this.props.ctx) : this.props.children
236
+ }
237
+ }
238
+
239
+ export function FenceBlock({ node, settled, renderers }: FenceBlockProps) {
240
+ const warned = useRef(false)
24
241
  const fence = node ? extractFenceInfo(node) : undefined
25
242
  if (!fence) return null
26
243
 
27
- if (fence.language === 'mermaid' && settled) {
28
- return <MermaidDiagram code={fence.code} />
244
+ const meta = node ? extractFenceMeta(node) : undefined
245
+ const ctx: FenceRenderContext = {
246
+ code: fence.code,
247
+ settled,
248
+ ...(fence.language !== undefined && { language: fence.language }),
249
+ ...(fence.title !== undefined && { title: fence.title }),
250
+ ...(meta !== undefined && { meta }),
29
251
  }
30
252
 
253
+ const language = fence.language
254
+ const render =
255
+ language === undefined
256
+ ? undefined
257
+ : (lookupFenceRenderer(renderers, language) ??
258
+ lookupFenceRenderer(BUILT_IN_FENCE_RENDERERS, language))
259
+ const active = render ?? defaultFenceRenderer
260
+
31
261
  return (
32
- <CodeBlock
33
- code={fence.code}
34
- showCopy={settled}
35
- {...(fence.language !== undefined && { language: fence.language })}
36
- {...(fence.title !== undefined && { title: fence.title })}
37
- />
262
+ <FenceRenderBoundary key={ctx.code} ctx={ctx} warned={warned}>
263
+ {renderFenceSafely(active, ctx, warned)}
264
+ </FenceRenderBoundary>
38
265
  )
39
266
  }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * heading-components — the slug/anchor/foreign-id contract (docs/CONTENT-SPEC.md §2 decision 6 /
3
+ * §7). `createHeadingComponents` builds one renderer per level against a shared `SlugTracker`; each
4
+ * test below builds its OWN tracker so a slug-collision counter never leaks across cases.
5
+ *
6
+ * `createHeadingComponents` is not part of the public surface (not re-exported by `./index`), same
7
+ * test idiom as `composeRehypePlugins` in `./markdown.test.tsx` — imported straight from the module.
8
+ */
9
+ import type { ComponentType, JSX } from 'react'
10
+ import type { Components, ExtraProps } from 'react-markdown'
11
+ import { MantineProvider } from '@mantine/core'
12
+ import { render, screen } from '@testing-library/react'
13
+ import { describe, expect, test } from 'bun:test'
14
+ import { createHeadingComponents } from './heading-components'
15
+ import { SlugTracker } from './slug'
16
+
17
+ function renderHeading(ui: React.ReactElement) {
18
+ return render(<MantineProvider>{ui}</MantineProvider>)
19
+ }
20
+
21
+ type HeadingProps = JSX.IntrinsicElements['h2'] & ExtraProps
22
+
23
+ /** Narrows `Components['h2']` (`ComponentType | keyof JSX.IntrinsicElements | undefined`) down to
24
+ * the function component `createHeadingComponents` actually builds — no cast, a runtime check. */
25
+ function h2Of(components: Partial<Components>): ComponentType<HeadingProps> {
26
+ const H2 = components.h2
27
+ if (typeof H2 !== 'function') throw new Error('expected createHeadingComponents to build h2')
28
+ return H2
29
+ }
30
+
31
+ describe('heading-components', () => {
32
+ test('an ordinary heading is slugged, gets an id, and gets the anchor affordance', () => {
33
+ const H2 = h2Of(createHeadingComponents(new SlugTracker()))
34
+ renderHeading(<H2>Introduction</H2>)
35
+
36
+ const heading = screen.getByRole('heading', { level: 2, name: 'Introduction' })
37
+ expect(heading.id).toBe('introduction')
38
+ expect(screen.getByLabelText('Copy link to section')).toBeDefined()
39
+ })
40
+
41
+ test('a heading arriving with an id keeps that id instead of the slugged one', () => {
42
+ const H2 = h2Of(createHeadingComponents(new SlugTracker()))
43
+ renderHeading(<H2 id="footnote-label">Footnotes</H2>)
44
+
45
+ // The slugger would produce 'footnotes' — the incoming id must win outright.
46
+ const heading = screen.getByRole('heading', { level: 2, name: 'Footnotes' })
47
+ expect(heading.id).toBe('footnote-label')
48
+ })
49
+
50
+ test('a heading arriving with a class keeps it', () => {
51
+ const H2 = h2Of(createHeadingComponents(new SlugTracker()))
52
+ renderHeading(<H2 className="sr-only">Footnotes</H2>)
53
+
54
+ const heading = screen.getByRole('heading', { level: 2, name: 'Footnotes' })
55
+ expect(heading.className).toBe('sr-only')
56
+ })
57
+
58
+ test('a heading that keeps its own id is not given the anchor affordance', () => {
59
+ const H2 = h2Of(createHeadingComponents(new SlugTracker()))
60
+ renderHeading(<H2 id="footnote-label">Footnotes</H2>)
61
+
62
+ screen.getByRole('heading', { level: 2, name: 'Footnotes' })
63
+ expect(screen.queryByLabelText('Copy link to section')).toBeNull()
64
+ })
65
+ })
@@ -5,7 +5,19 @@
5
5
  *
6
6
  * Ids come from `SlugTracker` — pass a document-scoped instance for correct cross-heading dedup,
7
7
  * or a fresh per-block instance in `Markdown`'s `streaming` mode (see that module's JSDoc for the
8
- * accepted collision tradeoff there).
8
+ * accepted collision tradeoff there). That is the default path, for an ordinary prose heading with
9
+ * no id of its own.
10
+ *
11
+ * FOREIGN IDS: a heading can already arrive with an `id` — remark-gfm's footnotes section heading
12
+ * is one (`<h2 id="footnote-label" class="sr-only">Footnotes</h2>`, referenced by the surrounding
13
+ * `<section>`'s `aria-labelledby`), and a consumer's own `rehypePlugins` injecting a heading is
14
+ * another. The rule is general, not a footnote special case: an id this renderer did NOT assign is
15
+ * something else's address for that heading, and slugging over it breaks whatever points at it. So
16
+ * an incoming `id` wins outright — no re-slug, no dedup registration against `tracker` (the heading
17
+ * isn't part of this document's slug namespace), and no `HeadingAnchor` (the affordance's premise is
18
+ * "copy a link to the id THIS renderer just minted"; a foreign id's meaning is basalt's to respect,
19
+ * not to advertise). Every other incoming prop (`className` included) is forwarded either way — the
20
+ * one thing always stripped is the hast `node` handle, which must never reach the DOM.
9
21
  *
10
22
  * Not part of the public surface.
11
23
  */
@@ -38,12 +50,21 @@ function headingText(node: ReactNode): string {
38
50
 
39
51
  function createHeadingRenderer(level: HeadingLevel, tracker: SlugTracker) {
40
52
  const Tag = HEADING_TAGS[level]
41
- return function HeadingRenderer({ children }: HeadingProps) {
42
- const id = tracker.slug(headingText(children))
53
+ // `node` is react-markdown's hast handle (`ExtraProps`) — read by neither branch, and must not
54
+ // reach the DOM, so it's stripped the same way `LinkRenderer` strips it in `./markdown`.
55
+ return function HeadingRenderer({ id, children, node: _node, ...rest }: HeadingProps) {
56
+ if (id !== undefined) {
57
+ return (
58
+ <Tag id={id} {...rest}>
59
+ {children}
60
+ </Tag>
61
+ )
62
+ }
63
+ const slug = tracker.slug(headingText(children))
43
64
  return (
44
- <Tag id={id}>
65
+ <Tag id={slug} {...rest}>
45
66
  {children}
46
- <HeadingAnchor id={id} />
67
+ <HeadingAnchor id={slug} />
47
68
  </Tag>
48
69
  )
49
70
  }