@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,225 @@
1
+ import type { ReactNode } from "react"
2
+ import { renderToStaticMarkup } from "react-dom/server"
3
+ import { CircleCheck } from "lucide-react"
4
+ import { describe, expect, it } from "vitest"
5
+ import { LocaleProvider } from "../locale/locale-provider.js"
6
+ import { TOOLKIT_MESSAGES } from "../locale/toolkit-messages.js"
7
+ import { FOCUS_RING } from "../lib/focus-ring.js"
8
+ import { createToast } from "../lib/toast-model.js"
9
+ import { TONE_BORDER, TONE_ICON, TONE_SOFT } from "../lib/tone-utils.js"
10
+ import { attribute, openingTags } from "../render-scan.js"
11
+ import { EmptyState } from "./EmptyState.js"
12
+ import { ErrorState } from "./ErrorState.js"
13
+ import { ToastProvider, ToastViewport, useToast, type ToastApi } from "./Toast.js"
14
+
15
+ /**
16
+ * Accessibility contract of the feedback primitives, pinned on their SSR
17
+ * markup (the house render path — no jsdom): roles, live regions, accessible
18
+ * names in the active locale, native focusable controls, nothing that steals
19
+ * focus.
20
+ */
21
+
22
+ const de = TOOLKIT_MESSAGES.de
23
+ const inGerman = (node: ReactNode) =>
24
+ renderToStaticMarkup(<LocaleProvider locale="de-DE">{node}</LocaleProvider>)
25
+ const noop = (): void => {}
26
+
27
+ /** No positive tabindex and no autofocus anywhere — feedback never steals focus. */
28
+ function expectNoFocusTheft(html: string) {
29
+ expect(html).not.toMatch(/autofocus/i)
30
+ for (const value of html.match(/tabindex="(-?\d+)"/gi) ?? []) {
31
+ expect(Number(/"(-?\d+)"/.exec(value)?.[1])).toBeLessThanOrEqual(0)
32
+ }
33
+ }
34
+
35
+ describe("Toast", () => {
36
+ const toasts = [
37
+ createToast(1, { tone: "success", title: "Job retried." }),
38
+ createToast(2, { tone: "danger", title: "Retry failed.", description: "Engine down (503)" }),
39
+ ]
40
+
41
+ it("renders ONE persistent live region: role=status, aria-live=polite, a localized name", () => {
42
+ const html = inGerman(<ToastViewport toasts={toasts} onDismiss={noop} />)
43
+ const [region, ...others] = openingTags(html, "div").filter((t) => /role="status"/.test(t))
44
+ expect(others).toHaveLength(0)
45
+ expect(attribute(region ?? "", "aria-live")).toBe("polite")
46
+ expect(attribute(region ?? "", "aria-label")).toBe(de.common.notifications)
47
+ // An empty stack keeps the region mounted, so the first toast is announced.
48
+ const empty = inGerman(<ToastViewport toasts={[]} onDismiss={noop} />)
49
+ expect(empty).toContain('role="status"')
50
+ expect(empty).toContain('aria-live="polite"')
51
+ })
52
+
53
+ it("shows the status as edge + icon next to plain text, never as coloured text", () => {
54
+ const html = inGerman(<ToastViewport toasts={toasts} onDismiss={noop} />)
55
+ const cards = openingTags(html, "div").filter((t) => /data-slot="toast"/.test(t))
56
+ expect(cards.map((t) => attribute(t, "data-tone"))).toEqual(["success", "danger"])
57
+ expect(cards[0]).toMatch(/border-l-success/)
58
+ expect(cards[1]).toMatch(/border-l-danger/)
59
+ for (const card of cards) expect(card).not.toMatch(/\btext-(success|danger|warning|info)\b/)
60
+ // The edge survives the dark 3:1 rim (a later dark: rule would paint over it).
61
+ expect(cards[0]).toMatch(/dark:border-input/)
62
+ expect(cards[0]).toMatch(/dark:border-l-success/)
63
+ // Icons are decorative and drawn in the tone colour; the text carries the message.
64
+ const svgs = openingTags(html, "svg")
65
+ for (const svg of svgs) expect(attribute(svg, "aria-hidden")).toBe("true")
66
+ expect(attribute(svgs[0] ?? "", "class")).toContain(TONE_ICON.success)
67
+ expect(attribute(svgs[2] ?? "", "class")).toContain(TONE_ICON.danger)
68
+ expect(html).toContain("Job retried.")
69
+ expect(html).toContain("Engine down (503)")
70
+ })
71
+
72
+ it("gives every toast a native close button named in the locale, and steals no focus", () => {
73
+ const html = inGerman(<ToastViewport toasts={toasts} onDismiss={noop} />)
74
+ const buttons = openingTags(html, "button")
75
+ expect(buttons).toHaveLength(2)
76
+ for (const b of buttons) {
77
+ expect(attribute(b, "type")).toBe("button")
78
+ expect(attribute(b, "aria-label")).toBe(de.common.dismiss)
79
+ expect(attribute(b, "class")).toContain(FOCUS_RING)
80
+ }
81
+ expectNoFocusTheft(html)
82
+ })
83
+
84
+ it("nests idempotently: one provider, one viewport", () => {
85
+ const html = renderToStaticMarkup(
86
+ <ToastProvider>
87
+ <ToastProvider>
88
+ <p>body</p>
89
+ </ToastProvider>
90
+ </ToastProvider>,
91
+ )
92
+ expect(html.match(/data-slot="toast-viewport"/g)).toHaveLength(1)
93
+ expect(html).toContain("<p>body</p>")
94
+ })
95
+
96
+ it("useToast outside a provider is a safe no-op", () => {
97
+ let api: ToastApi | undefined
98
+ function Probe() {
99
+ api = useToast()
100
+ return null
101
+ }
102
+ renderToStaticMarkup(<Probe />)
103
+ expect(api?.show({ title: "x" })).toBe(-1)
104
+ expect(() => api?.dismiss(1)).not.toThrow()
105
+ })
106
+ })
107
+
108
+ describe("EmptyState", () => {
109
+ it("is static (no live region), titles with h3 by default, hides its icon from AT", () => {
110
+ const html = inGerman(
111
+ <EmptyState
112
+ icon={<CircleCheck />}
113
+ tone="success"
114
+ title="Keine offenen Incidents"
115
+ description="Alles erledigt."
116
+ />,
117
+ )
118
+ expect(html).not.toMatch(/role="(status|alert)"|aria-live/)
119
+ expect(html).toMatch(/<h3[^>]*>Keine offenen Incidents<\/h3>/)
120
+ const iconSlot = openingTags(html, "div").find((t) => /aria-hidden="true"/.test(t))
121
+ // The success tone on the icon (TONE_ICON), never on the words.
122
+ expect(iconSlot).toContain(TONE_ICON.success)
123
+ expect(html).not.toMatch(/text-success-ink/)
124
+ expect(html).toContain("Alles erledigt.")
125
+ })
126
+
127
+ it("sizes an icon to 24 px but lets a mark keep its own size-* class (the Komet needs room)", () => {
128
+ const html = renderToStaticMarkup(
129
+ <EmptyState icon={<svg className="size-12" aria-label="mark" />} title="Willkommen" />,
130
+ )
131
+ const slot = openingTags(html, "div").find((t) => /aria-hidden="true"/.test(t)) ?? ""
132
+ const slotClass = attribute(slot, "class") ?? ""
133
+ // The default applies only to an SVG without its own size class…
134
+ expect(slotClass).toContain("[&_svg:not([class*='size-'])]:size-6")
135
+ // …and never as a clamp that outranks the mark's class (0,1,1 beats 0,1,0).
136
+ expect(slotClass).not.toMatch(/\[&_svg\]:size-/)
137
+ expect(html).toContain('<svg class="size-12"')
138
+ })
139
+
140
+ it("takes the heading level of its surface and at most two actions", () => {
141
+ const html = renderToStaticMarkup(
142
+ <EmptyState
143
+ titleAs="h2"
144
+ title="Nothing here"
145
+ action={<button type="button">First</button>}
146
+ secondaryAction={<button type="button">Second</button>}
147
+ />,
148
+ )
149
+ expect(html).toMatch(/<h2[^>]*>Nothing here<\/h2>/)
150
+ expect(openingTags(html, "button")).toHaveLength(2)
151
+ expect(renderToStaticMarkup(<EmptyState title="Bare" />)).not.toContain("<button")
152
+ })
153
+ })
154
+
155
+ describe("ErrorState", () => {
156
+ it("announces itself (role=alert) with a heading, the hint, details and a retry in the locale", () => {
157
+ const html = inGerman(
158
+ <ErrorState
159
+ title="Die Ansicht konnte nicht geladen werden"
160
+ description="Versuch es noch mal."
161
+ details="[503 ProcessEngineException] Engine is shutting down"
162
+ onRetry={noop}
163
+ />,
164
+ )
165
+ const root = openingTags(html, "div")[0] ?? ""
166
+ expect(attribute(root, "role")).toBe("alert")
167
+ expect(attribute(root, "data-variant")).toBe("block")
168
+ expect(html).toMatch(/<h2[^>]*>Die Ansicht konnte nicht geladen werden<\/h2>/)
169
+ // The raw reason sits collapsed in a native disclosure (keyboard-ready).
170
+ expect(html).toMatch(/<details[^>]*><summary[^>]*>Details<\/summary>/)
171
+ expect(html).not.toMatch(/<details[^>]*\sopen/)
172
+ const [retry] = openingTags(html, "button")
173
+ expect(attribute(retry ?? "", "type")).toBe("button")
174
+ expect(html).toContain(`${de.common.retry}</button>`)
175
+ expectNoFocusTheft(html)
176
+ })
177
+
178
+ it("supports a quieter status role, no role at all, and the inline alert form", () => {
179
+ const status = renderToStaticMarkup(<ErrorState title="Notice" role="status" tone="info" />)
180
+ expect(attribute(openingTags(status, "div")[0] ?? "", "role")).toBe("status")
181
+ const none = renderToStaticMarkup(<ErrorState title="Static" role="none" />)
182
+ expect(attribute(openingTags(none, "div")[0] ?? "", "role")).toBeUndefined()
183
+ const inline = renderToStaticMarkup(
184
+ <ErrorState variant="inline" title="Partial" tone="warning" />,
185
+ )
186
+ expect(inline).toMatch(/<p[^>]*>Partial<\/p>/)
187
+ expect(attribute(openingTags(inline, "div")[0] ?? "", "data-tone")).toBe("warning")
188
+ })
189
+
190
+ it.each(["danger", "warning", "info"] as const)(
191
+ "shows the %s tone as rim, tint and icon; every word on the tint is the foreground (CI §3.3)",
192
+ (tone) => {
193
+ const html = renderToStaticMarkup(
194
+ <ErrorState title="x" description="what to do" details="raw" tone={tone} onRetry={noop} />,
195
+ )
196
+ const rootClass = attribute(openingTags(html, "div")[0] ?? "", "class") ?? ""
197
+ expect(rootClass).toContain(TONE_SOFT[tone])
198
+ expect(rootClass).toContain(TONE_BORDER[tone])
199
+ expect(attribute(openingTags(html, "svg")[0] ?? "", "class")).toContain(TONE_ICON[tone])
200
+ // No quiet text, no ink and no status colour as text anywhere on the tint
201
+ // (the icons, which carry the tone colour, set aside).
202
+ const words = html.replace(/<svg[\s\S]*?<\/svg>/g, "")
203
+ expect(words).not.toMatch(/text-muted-foreground|text-\w+-ink|text-(danger|warning|info)\b/)
204
+ // The disclosure is a kit control: the kit's opaque focus ring.
205
+ expect(attribute(openingTags(html, "summary")[0] ?? "", "class")).toContain(FOCUS_RING)
206
+ },
207
+ )
208
+
209
+ it("has no retry without onRetry, and takes custom labels", () => {
210
+ expect(renderToStaticMarkup(<ErrorState title="x" />)).not.toContain("<button")
211
+ const html = renderToStaticMarkup(
212
+ <ErrorState
213
+ title="x"
214
+ details="raw"
215
+ detailsLabel="Technical"
216
+ onRetry={noop}
217
+ retryLabel="Reload"
218
+ actions={<a href="#help">Help</a>}
219
+ />,
220
+ )
221
+ expect(html).toContain("Technical</summary>")
222
+ expect(html).toContain("Reload</button>")
223
+ expect(html).toContain('<a href="#help">Help</a>')
224
+ })
225
+ })
@@ -0,0 +1,54 @@
1
+ import { renderToStaticMarkup } from "react-dom/server"
2
+ import { describe, expect, it } from "vitest"
3
+ import type { ToneVariant } from "../lib/tone-utils.js"
4
+ import { KpiGrid } from "./KpiGrid.js"
5
+ import { CountPill, LivePill } from "./Pills.js"
6
+
7
+ /**
8
+ * Status model (owner decision 2e-f, CI §3.3): coloured digits are retired.
9
+ * A figure — a KPI value, a trend, a count — stays in the foreground; the tone
10
+ * shows as a dot, a border or a tint next to it. These components carry
11
+ * figures, so no tone colour may reach their text (SSR, the house render path).
12
+ */
13
+
14
+ const STATUS = ["info", "success", "warning", "danger"] as const
15
+ const TONES: ToneVariant[] = ["neutral", ...STATUS]
16
+ /** Any tone used as a TEXT colour: the raw status colour or its -ink role. */
17
+ const TONE_AS_TEXT = /\btext-(?:success|warning|danger|info)(?:-ink)?\b/
18
+
19
+ describe("figures never take a tone colour", () => {
20
+ it("KpiGrid: trend lines and toned cells keep their text in the foreground", () => {
21
+ const html = renderToStaticMarkup(
22
+ <KpiGrid
23
+ cells={[
24
+ { label: "Up", value: "12", trend: "+12% vs last week", trendDirection: "up" },
25
+ { label: "Down", value: "3", trend: "-4% vs last week", trendDirection: "down" },
26
+ { label: "Flat", value: "7", trend: "±0", trendDirection: "flat" },
27
+ ...STATUS.map((tone) => ({ label: tone, value: "1", tone })),
28
+ ]}
29
+ />,
30
+ )
31
+ expect(html).not.toMatch(TONE_AS_TEXT)
32
+ // The tone is still there — as a dot.
33
+ expect(html).toMatch(/bg-danger\b/)
34
+ expect(html).toMatch(/bg-success\b/)
35
+ // A toned trend reads in the foreground, an untoned one stays quiet.
36
+ expect(html).toMatch(/text-foreground[^"]*"><span aria-hidden="true"[^>]*bg-danger/)
37
+ expect(html).toMatch(/text-muted-foreground">±0/)
38
+ })
39
+
40
+ it.each(TONES)(
41
+ "CountPill and LivePill (%s): number and label in the foreground, tone on the rim",
42
+ (tone) => {
43
+ const count = renderToStaticMarkup(<CountPill tone={tone}>42</CountPill>)
44
+ const live = renderToStaticMarkup(<LivePill tone={tone}>Live</LivePill>)
45
+ for (const html of [count, live]) {
46
+ expect(html).not.toMatch(TONE_AS_TEXT)
47
+ if (tone !== "neutral") {
48
+ expect(html).toContain("text-foreground")
49
+ expect(html).toContain(`border-${tone}`)
50
+ }
51
+ }
52
+ },
53
+ )
54
+ })
package/src/fonts.css ADDED
@@ -0,0 +1,19 @@
1
+ /*
2
+ * @miragon/mcp-toolkit-ui/fonts.css — OPT-IN: Geist and Geist Mono, self-hosted
3
+ * through your bundler (no CDN). Import it next to globals.css/theme.css:
4
+ *
5
+ * @import "@miragon/mcp-toolkit-ui/globals.css";
6
+ * @import "@miragon/mcp-toolkit-ui/fonts.css";
7
+ *
8
+ * Cost: a bundler that inlines assets (vite-plugin-singlefile, MCP App views)
9
+ * embeds the woff2 files as base64 in every view. When a server can serve the
10
+ * files, prefer `fontFaces({ baseUrl })`, which references them by URL (and
11
+ * allow that origin in the view's CSP resourceDomains). The token stacks
12
+ * (--font-sans, --font-mono) name both families first, so either path works.
13
+ * A host's own font stays the fallback behind them: McpToolkitApp moves it
14
+ * to --host-font-sans/--host-font-mono (adoptHostFontVariables); an app with
15
+ * its own host-document layer calls that helper after applying the host's
16
+ * style variables, or the host's --font-sans replaces the whole stack.
17
+ */
18
+ @import "@fontsource-variable/geist/wght.css";
19
+ @import "@fontsource-variable/geist-mono/wght.css";
package/src/globals.css CHANGED
@@ -1,168 +1,12 @@
1
+ /*
2
+ * @miragon/mcp-toolkit-ui/globals.css — the one-line stylesheet for an app
3
+ * entry: Tailwind, the toolkit's design tokens (./theme.css, Miragon by
4
+ * default) and the toolkit's own class sources. Fonts are opt-in: add
5
+ * `@import "@miragon/mcp-toolkit-ui/fonts.css"` or serve them with
6
+ * `fontFaces()`. A brand overrides the role variables after this import —
7
+ * see docs/guides/white-labeling.md.
8
+ */
1
9
  @import "tailwindcss";
2
- @import "tw-animate-css";
3
- @import "@fontsource-variable/geist";
10
+ @import "./theme.css";
4
11
 
5
12
  @source "../**/*.{ts,tsx}";
6
-
7
- @custom-variant dark (&:is(.dark *));
8
-
9
- @theme inline {
10
- --font-heading: var(--font-sans);
11
- --font-sans: "Geist Variable", sans-serif;
12
- --color-sidebar-ring: var(--sidebar-ring);
13
- --color-sidebar-border: var(--sidebar-border);
14
- --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
15
- --color-sidebar-accent: var(--sidebar-accent);
16
- --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
17
- --color-sidebar-primary: var(--sidebar-primary);
18
- --color-sidebar-foreground: var(--sidebar-foreground);
19
- --color-sidebar: var(--sidebar);
20
- --color-chart-5: var(--chart-5);
21
- --color-chart-4: var(--chart-4);
22
- --color-chart-3: var(--chart-3);
23
- --color-chart-2: var(--chart-2);
24
- --color-chart-1: var(--chart-1);
25
- --color-ring: var(--ring);
26
- --color-input: var(--input);
27
- --color-border: var(--border);
28
- --color-destructive: var(--destructive);
29
- --color-accent-foreground: var(--accent-foreground);
30
- --color-accent: var(--accent);
31
- --color-muted-foreground: var(--muted-foreground);
32
- --color-muted: var(--muted);
33
- --color-secondary-foreground: var(--secondary-foreground);
34
- --color-secondary: var(--secondary);
35
- --color-primary-foreground: var(--primary-foreground);
36
- --color-primary: var(--primary);
37
- --color-popover-foreground: var(--popover-foreground);
38
- --color-popover: var(--popover);
39
- --color-card-foreground: var(--card-foreground);
40
- --color-card: var(--card);
41
- --color-foreground: var(--foreground);
42
- --color-background: var(--background);
43
- /* Semantic status tones — the token surface behind ToneVariant/TONE_* maps.
44
- Each tone exposes a readable foreground (`text-danger`) and a tinted
45
- surface (`bg-danger-soft`); both carry light + dark values so tone-based
46
- components (Pills, KpiGrid, WidgetHeader, FilterBar) re-theme automatically. */
47
- --color-danger: var(--danger);
48
- --color-danger-soft: var(--danger-soft);
49
- --color-warning: var(--warning);
50
- --color-warning-soft: var(--warning-soft);
51
- --color-success: var(--success);
52
- --color-success-soft: var(--success-soft);
53
- --color-info: var(--info);
54
- --color-info-soft: var(--info-soft);
55
- --radius-sm: calc(var(--radius) * 0.6);
56
- --radius-md: calc(var(--radius) * 0.8);
57
- --radius-lg: var(--radius);
58
- --radius-xl: calc(var(--radius) * 1.4);
59
- --radius-2xl: calc(var(--radius) * 1.8);
60
- --radius-3xl: calc(var(--radius) * 2.2);
61
- --radius-4xl: calc(var(--radius) * 2.6);
62
- }
63
-
64
- :root {
65
- --background: oklch(1 0 0);
66
- --foreground: oklch(0.145 0 0);
67
- --card: oklch(1 0 0);
68
- --card-foreground: oklch(0.145 0 0);
69
- --popover: oklch(1 0 0);
70
- --popover-foreground: oklch(0.145 0 0);
71
- --primary: oklch(0.205 0 0);
72
- --primary-foreground: oklch(0.985 0 0);
73
- --secondary: oklch(0.97 0 0);
74
- --secondary-foreground: oklch(0.205 0 0);
75
- --muted: oklch(0.97 0 0);
76
- --muted-foreground: oklch(0.556 0 0);
77
- --accent: oklch(0.97 0 0);
78
- --accent-foreground: oklch(0.205 0 0);
79
- --destructive: oklch(0.577 0.245 27.325);
80
- --border: oklch(0.922 0 0);
81
- --input: oklch(0.922 0 0);
82
- --ring: oklch(0.708 0 0);
83
- --chart-1: oklch(0.87 0 0);
84
- --chart-2: oklch(0.556 0 0);
85
- --chart-3: oklch(0.439 0 0);
86
- --chart-4: oklch(0.371 0 0);
87
- --chart-5: oklch(0.269 0 0);
88
- --radius: 0.625rem;
89
- --sidebar: oklch(0.985 0 0);
90
- --sidebar-foreground: oklch(0.145 0 0);
91
- --sidebar-primary: oklch(0.205 0 0);
92
- --sidebar-primary-foreground: oklch(0.985 0 0);
93
- --sidebar-accent: oklch(0.97 0 0);
94
- --sidebar-accent-foreground: oklch(0.205 0 0);
95
- --sidebar-border: oklch(0.922 0 0);
96
- --sidebar-ring: oklch(0.708 0 0);
97
- /* Semantic status tones (light). Foreground = readable on a card; *-soft =
98
- a tinted surface for badges/dots/icon tiles. Neutral tone reuses the
99
- muted ramp and needs no dedicated token. */
100
- --danger: oklch(0.55 0.21 27.3);
101
- --danger-soft: oklch(0.95 0.04 27.3);
102
- --warning: oklch(0.62 0.16 70);
103
- --warning-soft: oklch(0.95 0.05 80);
104
- --success: oklch(0.58 0.15 152);
105
- --success-soft: oklch(0.95 0.05 152);
106
- --info: oklch(0.55 0.2 255);
107
- --info-soft: oklch(0.95 0.04 255);
108
- }
109
-
110
- .dark {
111
- --background: oklch(0.145 0 0);
112
- --foreground: oklch(0.985 0 0);
113
- --card: oklch(0.205 0 0);
114
- --card-foreground: oklch(0.985 0 0);
115
- --popover: oklch(0.205 0 0);
116
- --popover-foreground: oklch(0.985 0 0);
117
- --primary: oklch(0.922 0 0);
118
- --primary-foreground: oklch(0.205 0 0);
119
- --secondary: oklch(0.269 0 0);
120
- --secondary-foreground: oklch(0.985 0 0);
121
- --muted: oklch(0.269 0 0);
122
- --muted-foreground: oklch(0.708 0 0);
123
- --accent: oklch(0.269 0 0);
124
- --accent-foreground: oklch(0.985 0 0);
125
- --destructive: oklch(0.704 0.191 22.216);
126
- --border: oklch(1 0 0 / 10%);
127
- --input: oklch(1 0 0 / 15%);
128
- --ring: oklch(0.556 0 0);
129
- --chart-1: oklch(0.87 0 0);
130
- --chart-2: oklch(0.556 0 0);
131
- --chart-3: oklch(0.439 0 0);
132
- --chart-4: oklch(0.371 0 0);
133
- --chart-5: oklch(0.269 0 0);
134
- --sidebar: oklch(0.205 0 0);
135
- --sidebar-foreground: oklch(0.985 0 0);
136
- --sidebar-primary: oklch(0.488 0.243 264.376);
137
- --sidebar-primary-foreground: oklch(0.985 0 0);
138
- --sidebar-accent: oklch(0.269 0 0);
139
- --sidebar-accent-foreground: oklch(0.985 0 0);
140
- --sidebar-border: oklch(1 0 0 / 10%);
141
- --sidebar-ring: oklch(0.556 0 0);
142
- /* Semantic status tones (dark). Brighter foregrounds for contrast on the
143
- dark surface; *-soft is a low-alpha tint of the same hue. */
144
- --danger: oklch(0.7 0.19 22.2);
145
- --danger-soft: oklch(0.7 0.19 22.2 / 18%);
146
- --warning: oklch(0.78 0.15 75);
147
- --warning-soft: oklch(0.78 0.15 75 / 18%);
148
- --success: oklch(0.72 0.17 152);
149
- --success-soft: oklch(0.72 0.17 152 / 18%);
150
- --info: oklch(0.7 0.16 255);
151
- --info-soft: oklch(0.7 0.16 255 / 18%);
152
- }
153
-
154
- * {
155
- font-family: "Geist Variable", ui-sans-serif, system-ui, sans-serif !important;
156
- }
157
-
158
- @layer base {
159
- *,
160
- *::before,
161
- *::after {
162
- @apply border-border outline-ring/50;
163
- }
164
- html,
165
- body {
166
- @apply bg-background text-foreground;
167
- }
168
- }
@@ -1,5 +1,9 @@
1
1
  import { describe, expect, it } from "vitest"
2
- import { decodeRecoveredResult, resolveRecoveryToolName } from "./use-tool-result-recovery.js"
2
+ import {
3
+ decodeRecoveredResult,
4
+ isRecoveryReady,
5
+ resolveRecoveryToolName,
6
+ } from "./use-tool-result-recovery.js"
3
7
 
4
8
  // The hook's effect wiring (single-flight, StrictMode re-run, host-payload
5
9
  // precedence) is exercised end-to-end by the consumer's Playwright host
@@ -63,3 +67,23 @@ describe("decodeRecoveredResult", () => {
63
67
  expect(decodeRecoveredResult({ content: [] }, isView)).toBeNull()
64
68
  })
65
69
  })
70
+
71
+ describe("isRecoveryReady (toolkit#176)", () => {
72
+ const base = { resultReady: false, assumedReady: false, cancelled: false, errored: false }
73
+
74
+ it("fires once a result latched or the stripped-result grace period passed", () => {
75
+ expect(isRecoveryReady(base)).toBe(false)
76
+ expect(isRecoveryReady({ ...base, resultReady: true })).toBe(true)
77
+ expect(isRecoveryReady({ ...base, assumedReady: true })).toBe(true)
78
+ })
79
+
80
+ it("never re-executes a call that failed (K14: isError renders the error instead)", () => {
81
+ expect(isRecoveryReady({ ...base, resultReady: true, errored: true })).toBe(false)
82
+ expect(isRecoveryReady({ ...base, assumedReady: true, errored: true })).toBe(false)
83
+ })
84
+
85
+ it("never re-executes a call the host cancelled", () => {
86
+ expect(isRecoveryReady({ ...base, resultReady: true, cancelled: true })).toBe(false)
87
+ expect(isRecoveryReady({ ...base, assumedReady: true, cancelled: true })).toBe(false)
88
+ })
89
+ })
@@ -23,7 +23,10 @@ import { parseToolResult } from "../lib/parse-tool-result.js"
23
23
  * A call the host cancelled is never recovered: since mcp-use 2.7.1 the
24
24
  * cancellation latches as `status: "error"` (a `ToolCancelledError`), which
25
25
  * would otherwise count as a ready-but-invalid result — re-executing it would
26
- * override the user's decision to stop the call.
26
+ * override the user's decision to stop the call. Neither is a call that
27
+ * failed (`isError` or a transport error, {@link ToolResultRecoveryOptions.errored}):
28
+ * re-running it only repeats the failure, so the shell renders the error and
29
+ * offers an explicit retry instead (toolkit#176).
27
30
  *
28
31
  * Assumes the originating tool is a read-only render pipeline (true for all
29
32
  * toolkit `render-view`/`show_*` tools) — the re-execution must be safe to
@@ -53,6 +56,14 @@ export interface ToolResultRecoveryOptions {
53
56
  * attempt already in flight is dropped. Defaults to `false`.
54
57
  */
55
58
  cancelled?: boolean
59
+ /**
60
+ * The originating call latched a tool error (`useToolContext().status ===
61
+ * "error"` without a cancellation): an `isError` result or a failed call.
62
+ * Suppresses the recovery exactly like {@link cancelled} — the shell shows
63
+ * the error instead of silently re-running a call that just failed
64
+ * (toolkit#176). Defaults to `false`.
65
+ */
66
+ errored?: boolean
56
67
  /** The payload the host delivered (`useToolContext().toolOutput`). */
57
68
  props: unknown
58
69
  /**
@@ -124,12 +135,28 @@ export function decodeRecoveredResult<T>(
124
135
  return decoded !== null && isValid(decoded) ? (decoded as T) : null
125
136
  }
126
137
 
138
+ /**
139
+ * Whether the recovery may fire: the result latched (or is assumed stripped)
140
+ * and the call was neither cancelled nor failed. Exported for tests — this is
141
+ * the #176 rule "never recover on an error status".
142
+ */
143
+ export function isRecoveryReady(state: {
144
+ resultReady: boolean
145
+ assumedReady: boolean
146
+ cancelled: boolean
147
+ errored: boolean
148
+ }): boolean {
149
+ if (state.cancelled || state.errored) return false
150
+ return state.resultReady || state.assumedReady
151
+ }
152
+
127
153
  export function useToolResultRecovery<T>(
128
154
  options: ToolResultRecoveryOptions,
129
155
  ): ToolResultRecoveryState<T> {
130
156
  const {
131
157
  resultReady,
132
158
  cancelled = false,
159
+ errored = false,
133
160
  props,
134
161
  isValid,
135
162
  toolInput,
@@ -160,15 +187,16 @@ export function useToolResultRecovery<T>(
160
187
  setPrevTimerInput(toolInput)
161
188
  if (assumedReady) setAssumedReady(false)
162
189
  }
190
+ const suppressed = cancelled || errored
163
191
  useEffect(() => {
164
- if (resultReady || cancelled || toolName === undefined) return
192
+ if (resultReady || suppressed || toolName === undefined) return
165
193
  const timer = setTimeout(() => setAssumedReady(true), assumeStrippedAfterMs)
166
194
  return () => clearTimeout(timer)
167
- }, [resultReady, cancelled, toolName, toolInput, assumeStrippedAfterMs])
195
+ }, [resultReady, suppressed, toolName, toolInput, assumeStrippedAfterMs])
168
196
 
169
- // A cancelled call never counts as ready, so the effect below invalidates
170
- // any in-flight attempt the same way it does for a superseded run.
171
- const ready = !cancelled && (resultReady || assumedReady)
197
+ // A cancelled or failed call never counts as ready, so the effect below
198
+ // invalidates any in-flight attempt the same way it does for a superseded run.
199
+ const ready = isRecoveryReady({ resultReady, assumedReady, cancelled, errored })
172
200
 
173
201
  useEffect(() => {
174
202
  // A new tool run (result no longer ready, or fresh tool-input identity)
@@ -0,0 +1,60 @@
1
+ import { renderToStaticMarkup } from "react-dom/server"
2
+ import { describe, expect, it, vi } from "vitest"
3
+ import { AppQueryProvider } from "../providers/query-provider.js"
4
+ import { useViewData, type ViewDataResult } from "./use-view-data.js"
5
+
6
+ type Demo = { n: number }
7
+ type CallTool = (name: string, args: object) => Promise<unknown>
8
+
9
+ /** Renders a probe once (SSR, the house path) and hands back the hook's result. */
10
+ function probe(
11
+ initialData: Demo | null,
12
+ ready: boolean,
13
+ callTool: CallTool,
14
+ key: string,
15
+ ): ViewDataResult<Demo> {
16
+ let result: ViewDataResult<Demo> | undefined
17
+ function Probe() {
18
+ result = useViewData<Demo>(
19
+ initialData,
20
+ ["use-view-data.test", key],
21
+ "demo_data",
22
+ { id: 1 },
23
+ ready,
24
+ )
25
+ return null
26
+ }
27
+ renderToStaticMarkup(
28
+ <AppQueryProvider callTool={callTool}>
29
+ <Probe />
30
+ </AppQueryProvider>,
31
+ )
32
+ if (!result) throw new Error("probe did not render")
33
+ return result
34
+ }
35
+
36
+ describe("useViewData", () => {
37
+ it("returns host-pushed data verbatim without fetching", () => {
38
+ const callTool = vi.fn<CallTool>(() => Promise.resolve({}))
39
+ const result = probe({ n: 1 }, true, callTool, "pushed")
40
+ expect(result.data).toEqual({ n: 1 })
41
+ expect(result.loading).toBe(false)
42
+ expect(result.error).toBeNull()
43
+ expect(callTool).not.toHaveBeenCalled()
44
+ })
45
+
46
+ it("is not loading before it is ready", () => {
47
+ const result = probe(null, false, vi.fn<CallTool>(), "not-ready")
48
+ expect(result).toMatchObject({ data: null, loading: false, error: null })
49
+ })
50
+
51
+ it("refetch calls the tool again: the retry an error state offers", async () => {
52
+ const callTool = vi.fn<CallTool>(() => Promise.resolve({ structuredContent: { n: 2 } }))
53
+ const result = probe(null, false, callTool, "refetch")
54
+ expect(callTool).not.toHaveBeenCalled()
55
+ result.refetch()
56
+ await vi.waitFor(() => {
57
+ expect(callTool).toHaveBeenCalledWith("demo_data", { id: 1 })
58
+ })
59
+ })
60
+ })
@@ -1,3 +1,4 @@
1
+ import { useCallback } from "react"
1
2
  import { useToolQuery } from "./use-tool-query.js"
2
3
 
3
4
  export interface ViewDataResult<T> {
@@ -5,6 +6,12 @@ export interface ViewDataResult<T> {
5
6
  /** True while self-fetching with no data yet — the first paint of a cockpit cell. */
6
7
  loading: boolean
7
8
  error: Error | null
9
+ /**
10
+ * Fetch `tool` again: the retry of an error state
11
+ * (`<ErrorState onRetry={refetch} … />`). Host-pushed data, when present,
12
+ * stays what `data` returns.
13
+ */
14
+ refetch: () => void
8
15
  }
9
16
 
10
17
  /**
@@ -27,5 +34,9 @@ export function useViewData<T>(
27
34
  ): ViewDataResult<T> {
28
35
  const query = useToolQuery<T>([...key], tool, args, { enabled: !initialData && ready })
29
36
  const data = initialData ?? query.data ?? null
30
- return { data, loading: !data && ready && !query.isError, error: query.error ?? null }
37
+ const { refetch: refetchQuery } = query
38
+ const refetch = useCallback(() => {
39
+ void refetchQuery()
40
+ }, [refetchQuery])
41
+ return { data, loading: !data && ready && !query.isError, error: query.error ?? null, refetch }
31
42
  }