@miragon/mcp-toolkit-ui 2.6.0 → 2.7.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 (348) hide show
  1. package/README.md +7 -1
  2. package/dist/app/builder/BuilderBanners.d.ts +7 -2
  3. package/dist/app/builder/BuilderBanners.d.ts.map +1 -1
  4. package/dist/app/builder/BuilderBanners.js +8 -3
  5. package/dist/app/builder/BuilderBanners.js.map +1 -1
  6. package/dist/app/builder/CanvasRow.d.ts.map +1 -1
  7. package/dist/app/builder/CanvasRow.js +12 -14
  8. package/dist/app/builder/CanvasRow.js.map +1 -1
  9. package/dist/app/builder/CatalogueSheet.d.ts.map +1 -1
  10. package/dist/app/builder/CatalogueSheet.js +6 -5
  11. package/dist/app/builder/CatalogueSheet.js.map +1 -1
  12. package/dist/app/builder/EmptyHint.d.ts +4 -0
  13. package/dist/app/builder/EmptyHint.d.ts.map +1 -1
  14. package/dist/app/builder/EmptyHint.js +6 -1
  15. package/dist/app/builder/EmptyHint.js.map +1 -1
  16. package/dist/app/builder/LayoutSurface.js +3 -3
  17. package/dist/app/builder/LayoutSurface.js.map +1 -1
  18. package/dist/app/builder/PipelineStrip.d.ts +3 -1
  19. package/dist/app/builder/PipelineStrip.d.ts.map +1 -1
  20. package/dist/app/builder/PipelineStrip.js +11 -6
  21. package/dist/app/builder/PipelineStrip.js.map +1 -1
  22. package/dist/app/builder/SaveDialog.d.ts.map +1 -1
  23. package/dist/app/builder/SaveDialog.js +2 -2
  24. package/dist/app/builder/SaveDialog.js.map +1 -1
  25. package/dist/app/builder/Toolbar.d.ts +3 -1
  26. package/dist/app/builder/Toolbar.d.ts.map +1 -1
  27. package/dist/app/builder/Toolbar.js +9 -3
  28. package/dist/app/builder/Toolbar.js.map +1 -1
  29. package/dist/app/builder/Workspace.js +2 -2
  30. package/dist/app/builder/Workspace.js.map +1 -1
  31. package/dist/app/builder/builder-model.d.ts +3 -2
  32. package/dist/app/builder/builder-model.d.ts.map +1 -1
  33. package/dist/app/builder/builder-model.js +5 -4
  34. package/dist/app/builder/builder-model.js.map +1 -1
  35. package/dist/app/builder/labels.d.ts +13 -62
  36. package/dist/app/builder/labels.d.ts.map +1 -1
  37. package/dist/app/builder/labels.js +9 -58
  38. package/dist/app/builder/labels.js.map +1 -1
  39. package/dist/app/builder/status-chip.d.ts.map +1 -1
  40. package/dist/app/builder/status-chip.js +3 -2
  41. package/dist/app/builder/status-chip.js.map +1 -1
  42. package/dist/app/builder/use-builder-catalogue.d.ts +1 -0
  43. package/dist/app/builder/use-builder-catalogue.d.ts.map +1 -1
  44. package/dist/app/builder/use-builder-catalogue.js +6 -0
  45. package/dist/app/builder/use-builder-catalogue.js.map +1 -1
  46. package/dist/app/builder/use-save-dashboard.d.ts +3 -1
  47. package/dist/app/builder/use-save-dashboard.d.ts.map +1 -1
  48. package/dist/app/builder/use-save-dashboard.js +3 -1
  49. package/dist/app/builder/use-save-dashboard.js.map +1 -1
  50. package/dist/app/index.d.ts +1 -1
  51. package/dist/app/index.d.ts.map +1 -1
  52. package/dist/app/index.js.map +1 -1
  53. package/dist/app/layout-builder.d.ts +7 -2
  54. package/dist/app/layout-builder.d.ts.map +1 -1
  55. package/dist/app/layout-builder.js +17 -5
  56. package/dist/app/layout-builder.js.map +1 -1
  57. package/dist/app/mcp-app-view-chrome.d.ts +94 -20
  58. package/dist/app/mcp-app-view-chrome.d.ts.map +1 -1
  59. package/dist/app/mcp-app-view-chrome.js +109 -40
  60. package/dist/app/mcp-app-view-chrome.js.map +1 -1
  61. package/dist/app/mcp-app-view-states.d.ts +15 -0
  62. package/dist/app/mcp-app-view-states.d.ts.map +1 -0
  63. package/dist/app/mcp-app-view-states.js +34 -0
  64. package/dist/app/mcp-app-view-states.js.map +1 -0
  65. package/dist/app/mcp-app-view.d.ts +40 -5
  66. package/dist/app/mcp-app-view.d.ts.map +1 -1
  67. package/dist/app/mcp-app-view.js +174 -128
  68. package/dist/app/mcp-app-view.js.map +1 -1
  69. package/dist/app/mcp-toolkit-app.d.ts +2 -1
  70. package/dist/app/mcp-toolkit-app.d.ts.map +1 -1
  71. package/dist/app/mcp-toolkit-app.js +25 -3
  72. package/dist/app/mcp-toolkit-app.js.map +1 -1
  73. package/dist/app/use-view-lifecycle.d.ts +38 -0
  74. package/dist/app/use-view-lifecycle.d.ts.map +1 -0
  75. package/dist/app/use-view-lifecycle.js +79 -0
  76. package/dist/app/use-view-lifecycle.js.map +1 -0
  77. package/dist/app/view-lifecycle.d.ts +109 -0
  78. package/dist/app/view-lifecycle.d.ts.map +1 -0
  79. package/dist/app/view-lifecycle.js +120 -0
  80. package/dist/app/view-lifecycle.js.map +1 -0
  81. package/dist/app/widget-props-sheet.d.ts +3 -9
  82. package/dist/app/widget-props-sheet.d.ts.map +1 -1
  83. package/dist/app/widget-props-sheet.js +6 -13
  84. package/dist/app/widget-props-sheet.js.map +1 -1
  85. package/dist/app/widget-renderer.d.ts +41 -1
  86. package/dist/app/widget-renderer.d.ts.map +1 -1
  87. package/dist/app/widget-renderer.js +82 -31
  88. package/dist/app/widget-renderer.js.map +1 -1
  89. package/dist/components/DrillButton.d.ts.map +1 -1
  90. package/dist/components/DrillButton.js +2 -1
  91. package/dist/components/DrillButton.js.map +1 -1
  92. package/dist/components/EmptyState.d.ts +33 -0
  93. package/dist/components/EmptyState.d.ts.map +1 -0
  94. package/dist/components/EmptyState.js +17 -0
  95. package/dist/components/EmptyState.js.map +1 -0
  96. package/dist/components/ErrorState.d.ts +50 -0
  97. package/dist/components/ErrorState.d.ts.map +1 -0
  98. package/dist/components/ErrorState.js +60 -0
  99. package/dist/components/ErrorState.js.map +1 -0
  100. package/dist/components/FilterBar.d.ts +1 -1
  101. package/dist/components/FilterBar.d.ts.map +1 -1
  102. package/dist/components/FilterBar.js +7 -4
  103. package/dist/components/FilterBar.js.map +1 -1
  104. package/dist/components/GroupCard.d.ts.map +1 -1
  105. package/dist/components/GroupCard.js +2 -1
  106. package/dist/components/GroupCard.js.map +1 -1
  107. package/dist/components/KpiGrid.d.ts +10 -3
  108. package/dist/components/KpiGrid.d.ts.map +1 -1
  109. package/dist/components/KpiGrid.js +31 -9
  110. package/dist/components/KpiGrid.js.map +1 -1
  111. package/dist/components/ListFooter.d.ts +3 -2
  112. package/dist/components/ListFooter.d.ts.map +1 -1
  113. package/dist/components/ListFooter.js +14 -4
  114. package/dist/components/ListFooter.js.map +1 -1
  115. package/dist/components/Pills.d.ts +10 -6
  116. package/dist/components/Pills.d.ts.map +1 -1
  117. package/dist/components/Pills.js +12 -6
  118. package/dist/components/Pills.js.map +1 -1
  119. package/dist/components/Toast.d.ts +37 -0
  120. package/dist/components/Toast.d.ts.map +1 -0
  121. package/dist/components/Toast.js +91 -0
  122. package/dist/components/Toast.js.map +1 -0
  123. package/dist/components/WidgetHeader.js +1 -1
  124. package/dist/hooks/use-tool-result-recovery.d.ts +23 -1
  125. package/dist/hooks/use-tool-result-recovery.d.ts.map +1 -1
  126. package/dist/hooks/use-tool-result-recovery.js +21 -7
  127. package/dist/hooks/use-tool-result-recovery.js.map +1 -1
  128. package/dist/hooks/use-view-data.d.ts +6 -0
  129. package/dist/hooks/use-view-data.d.ts.map +1 -1
  130. package/dist/hooks/use-view-data.js +6 -1
  131. package/dist/hooks/use-view-data.js.map +1 -1
  132. package/dist/index.d.ts +12 -5
  133. package/dist/index.d.ts.map +1 -1
  134. package/dist/index.js +14 -4
  135. package/dist/index.js.map +1 -1
  136. package/dist/lib/focus-ring.d.ts +18 -0
  137. package/dist/lib/focus-ring.d.ts.map +1 -0
  138. package/dist/lib/focus-ring.js +18 -0
  139. package/dist/lib/focus-ring.js.map +1 -0
  140. package/dist/lib/icon-stroke.d.ts +12 -0
  141. package/dist/lib/icon-stroke.d.ts.map +1 -0
  142. package/dist/lib/icon-stroke.js +12 -0
  143. package/dist/lib/icon-stroke.js.map +1 -0
  144. package/dist/lib/toast-model.d.ts +46 -0
  145. package/dist/lib/toast-model.d.ts.map +1 -0
  146. package/dist/lib/toast-model.js +36 -0
  147. package/dist/lib/toast-model.js.map +1 -0
  148. package/dist/lib/tone-utils.d.ts +43 -9
  149. package/dist/lib/tone-utils.d.ts.map +1 -1
  150. package/dist/lib/tone-utils.js +36 -7
  151. package/dist/lib/tone-utils.js.map +1 -1
  152. package/dist/locale/index.d.ts +3 -1
  153. package/dist/locale/index.d.ts.map +1 -1
  154. package/dist/locale/index.js +2 -1
  155. package/dist/locale/index.js.map +1 -1
  156. package/dist/locale/locale-provider.d.ts +42 -1
  157. package/dist/locale/locale-provider.d.ts.map +1 -1
  158. package/dist/locale/locale-provider.js +44 -3
  159. package/dist/locale/locale-provider.js.map +1 -1
  160. package/dist/locale/message-types.d.ts +223 -0
  161. package/dist/locale/message-types.d.ts.map +1 -0
  162. package/dist/locale/message-types.js +7 -0
  163. package/dist/locale/message-types.js.map +1 -0
  164. package/dist/locale/messages-de.d.ts +9 -0
  165. package/dist/locale/messages-de.d.ts.map +1 -0
  166. package/dist/locale/messages-de.js +151 -0
  167. package/dist/locale/messages-de.js.map +1 -0
  168. package/dist/locale/messages-en.d.ts +8 -0
  169. package/dist/locale/messages-en.d.ts.map +1 -0
  170. package/dist/locale/messages-en.js +150 -0
  171. package/dist/locale/messages-en.js.map +1 -0
  172. package/dist/locale/toolkit-messages.d.ts +39 -0
  173. package/dist/locale/toolkit-messages.d.ts.map +1 -0
  174. package/dist/locale/toolkit-messages.js +50 -0
  175. package/dist/locale/toolkit-messages.js.map +1 -0
  176. package/dist/primitives/alert.d.ts.map +1 -1
  177. package/dist/primitives/alert.js +4 -2
  178. package/dist/primitives/alert.js.map +1 -1
  179. package/dist/primitives/badge.d.ts +1 -1
  180. package/dist/primitives/badge.d.ts.map +1 -1
  181. package/dist/primitives/badge.js +14 -6
  182. package/dist/primitives/badge.js.map +1 -1
  183. package/dist/primitives/button.d.ts.map +1 -1
  184. package/dist/primitives/button.js +12 -8
  185. package/dist/primitives/button.js.map +1 -1
  186. package/dist/primitives/card.js +4 -4
  187. package/dist/primitives/card.js.map +1 -1
  188. package/dist/primitives/dialog.d.ts +7 -3
  189. package/dist/primitives/dialog.d.ts.map +1 -1
  190. package/dist/primitives/dialog.js +14 -7
  191. package/dist/primitives/dialog.js.map +1 -1
  192. package/dist/primitives/input.d.ts.map +1 -1
  193. package/dist/primitives/input.js +4 -1
  194. package/dist/primitives/input.js.map +1 -1
  195. package/dist/primitives/scroll-area.js +1 -1
  196. package/dist/primitives/scroll-area.js.map +1 -1
  197. package/dist/primitives/select.d.ts.map +1 -1
  198. package/dist/primitives/select.js +5 -2
  199. package/dist/primitives/select.js.map +1 -1
  200. package/dist/primitives/sheet.d.ts +3 -1
  201. package/dist/primitives/sheet.d.ts.map +1 -1
  202. package/dist/primitives/sheet.js +12 -6
  203. package/dist/primitives/sheet.js.map +1 -1
  204. package/dist/primitives/skeleton.js +1 -1
  205. package/dist/primitives/skeleton.js.map +1 -1
  206. package/dist/primitives/switch.d.ts.map +1 -1
  207. package/dist/primitives/switch.js +2 -1
  208. package/dist/primitives/switch.js.map +1 -1
  209. package/dist/primitives/tabs.d.ts.map +1 -1
  210. package/dist/primitives/tabs.js +5 -3
  211. package/dist/primitives/tabs.js.map +1 -1
  212. package/dist/render-cases.d.ts.map +1 -1
  213. package/dist/render-cases.js +20 -2
  214. package/dist/render-cases.js.map +1 -1
  215. package/dist/render-scan.d.ts +25 -0
  216. package/dist/render-scan.d.ts.map +1 -0
  217. package/dist/render-scan.js +66 -0
  218. package/dist/render-scan.js.map +1 -0
  219. package/dist/theme/cd-tokens.d.ts +66 -0
  220. package/dist/theme/cd-tokens.d.ts.map +1 -0
  221. package/dist/theme/cd-tokens.js +64 -0
  222. package/dist/theme/cd-tokens.js.map +1 -0
  223. package/dist/theme/create-theme.d.ts +196 -39
  224. package/dist/theme/create-theme.d.ts.map +1 -1
  225. package/dist/theme/create-theme.js +189 -13
  226. package/dist/theme/create-theme.js.map +1 -1
  227. package/dist/theme/fonts.d.ts +38 -0
  228. package/dist/theme/fonts.d.ts.map +1 -0
  229. package/dist/theme/fonts.js +56 -0
  230. package/dist/theme/fonts.js.map +1 -0
  231. package/dist/theme/host-fonts.d.ts +39 -0
  232. package/dist/theme/host-fonts.d.ts.map +1 -0
  233. package/dist/theme/host-fonts.js +44 -0
  234. package/dist/theme/host-fonts.js.map +1 -0
  235. package/dist/theme/index.d.ts +8 -1
  236. package/dist/theme/index.d.ts.map +1 -1
  237. package/dist/theme/index.js +5 -1
  238. package/dist/theme/index.js.map +1 -1
  239. package/dist/theme/portal-container.d.ts +16 -0
  240. package/dist/theme/portal-container.d.ts.map +1 -0
  241. package/dist/theme/portal-container.js +19 -0
  242. package/dist/theme/portal-container.js.map +1 -0
  243. package/dist/theme/presets.d.ts +24 -9
  244. package/dist/theme/presets.d.ts.map +1 -1
  245. package/dist/theme/presets.js +44 -11
  246. package/dist/theme/presets.js.map +1 -1
  247. package/dist/theme/theme-provider.d.ts +27 -9
  248. package/dist/theme/theme-provider.d.ts.map +1 -1
  249. package/dist/theme/theme-provider.js +42 -20
  250. package/dist/theme/theme-provider.js.map +1 -1
  251. package/package.json +9 -4
  252. package/src/app/builder/BuilderBanners.tsx +26 -13
  253. package/src/app/builder/CanvasRow.tsx +22 -21
  254. package/src/app/builder/CatalogueSheet.tsx +16 -14
  255. package/src/app/builder/EmptyHint.tsx +16 -11
  256. package/src/app/builder/LayoutSurface.tsx +7 -7
  257. package/src/app/builder/PipelineStrip.tsx +26 -17
  258. package/src/app/builder/SaveDialog.tsx +11 -12
  259. package/src/app/builder/Toolbar.tsx +19 -7
  260. package/src/app/builder/Workspace.tsx +5 -3
  261. package/src/app/builder/builder-feedback.test.tsx +75 -0
  262. package/src/app/builder/builder-model.test.ts +5 -0
  263. package/src/app/builder/builder-model.ts +5 -4
  264. package/src/app/builder/labels.ts +15 -121
  265. package/src/app/builder/status-chip.tsx +8 -4
  266. package/src/app/builder/use-builder-catalogue.ts +6 -0
  267. package/src/app/builder/use-save-dashboard.ts +5 -0
  268. package/src/app/index.ts +8 -1
  269. package/src/app/layout-builder.tsx +23 -3
  270. package/src/app/mcp-app-view-chrome.test.tsx +347 -14
  271. package/src/app/mcp-app-view-chrome.tsx +278 -100
  272. package/src/app/mcp-app-view-states.tsx +100 -0
  273. package/src/app/mcp-app-view.test.tsx +258 -0
  274. package/src/app/mcp-app-view.tsx +292 -185
  275. package/src/app/mcp-toolkit-app.tsx +31 -7
  276. package/src/app/use-view-lifecycle.ts +116 -0
  277. package/src/app/view-lifecycle.test.ts +227 -0
  278. package/src/app/view-lifecycle.ts +174 -0
  279. package/src/app/widget-props-sheet.tsx +13 -26
  280. package/src/app/widget-renderer.render.test.tsx +131 -0
  281. package/src/app/widget-renderer.tsx +136 -38
  282. package/src/components/DrillButton.tsx +2 -1
  283. package/src/components/EmptyState.tsx +81 -0
  284. package/src/components/ErrorState.tsx +182 -0
  285. package/src/components/FilterBar.tsx +9 -6
  286. package/src/components/GroupCard.tsx +3 -2
  287. package/src/components/KpiGrid.tsx +75 -44
  288. package/src/components/ListFooter.tsx +17 -6
  289. package/src/components/Pills.tsx +20 -11
  290. package/src/components/Toast.tsx +188 -0
  291. package/src/components/WidgetHeader.tsx +1 -1
  292. package/src/components/feedback-states.test.tsx +225 -0
  293. package/src/components/tone-figures.test.tsx +54 -0
  294. package/src/fonts.css +19 -0
  295. package/src/globals.css +9 -165
  296. package/src/hooks/use-tool-result-recovery.test.ts +25 -1
  297. package/src/hooks/use-tool-result-recovery.ts +34 -6
  298. package/src/hooks/use-view-data.test.tsx +60 -0
  299. package/src/hooks/use-view-data.ts +12 -1
  300. package/src/i18n-coverage.test.tsx +323 -0
  301. package/src/index.ts +57 -5
  302. package/src/lib/focus-ring.ts +20 -0
  303. package/src/lib/icon-stroke.test.ts +59 -0
  304. package/src/lib/icon-stroke.ts +13 -0
  305. package/src/lib/toast-model.test.ts +68 -0
  306. package/src/lib/toast-model.ts +63 -0
  307. package/src/lib/tone-utils.test.ts +47 -14
  308. package/src/lib/tone-utils.ts +61 -13
  309. package/src/locale/index.ts +13 -1
  310. package/src/locale/locale-provider.test.tsx +87 -0
  311. package/src/locale/locale-provider.tsx +90 -3
  312. package/src/locale/message-types.ts +221 -0
  313. package/src/locale/messages-de.ts +159 -0
  314. package/src/locale/messages-en.ts +157 -0
  315. package/src/locale/toolkit-messages.test.ts +216 -0
  316. package/src/locale/toolkit-messages.ts +82 -0
  317. package/src/no-hardcoded-copy.test.ts +432 -0
  318. package/src/primitives/alert.tsx +4 -2
  319. package/src/primitives/badge.tsx +14 -7
  320. package/src/primitives/button.tsx +13 -8
  321. package/src/primitives/card.tsx +4 -4
  322. package/src/primitives/dialog.tsx +32 -7
  323. package/src/primitives/input.tsx +5 -3
  324. package/src/primitives/scroll-area.tsx +1 -1
  325. package/src/primitives/select.tsx +6 -3
  326. package/src/primitives/sheet.tsx +26 -6
  327. package/src/primitives/skeleton.tsx +1 -1
  328. package/src/primitives/switch.tsx +4 -2
  329. package/src/primitives/tabs.tsx +9 -6
  330. package/src/render-cases.tsx +45 -1
  331. package/src/render-scan.ts +75 -0
  332. package/src/theme/cd-tokens.generated.css +48 -0
  333. package/src/theme/cd-tokens.test.ts +107 -0
  334. package/src/theme/cd-tokens.ts +67 -0
  335. package/src/theme/create-theme.test.ts +243 -5
  336. package/src/theme/create-theme.ts +313 -41
  337. package/src/theme/fonts.test.ts +85 -0
  338. package/src/theme/fonts.ts +72 -0
  339. package/src/theme/host-fonts.test.ts +135 -0
  340. package/src/theme/host-fonts.ts +52 -0
  341. package/src/theme/index.ts +15 -1
  342. package/src/theme/portal-container.ts +20 -0
  343. package/src/theme/presets.ts +44 -11
  344. package/src/theme/theme-css.test.ts +568 -0
  345. package/src/theme/theme-provider.test.tsx +82 -0
  346. package/src/theme/theme-provider.tsx +60 -24
  347. package/src/theme.css +314 -0
  348. package/ui-catalog.json +344 -17
@@ -0,0 +1,131 @@
1
+ import { renderToStaticMarkup } from "react-dom/server"
2
+ import { describe, expect, it, vi } from "vitest"
3
+ import type { WidgetProps } from "@miragon/mcp-toolkit-core"
4
+ import { LocaleProvider } from "../locale/locale-provider.js"
5
+ import { TOOLKIT_MESSAGES } from "../locale/toolkit-messages.js"
6
+ import { decodeEntities } from "../render-scan.js"
7
+ import {
8
+ buildPipelineContext,
9
+ RenderedWidget,
10
+ resetKeysChanged,
11
+ WidgetCrashFallback,
12
+ WidgetErrorBoundary,
13
+ WidgetRenderer,
14
+ } from "./widget-renderer.js"
15
+
16
+ const de = TOOLKIT_MESSAGES.de.renderer
17
+
18
+ function Hello({ keys }: WidgetProps) {
19
+ return <p>hello {typeof keys.who === "string" ? keys.who : ""}</p>
20
+ }
21
+
22
+ describe("WidgetRenderer fallbacks (toolkit#176 N24, U19)", () => {
23
+ it("an unknown widget id renders a localized 'not available' note instead of vanishing", () => {
24
+ const html = renderToStaticMarkup(
25
+ <LocaleProvider locale="de">
26
+ <WidgetRenderer
27
+ layout={{ rows: [{ row: [{ widget: "demo:hello" }, { widget: "demo:gone" }] }] }}
28
+ keys={{ who: "ada" }}
29
+ errors={[]}
30
+ widgets={{ "demo:hello": Hello }}
31
+ />
32
+ </LocaleProvider>,
33
+ )
34
+ expect(html).toContain("hello ada")
35
+ expect(decodeEntities(html)).toContain(de.widgetUnavailable)
36
+ expect(html).toContain('data-widget-id="demo:gone"')
37
+ })
38
+
39
+ it("the crash fallback names what happened, keeps the raw error under Details, offers a retry", () => {
40
+ const html = decodeEntities(
41
+ renderToStaticMarkup(
42
+ <LocaleProvider locale="de">
43
+ <WidgetCrashFallback widgetId="demo:x" error={new Error("kaputt")} onRetry={() => {}} />
44
+ </LocaleProvider>,
45
+ ),
46
+ )
47
+ expect(html).toContain('role="alert"')
48
+ expect(html).toContain(de.widgetCrashed)
49
+ expect(html).toContain("demo:x: kaputt")
50
+ expect(html).toContain(`${TOOLKIT_MESSAGES.de.common.retry}</button>`)
51
+ })
52
+
53
+ it("the builder's empty-context hint is localized", () => {
54
+ const html = renderToStaticMarkup(
55
+ <LocaleProvider locale="de">
56
+ <RenderedWidget
57
+ widget={Hello}
58
+ widgetId="demo:hello"
59
+ props={{ keys: {}, context: { steps: {}, keys: {}, errors: [] } }}
60
+ showEmptyHint
61
+ />
62
+ </LocaleProvider>,
63
+ )
64
+ expect(html).toContain(TOOLKIT_MESSAGES.de.renderer.noDataYet)
65
+ })
66
+ })
67
+
68
+ describe("WidgetErrorBoundary reset", () => {
69
+ it("resets a latched crash when its reset keys change, not on an unrelated update", () => {
70
+ const keysA = [{ a: 1 }]
71
+ const boundary = new WidgetErrorBoundary({ widgetId: "w", children: null, resetKeys: keysA })
72
+ const setState = vi.fn()
73
+ boundary.setState = setState
74
+ boundary.state = { error: new Error("boom") }
75
+
76
+ // Same keys (identity) → stays failed.
77
+ boundary.componentDidUpdate({ resetKeys: keysA })
78
+ expect(setState).not.toHaveBeenCalled()
79
+
80
+ // New data → a fresh render.
81
+ boundary.componentDidUpdate({ resetKeys: [{ a: 1 }] })
82
+ expect(setState).toHaveBeenCalledWith({ error: null })
83
+ })
84
+
85
+ it("does nothing while healthy", () => {
86
+ const boundary = new WidgetErrorBoundary({ widgetId: "w", children: null, resetKeys: [1] })
87
+ const setState = vi.fn()
88
+ boundary.setState = setState
89
+ boundary.componentDidUpdate({ resetKeys: [2] })
90
+ expect(setState).not.toHaveBeenCalled()
91
+ })
92
+
93
+ it("its retry clears the error", () => {
94
+ const boundary = new WidgetErrorBoundary({ widgetId: "w", children: null })
95
+ const setState = vi.fn()
96
+ boundary.setState = setState
97
+ boundary.reset()
98
+ expect(setState).toHaveBeenCalledWith({ error: null })
99
+ })
100
+ })
101
+
102
+ describe("resetKeysChanged", () => {
103
+ it("compares by length and entry identity", () => {
104
+ const a = { x: 1 }
105
+ expect(resetKeysChanged([a], [a])).toBe(false)
106
+ expect(resetKeysChanged(undefined, undefined)).toBe(false)
107
+ const same = [a]
108
+ expect(resetKeysChanged(same, same)).toBe(false)
109
+ expect(resetKeysChanged([a], [{ x: 1 }])).toBe(true)
110
+ expect(resetKeysChanged([a], [a, a])).toBe(true)
111
+ expect(resetKeysChanged(undefined, [a])).toBe(true)
112
+ expect(resetKeysChanged([a], undefined)).toBe(true)
113
+ expect(resetKeysChanged([Number.NaN], [Number.NaN])).toBe(false)
114
+ })
115
+ })
116
+
117
+ describe("buildPipelineContext", () => {
118
+ it("tags each step with its id and passes keys and errors through", () => {
119
+ const keys = { k: 1 }
120
+ const errors = [{ stepId: "s2", reason: "x" }]
121
+ const ctx = buildPipelineContext(
122
+ keys,
123
+ { s1: { data: 1, keys: {}, _app: "a", _dataType: "a:t" } },
124
+ errors,
125
+ )
126
+ expect(ctx.steps.s1).toEqual({ data: 1, keys: {}, _app: "a", _dataType: "a:t", _step: "s1" })
127
+ expect(ctx.keys).toBe(keys)
128
+ expect(ctx.errors).toBe(errors)
129
+ expect(buildPipelineContext(keys, undefined, []).steps).toEqual({})
130
+ })
131
+ })
@@ -1,46 +1,136 @@
1
- import { Component, type ComponentType, type ReactNode, type ErrorInfo } from "react"
1
+ import {
2
+ Component,
3
+ createRef,
4
+ useMemo,
5
+ type ComponentType,
6
+ type ReactNode,
7
+ type ErrorInfo,
8
+ } from "react"
2
9
  import type { LayoutConfig, PipelineContext, RowDef, WidgetProps } from "@miragon/mcp-toolkit-core"
3
10
  import { normalizeLayout } from "@miragon/mcp-toolkit-core"
11
+ import { ErrorState } from "../components/ErrorState.js"
4
12
  import { GridLayout, GridItem } from "../components/GridLayout.js"
5
- import { Card, CardContent } from "../primitives/card.js"
13
+ import { useToolkitMessages } from "../locale/locale-provider.js"
6
14
  import { Tabs, TabsList, TabsTrigger, TabsContent } from "../primitives/tabs.js"
7
15
 
8
16
  export type WidgetComponent = ComponentType<WidgetProps>
9
17
 
18
+ /**
19
+ * True when two reset-key lists differ (length or any entry by identity).
20
+ * Exported for tests: it decides when a crashed widget gets a fresh render.
21
+ */
22
+ export function resetKeysChanged(
23
+ prev: readonly unknown[] | undefined,
24
+ next: readonly unknown[] | undefined,
25
+ ): boolean {
26
+ if (prev === next) return false
27
+ if (!prev || !next || prev.length !== next.length) return true
28
+ return prev.some((value, idx) => !Object.is(value, next[idx]))
29
+ }
30
+
10
31
  /**
11
32
  * Catches render-time crashes from a single widget so one broken widget
12
33
  * can't take down the entire iframe view (or the builder canvas/preview).
13
34
  *
35
+ * The fallback is localized, names what happened, keeps the raw error under
36
+ * "Details" and offers a retry. The boundary resets itself when its
37
+ * `resetKeys` change, so new data (a refresh, a new tool result, a builder
38
+ * edit) gets a fresh render instead of a latched failure (toolkit#176 N24).
39
+ * When a retry crashes again, keyboard focus moves to the new retry button
40
+ * instead of falling back to the document (the pressed one unmounted). After
41
+ * a retry that renders, the widget's own markup is unknown here, so focus
42
+ * returns to the document.
43
+ *
14
44
  * Exported so the visual builder's canvas + preview render sites reuse the
15
45
  * same isolation the rendered `WidgetRenderer` already had — every place a
16
46
  * widget component is rendered goes through {@link RenderedWidget}, which
17
47
  * wraps the component in this boundary.
18
48
  */
19
49
  export class WidgetErrorBoundary extends Component<
20
- { widgetId: string; children: ReactNode },
50
+ { widgetId: string; children: ReactNode; resetKeys?: readonly unknown[] },
21
51
  { error: Error | null }
22
52
  > {
23
53
  override state: { error: Error | null } = { error: null }
54
+ /** Set by the user's retry; consumed by the next update. */
55
+ private retryFocusOwed = false
56
+ private readonly fallbackRef = createRef<HTMLDivElement>()
24
57
  static getDerivedStateFromError(error: Error) {
25
58
  return { error }
26
59
  }
27
60
  override componentDidCatch(error: Error, info: ErrorInfo) {
28
61
  console.error(`Widget ${this.props.widgetId} crashed:`, error, info)
29
62
  }
63
+ override componentDidUpdate(prevProps: { resetKeys?: readonly unknown[] }) {
64
+ if (this.retryFocusOwed) {
65
+ this.retryFocusOwed = false
66
+ if (this.state.error) this.fallbackRef.current?.querySelector<HTMLElement>("button")?.focus()
67
+ }
68
+ if (this.state.error && resetKeysChanged(prevProps.resetKeys, this.props.resetKeys)) {
69
+ this.reset()
70
+ }
71
+ }
72
+ reset = () => {
73
+ this.setState({ error: null })
74
+ }
75
+ /** The fallback's retry: a reset that keeps keyboard focus if the widget crashes again. */
76
+ retry = () => {
77
+ this.retryFocusOwed = true
78
+ this.reset()
79
+ }
30
80
  override render() {
31
81
  if (this.state.error) {
32
82
  return (
33
- <Card>
34
- <CardContent className="text-muted-foreground py-3 text-sm">
35
- Widget "{this.props.widgetId}" failed to render.
36
- </CardContent>
37
- </Card>
83
+ <div ref={this.fallbackRef} data-slot="widget-crash">
84
+ <WidgetCrashFallback
85
+ widgetId={this.props.widgetId}
86
+ error={this.state.error}
87
+ onRetry={this.retry}
88
+ />
89
+ </div>
38
90
  )
39
91
  }
40
92
  return this.props.children
41
93
  }
42
94
  }
43
95
 
96
+ /** The localized crash fallback of {@link WidgetErrorBoundary}. */
97
+ export function WidgetCrashFallback({
98
+ widgetId,
99
+ error,
100
+ onRetry,
101
+ }: {
102
+ widgetId: string
103
+ error: Error
104
+ onRetry: () => void
105
+ }) {
106
+ const messages = useToolkitMessages()
107
+ return (
108
+ <ErrorState
109
+ variant="inline"
110
+ title={messages.renderer.widgetCrashed}
111
+ details={`${widgetId}: ${error.message}`}
112
+ onRetry={onRetry}
113
+ />
114
+ )
115
+ }
116
+
117
+ /** A layout cell names a widget the bundle does not contain: say so, quietly. */
118
+ export function UnavailableWidget({
119
+ widgetId,
120
+ className = "text-muted-foreground rounded-md border border-dashed p-4 text-sm",
121
+ }: {
122
+ widgetId: string
123
+ className?: string
124
+ }) {
125
+ const messages = useToolkitMessages()
126
+ return (
127
+ <div data-slot="widget-unavailable" data-widget-id={widgetId} className={className}>
128
+ <p>{messages.renderer.widgetUnavailable}</p>
129
+ <p className="mt-1 font-mono text-xs break-all">{widgetId}</p>
130
+ </div>
131
+ )
132
+ }
133
+
44
134
  /**
45
135
  * True when the pipeline context carries no widget data at all — no step
46
136
  * results and no keys. A widget rendered against an empty context typically
@@ -59,7 +149,7 @@ export function contextHasNoData(context: PipelineContext): boolean {
59
149
  * The single render site for a layout cell's widget, shared across the
60
150
  * rendered view (`RowsRenderer`), the builder canvas, and the builder
61
151
  * preview so all three isolate crashes identically:
62
- * - missing component → "not bundled" fallback,
152
+ * - missing component → a localized "not available in this version" note,
63
153
  * - render crash → caught by {@link WidgetErrorBoundary} (Finding [1]),
64
154
  * - (builder only, opt-in) empty pipeline context → a dezent
65
155
  * "no data yet" hint (Finding [4]).
@@ -79,7 +169,7 @@ export function RenderedWidget({
79
169
  props,
80
170
  cellProps,
81
171
  showEmptyHint = false,
82
- notBundledClassName = "text-muted-foreground rounded border border-dashed p-4 text-center text-xs",
172
+ notBundledClassName,
83
173
  }: {
84
174
  widget: WidgetComponent | undefined
85
175
  widgetId: string
@@ -88,23 +178,26 @@ export function RenderedWidget({
88
178
  showEmptyHint?: boolean
89
179
  notBundledClassName?: string
90
180
  }) {
91
- if (!Widget) {
92
- return <div className={notBundledClassName}>Widget "{widgetId}" not bundled.</div>
93
- }
181
+ if (!Widget) return <UnavailableWidget widgetId={widgetId} className={notBundledClassName} />
94
182
  if (showEmptyHint && contextHasNoData(props.context)) {
95
- return (
96
- <div className={notBundledClassName}>
97
- No data for this widget yet — it appears after a refresh.
98
- </div>
99
- )
183
+ return <NoDataYet className={notBundledClassName} />
100
184
  }
101
185
  return (
102
- <WidgetErrorBoundary widgetId={widgetId}>
186
+ <WidgetErrorBoundary widgetId={widgetId} resetKeys={[props.context, props.keys, cellProps]}>
103
187
  <Widget {...props} widgetProps={cellProps} />
104
188
  </WidgetErrorBoundary>
105
189
  )
106
190
  }
107
191
 
192
+ function NoDataYet({
193
+ className = "text-muted-foreground rounded-md border border-dashed p-4 text-sm",
194
+ }: {
195
+ className?: string
196
+ }) {
197
+ const messages = useToolkitMessages()
198
+ return <div className={className}>{messages.renderer.noDataYet}</div>
199
+ }
200
+
108
201
  export interface WidgetRendererProps {
109
202
  layout: LayoutConfig
110
203
  keys: Record<string, unknown>
@@ -137,14 +230,11 @@ function RowsRenderer({
137
230
  <GridLayout key={rowIdx}>
138
231
  {row.row.map((cell, cellIdx) => {
139
232
  const WidgetComponent = widgets[cell.widget]
140
- // Keep the rendered view's original behaviour of skipping cells
141
- // whose component isn't bundled (no placeholder in the shipped
142
- // view), while still routing the resolvable ones through the
143
- // shared `RenderedWidget` for error-boundary + empty-state.
144
- if (!WidgetComponent) return null
145
- // Use a composite key so the same widget can appear in multiple
146
- // cells of one row (e.g. side-by-side scoped dashboards) without
147
- // colliding on React's reconciliation key.
233
+ // An unknown widget id renders the localized "not available"
234
+ // note in its cell instead of vanishing (an empty page reads as
235
+ // broken). The composite key lets the same widget appear in
236
+ // several cells of one row (e.g. side-by-side scoped dashboards)
237
+ // without colliding on React's reconciliation key.
148
238
  return (
149
239
  <GridItem key={`${cell.widget}-${cellIdx}`} span={cell.span ?? 12}>
150
240
  <RenderedWidget
@@ -162,19 +252,27 @@ function RowsRenderer({
162
252
  )
163
253
  }
164
254
 
165
- export function WidgetRenderer({ layout, keys, stepData, errors, widgets }: WidgetRendererProps) {
255
+ /**
256
+ * The pipeline context widgets read. `WidgetRenderer` rebuilds it only when
257
+ * the view data changes — its identity is the error boundaries' reset key.
258
+ */
259
+ export function buildPipelineContext(
260
+ keys: Record<string, unknown>,
261
+ stepData: WidgetRendererProps["stepData"],
262
+ errors: WidgetRendererProps["errors"],
263
+ ): PipelineContext {
166
264
  const steps: Record<string, PipelineContext["steps"][string]> = {}
167
- if (stepData) {
168
- for (const [id, result] of Object.entries(stepData)) {
169
- steps[id] = { ...result, _step: id }
170
- }
265
+ for (const [id, result] of Object.entries(stepData ?? {})) {
266
+ steps[id] = { ...result, _step: id }
171
267
  }
172
- const pipelineContext: PipelineContext = {
173
- steps,
174
- keys,
175
- errors,
176
- }
177
- const widgetProps: WidgetProps = { keys, context: pipelineContext }
268
+ return { steps, keys, errors }
269
+ }
270
+
271
+ export function WidgetRenderer({ layout, keys, stepData, errors, widgets }: WidgetRendererProps) {
272
+ const widgetProps = useMemo<WidgetProps>(
273
+ () => ({ keys, context: buildPipelineContext(keys, stepData, errors) }),
274
+ [keys, stepData, errors],
275
+ )
178
276
  const normalized = normalizeLayout(layout)
179
277
 
180
278
  if ("tabs" in normalized) {
@@ -1,4 +1,5 @@
1
1
  import type { ReactNode } from "react"
2
+ import { FOCUS_RING } from "../lib/focus-ring.js"
2
3
 
3
4
  export interface DrillButtonProps {
4
5
  /** Button label. */
@@ -27,7 +28,7 @@ export function DrillButton({ children, onDrill, icon, size = "sm", ariaLabel }:
27
28
  type="button"
28
29
  onClick={onDrill}
29
30
  aria-label={ariaLabel}
30
- className={`border-border text-foreground hover:bg-muted focus-visible:ring-ring inline-flex items-center gap-1 rounded-md border font-medium transition-colors outline-none focus-visible:ring-2 ${pad}`}
31
+ className={`border-border text-foreground hover:border-focus hover:bg-accent hover:text-accent-foreground inline-flex items-center gap-1 rounded-md border font-medium transition-colors ${FOCUS_RING} ${pad}`}
31
32
  >
32
33
  {icon && <span aria-hidden="true">{icon}</span>}
33
34
  {children}
@@ -0,0 +1,81 @@
1
+ import type { ReactNode } from "react"
2
+ import { TONE_ICON } from "../lib/tone-utils.js"
3
+ import { cn } from "../lib/utils.js"
4
+
5
+ /** Heading element for an empty/error state title (the surface decides the outline level). */
6
+ export type StateHeadingLevel = "h2" | "h3" | "h4" | "p"
7
+
8
+ export interface EmptyStateProps {
9
+ /**
10
+ * Icon or mark above the title, rendered decoratively (`aria-hidden`): a
11
+ * Lucide icon for the concrete situation (e.g. `<CircleCheck />` for "all
12
+ * done"), or a brand mark (an app passes its logo; a customer brand passes
13
+ * its own or nothing). An SVG renders at 24 px unless it carries its own
14
+ * `size-*` class, so a mark can be larger (`<Komet className="size-12" />`).
15
+ */
16
+ icon?: ReactNode
17
+ /** Colour of the icon slot: `"neutral"` (muted, default) or `"success"` ("all done"). */
18
+ tone?: "neutral" | "success"
19
+ /** What is empty, in plain words ("No open incidents"). */
20
+ title: ReactNode
21
+ /** One help line: why it is empty or what to do next. */
22
+ description?: ReactNode
23
+ /** The primary next step (a `Button`). */
24
+ action?: ReactNode
25
+ /** An optional second, quieter step. Empty states carry at most two actions. */
26
+ secondaryAction?: ReactNode
27
+ /** Element of the title. Default `"h3"` (inside a widget below its own heading). */
28
+ titleAs?: StateHeadingLevel
29
+ className?: string
30
+ }
31
+
32
+ /** The icon slot in the tone's icon colour (`TONE_ICON`): quiet, or the success tone. */
33
+ const ICON_TONE: Record<NonNullable<EmptyStateProps["tone"]>, string> = {
34
+ neutral: TONE_ICON.neutral,
35
+ success: TONE_ICON.success,
36
+ }
37
+
38
+ /**
39
+ * The one empty-state pattern: icon or mark, a title (600), one help line and
40
+ * up to two actions, centred in the space the content would take. Static (no
41
+ * live region): it replaces content, it does not announce a change.
42
+ */
43
+ export function EmptyState({
44
+ icon,
45
+ tone = "neutral",
46
+ title,
47
+ description,
48
+ action,
49
+ secondaryAction,
50
+ titleAs: Title = "h3",
51
+ className,
52
+ }: EmptyStateProps) {
53
+ return (
54
+ <div
55
+ data-slot="empty-state"
56
+ className={cn("flex flex-col items-center gap-2 px-4 py-8 text-center", className)}
57
+ >
58
+ {icon && (
59
+ <div
60
+ aria-hidden="true"
61
+ className={cn(
62
+ "mb-1 flex items-center justify-center [&_svg:not([class*='size-'])]:size-6",
63
+ ICON_TONE[tone],
64
+ )}
65
+ >
66
+ {icon}
67
+ </div>
68
+ )}
69
+ <Title className="text-foreground text-base font-semibold">{title}</Title>
70
+ {description && (
71
+ <p className="text-muted-foreground max-w-prose text-sm text-balance">{description}</p>
72
+ )}
73
+ {(action || secondaryAction) && (
74
+ <div className="mt-2 flex flex-wrap items-center justify-center gap-2">
75
+ {action}
76
+ {secondaryAction}
77
+ </div>
78
+ )}
79
+ </div>
80
+ )
81
+ }
@@ -0,0 +1,182 @@
1
+ import type { ReactNode } from "react"
2
+ import { CircleAlert, Info, RefreshCw, TriangleAlert, type LucideIcon } from "lucide-react"
3
+ import { useToolkitMessages } from "../locale/locale-provider.js"
4
+ import { Button } from "../primitives/button.js"
5
+ import { FOCUS_RING } from "../lib/focus-ring.js"
6
+ import { TONE_BORDER, TONE_ICON, TONE_SOFT } from "../lib/tone-utils.js"
7
+ import { cn } from "../lib/utils.js"
8
+ import type { StateHeadingLevel } from "./EmptyState.js"
9
+
10
+ /** Status tone of an {@link ErrorState}. */
11
+ export type ErrorStateTone = "danger" | "warning" | "info"
12
+
13
+ export interface ErrorStateProps {
14
+ /** What happened, in plain words ("This view couldn't be loaded"). */
15
+ title: ReactNode
16
+ /** What you can do next — or the human reason, when you can map the raw one. */
17
+ description?: ReactNode
18
+ /**
19
+ * The raw technical reason (engine exception, HTTP status), shown collapsed
20
+ * under a "Details" disclosure so it informs without leading.
21
+ */
22
+ details?: ReactNode
23
+ /** Summary text of the details disclosure. Default: the locale's "Details". */
24
+ detailsLabel?: string
25
+ /** Renders a retry button when set. */
26
+ onRetry?: () => void
27
+ /** Retry button label. Default: the locale's "Try again". */
28
+ retryLabel?: string
29
+ /** Further actions after the retry button (e.g. a link to another view). */
30
+ actions?: ReactNode
31
+ /** Default `"danger"`. `"warning"` for partial failures, `"info"` for notices. */
32
+ tone?: ErrorStateTone
33
+ /** Replace the tone's default Lucide icon. */
34
+ icon?: LucideIcon
35
+ /**
36
+ * `"block"` (default): the state that replaces a whole view or widget body.
37
+ * `"inline"`: a compact alert above content that still renders.
38
+ */
39
+ variant?: "block" | "inline"
40
+ /**
41
+ * Live-region role. Default `"alert"` — an error that appears after the
42
+ * fact is announced. Use `"status"` for a quieter notice, or `"none"` when
43
+ * the state is part of the first paint.
44
+ */
45
+ role?: "alert" | "status" | "none"
46
+ /** Element of the title. Default `"h2"` for `block`, `"p"` for `inline`. */
47
+ titleAs?: StateHeadingLevel
48
+ className?: string
49
+ }
50
+
51
+ const STATE_ICON: Record<ErrorStateTone, LucideIcon> = {
52
+ danger: CircleAlert,
53
+ warning: TriangleAlert,
54
+ info: Info,
55
+ }
56
+
57
+ /** Per-variant geometry: `block` replaces a surface, `inline` sits above content. */
58
+ const VARIANT = {
59
+ block: {
60
+ box: "gap-4 p-4",
61
+ icon: "size-5",
62
+ title: "text-base",
63
+ defaultTitleAs: "h2",
64
+ retry: "default",
65
+ },
66
+ inline: {
67
+ box: "gap-3 px-4 py-3 text-sm",
68
+ icon: "size-4",
69
+ title: "text-sm",
70
+ defaultTitleAs: "p",
71
+ retry: "outline",
72
+ },
73
+ } as const satisfies Record<
74
+ NonNullable<ErrorStateProps["variant"]>,
75
+ {
76
+ box: string
77
+ icon: string
78
+ title: string
79
+ defaultTitleAs: StateHeadingLevel
80
+ retry: "default" | "outline"
81
+ }
82
+ >
83
+
84
+ /**
85
+ * The one error pattern: a Lucide icon, a title that says what happened, a
86
+ * line that says what you can do, the raw reason collapsed under "Details",
87
+ * and a retry. `inline` is the alert form above content that still renders
88
+ * (failed data steps); `block` replaces a whole view or widget body.
89
+ */
90
+ export function ErrorState({
91
+ title,
92
+ description,
93
+ details,
94
+ detailsLabel,
95
+ onRetry,
96
+ retryLabel,
97
+ actions,
98
+ tone = "danger",
99
+ icon,
100
+ variant = "block",
101
+ role = "alert",
102
+ titleAs,
103
+ className,
104
+ }: ErrorStateProps) {
105
+ const style = VARIANT[variant]
106
+ const Icon = icon ?? STATE_ICON[tone]
107
+ const Title = titleAs ?? style.defaultTitleAs
108
+ // The status colour as rim, tint and icon; every word on the tint is the
109
+ // foreground (TONE_SOFT, CI §3.3). On dark the tint is transparent and the
110
+ // rim carries the tone.
111
+ return (
112
+ <div
113
+ data-slot="error-state"
114
+ data-variant={variant}
115
+ data-tone={tone}
116
+ role={role === "none" ? undefined : role}
117
+ className={cn(
118
+ "flex items-start rounded-md border",
119
+ style.box,
120
+ TONE_SOFT[tone],
121
+ TONE_BORDER[tone],
122
+ className,
123
+ )}
124
+ >
125
+ <Icon aria-hidden="true" className={cn("mt-0.5 shrink-0", style.icon, TONE_ICON[tone])} />
126
+ <div className="flex min-w-0 flex-1 flex-col gap-1">
127
+ <Title className={cn("font-semibold", style.title)}>{title}</Title>
128
+ {description && <div className="text-sm break-words">{description}</div>}
129
+ {details && <ErrorDetails label={detailsLabel}>{details}</ErrorDetails>}
130
+ <ErrorActions
131
+ onRetry={onRetry}
132
+ retryLabel={retryLabel}
133
+ retryVariant={style.retry}
134
+ actions={actions}
135
+ />
136
+ </div>
137
+ </div>
138
+ )
139
+ }
140
+
141
+ /**
142
+ * The raw reason, collapsed: a native disclosure (keyboard + screen-reader
143
+ * ready). It sits on the tint, so its text stays the foreground; the collapse
144
+ * and the small mono type keep it quieter than the title.
145
+ */
146
+ function ErrorDetails({ label, children }: { label: string | undefined; children: ReactNode }) {
147
+ const messages = useToolkitMessages()
148
+ return (
149
+ <details className="text-xs">
150
+ <summary className={`w-fit cursor-pointer rounded-sm py-0.5 select-none ${FOCUS_RING}`}>
151
+ {label ?? messages.common.details}
152
+ </summary>
153
+ <pre className="mt-1 font-mono break-words whitespace-pre-wrap">{children}</pre>
154
+ </details>
155
+ )
156
+ }
157
+
158
+ function ErrorActions({
159
+ onRetry,
160
+ retryLabel,
161
+ retryVariant,
162
+ actions,
163
+ }: {
164
+ onRetry: (() => void) | undefined
165
+ retryLabel: string | undefined
166
+ retryVariant: "default" | "outline"
167
+ actions: ReactNode
168
+ }) {
169
+ const messages = useToolkitMessages()
170
+ if (!onRetry && !actions) return null
171
+ return (
172
+ <div className="mt-2 flex flex-wrap items-center gap-2">
173
+ {onRetry && (
174
+ <Button type="button" size="sm" variant={retryVariant} onClick={onRetry}>
175
+ <RefreshCw aria-hidden="true" />
176
+ {retryLabel ?? messages.common.retry}
177
+ </Button>
178
+ )}
179
+ {actions}
180
+ </div>
181
+ )
182
+ }