@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,323 @@
1
+ import { isValidElement, type ReactElement } from "react"
2
+ import { renderToStaticMarkup } from "react-dom/server"
3
+ import { describe, expect, it } from "vitest"
4
+ import type { WidgetProps } from "@miragon/mcp-toolkit-core"
5
+ import { EmptyState } from "./components/EmptyState.js"
6
+ import { ErrorState } from "./components/ErrorState.js"
7
+ import { ToastViewport } from "./components/Toast.js"
8
+ import { LocaleProvider } from "./locale/locale-provider.js"
9
+ import { TOOLKIT_MESSAGES, type ToolkitMessages } from "./locale/toolkit-messages.js"
10
+ import { createToast } from "./lib/toast-model.js"
11
+ import { RENDER_CASES } from "./render-cases.js"
12
+ import { userFacingTexts } from "./render-scan.js"
13
+ import { StepErrors, ViewToolbar, type ViewData } from "./app/mcp-app-view-chrome.js"
14
+ import { ViewPlaceholder } from "./app/mcp-app-view-states.js"
15
+ import { LayoutBuilder } from "./app/layout-builder.js"
16
+ import { BuilderBanners } from "./app/builder/BuilderBanners.js"
17
+ import { PipelineStrip } from "./app/builder/PipelineStrip.js"
18
+ import { StatusChip } from "./app/builder/status-chip.js"
19
+ import { Toolbar } from "./app/builder/Toolbar.js"
20
+ import { WidgetCrashFallback, WidgetRenderer } from "./app/widget-renderer.js"
21
+
22
+ /**
23
+ * Render half of the i18n guard (the static half is `no-hardcoded-copy.test.ts`):
24
+ * render every component under the German locale with a MARKED catalog —
25
+ * each catalog string wrapped in ⟦…⟧ — and scan every text node and text
26
+ * attribute. Whatever is neither marked (it came from the catalog) nor a
27
+ * fixture value the case passed in (it came from props) is a hard-coded
28
+ * string that a German user would read in English.
29
+ */
30
+
31
+ const OPEN = "⟦"
32
+ const CLOSE = "⟧"
33
+
34
+ function marked(messages: ToolkitMessages): ToolkitMessages {
35
+ const mark = (value: unknown): unknown =>
36
+ typeof value === "function"
37
+ ? (...args: unknown[]) => `${OPEN}${(value as (...a: unknown[]) => string)(...args)}${CLOSE}`
38
+ : `${OPEN}${String(value)}${CLOSE}`
39
+ return Object.fromEntries(
40
+ Object.entries(messages).map(([section, entries]) => [
41
+ section,
42
+ Object.fromEntries(
43
+ Object.entries(entries as Record<string, unknown>).map(([k, v]) => [k, mark(v)]),
44
+ ),
45
+ ]),
46
+ ) as unknown as ToolkitMessages
47
+ }
48
+
49
+ const PSEUDO = marked(TOOLKIT_MESSAGES.de)
50
+
51
+ /** Every string/number a case hands to the tree through props (its fixtures). */
52
+ function fixtureStrings(node: unknown, out = new Set<string>(), seen = new Set<unknown>()) {
53
+ if (typeof node === "string" || typeof node === "number") {
54
+ const text = String(node).trim()
55
+ if (text.length >= 2) out.add(text)
56
+ return out
57
+ }
58
+ if (!node || typeof node !== "object" || seen.has(node)) return out
59
+ seen.add(node)
60
+ if (node instanceof Error) return fixtureStrings(node.message, out, seen)
61
+ if (isValidElement(node)) return fixtureStrings(node.props, out, seen)
62
+ for (const value of Array.isArray(node) ? node : Object.values(node)) {
63
+ fixtureStrings(value, out, seen)
64
+ }
65
+ return out
66
+ }
67
+
68
+ /** Text a German user would read that came neither from the catalog nor from props. */
69
+ function leaks(element: ReactElement): string[] {
70
+ const html = renderToStaticMarkup(
71
+ <LocaleProvider locale="de" toolkitMessages={PSEUDO}>
72
+ {element}
73
+ </LocaleProvider>,
74
+ )
75
+ const fixtures = [...fixtureStrings(element)].sort((a, b) => b.length - a.length)
76
+ const found: string[] = []
77
+ for (const { source, value } of userFacingTexts(html)) {
78
+ let rest = value
79
+ const innermost = /⟦[^⟦⟧]*⟧/g
80
+ while (innermost.test(rest)) rest = rest.replace(innermost, " ")
81
+ rest = rest.replace(/RC:[\w:-]+/g, " ")
82
+ for (const fixture of fixtures) rest = rest.split(fixture).join(" ")
83
+ if (/\p{L}/u.test(rest)) found.push(`${source}: "${value}" → "${rest.trim()}"`)
84
+ }
85
+ return found
86
+ }
87
+
88
+ const noop = (): void => {}
89
+ const asyncNoop = (): Promise<void> => Promise.resolve()
90
+
91
+ const view: ViewData = {
92
+ title: "Prozessdefinitionen",
93
+ context: { keys: {}, stepIds: [], stepData: {}, errors: [] },
94
+ layout: { rows: [] },
95
+ _refreshParams: { layout: { rows: [] } },
96
+ }
97
+
98
+ function Crash(): never {
99
+ throw new Error("kaputt")
100
+ }
101
+
102
+ function Fine({ keys }: WidgetProps) {
103
+ return <span>{typeof keys.label === "string" ? keys.label : ""}</span>
104
+ }
105
+
106
+ /** The toolkit chrome no catalogued render case reaches. */
107
+ const CHROME_CASES: { name: string; render: () => ReactElement }[] = [
108
+ {
109
+ name: "ViewPlaceholder waiting + slow hint",
110
+ render: () => (
111
+ <ViewPlaceholder
112
+ lifecycle={{ phase: "waiting", slow: true }}
113
+ labels={PSEUDO.view}
114
+ onRetry={noop}
115
+ />
116
+ ),
117
+ },
118
+ {
119
+ name: "ViewPlaceholder error",
120
+ render: () => (
121
+ <ViewPlaceholder
122
+ lifecycle={{ phase: "error", reason: "[503] engine down", canRetry: true }}
123
+ labels={PSEUDO.view}
124
+ onRetry={noop}
125
+ />
126
+ ),
127
+ },
128
+ {
129
+ name: "ViewPlaceholder error without a retry",
130
+ render: () => (
131
+ <ViewPlaceholder
132
+ lifecycle={{ phase: "error", reason: undefined, canRetry: false }}
133
+ labels={PSEUDO.view}
134
+ onRetry={noop}
135
+ />
136
+ ),
137
+ },
138
+ {
139
+ name: "StepErrors without a refresh",
140
+ render: () => (
141
+ <StepErrors
142
+ hidden={false}
143
+ errors={[{ stepId: "inc", reason: "[503] engine down" }]}
144
+ labels={PSEUDO.view}
145
+ />
146
+ ),
147
+ },
148
+ {
149
+ name: "ViewPlaceholder cancelled",
150
+ render: () => (
151
+ <ViewPlaceholder lifecycle={{ phase: "cancelled" }} labels={PSEUDO.view} onRetry={noop} />
152
+ ),
153
+ },
154
+ {
155
+ name: "ViewToolbar",
156
+ render: () => (
157
+ <ViewToolbar
158
+ hidden={false}
159
+ viewData={view}
160
+ builderAvailable
161
+ isFullscreen={false}
162
+ canFullscreen
163
+ isRefreshing={false}
164
+ labels={PSEUDO.view}
165
+ titleMode="show"
166
+ titleLevel={2}
167
+ onRefresh={asyncNoop}
168
+ onEnterBuildMode={noop}
169
+ onToggleFullscreen={asyncNoop}
170
+ />
171
+ ),
172
+ },
173
+ {
174
+ name: "StepErrors",
175
+ render: () => (
176
+ <StepErrors
177
+ hidden={false}
178
+ errors={[{ stepId: "inc", reason: "[503] engine down" }]}
179
+ steps={[{ id: "inc", step: "camunda7:incidents" }]}
180
+ labels={PSEUDO.view}
181
+ onRetry={noop}
182
+ />
183
+ ),
184
+ },
185
+ {
186
+ name: "WidgetRenderer: unknown widget + crashing widget",
187
+ render: () => (
188
+ <WidgetRenderer
189
+ layout={{ rows: [{ row: [{ widget: "demo:gone" }, { widget: "demo:fine" }] }] }}
190
+ keys={{ label: "Rechnung 42" }}
191
+ errors={[]}
192
+ widgets={{ "demo:fine": Fine }}
193
+ />
194
+ ),
195
+ },
196
+ {
197
+ name: "WidgetCrashFallback",
198
+ render: () => (
199
+ <WidgetCrashFallback widgetId="demo:crash" error={new Error("kaputt")} onRetry={noop} />
200
+ ),
201
+ },
202
+ {
203
+ name: "ToastViewport",
204
+ render: () => (
205
+ <ToastViewport
206
+ toasts={[createToast(1, { tone: "success", title: "Job neu eingeplant." })]}
207
+ onDismiss={noop}
208
+ />
209
+ ),
210
+ },
211
+ {
212
+ name: "ErrorState defaults",
213
+ render: () => <ErrorState title="Fehler" details="raw" onRetry={noop} />,
214
+ },
215
+ {
216
+ name: "EmptyState",
217
+ render: () => <EmptyState title="Leer" description="Nichts zu tun." />,
218
+ },
219
+ {
220
+ name: "LayoutBuilder (empty: data-sources hint)",
221
+ render: () => <LayoutBuilder widgets={{}} callTool={() => Promise.resolve({})} />,
222
+ },
223
+ {
224
+ name: "LayoutBuilder (tabs, cells, unknown widget)",
225
+ render: () => (
226
+ <LayoutBuilder
227
+ initialLayout={{
228
+ tabs: [
229
+ { label: "Übersicht", rows: [{ row: [{ widget: "demo:gone" }] }, { row: [] }] },
230
+ { label: "Details-Tab", rows: [] },
231
+ ],
232
+ }}
233
+ widgets={{ "demo:fine": Crash }}
234
+ callTool={() => Promise.resolve({})}
235
+ />
236
+ ),
237
+ },
238
+ {
239
+ name: "Builder toolbar",
240
+ render: () => (
241
+ <Toolbar
242
+ L={PSEUDO.builder}
243
+ previewMode={false}
244
+ isBusy={false}
245
+ status="pending"
246
+ liveKeys={2}
247
+ reachableCount={5}
248
+ unreachableCount={1}
249
+ onOpenCatalogue={noop}
250
+ onOpenSave={noop}
251
+ onDone={noop}
252
+ hasAnyCell
253
+ />
254
+ ),
255
+ },
256
+ {
257
+ name: "Builder data sources",
258
+ render: () => (
259
+ <PipelineStrip
260
+ L={PSEUDO.builder}
261
+ keyEntries={[
262
+ { name: "demo:id", rawValue: "42" },
263
+ { name: "demo:live", rawValue: "" },
264
+ ]}
265
+ stepEntries={[{ id: "s1", step: "demo:load" }]}
266
+ keyCatalog={[]}
267
+ availableSteps={[
268
+ { id: "demo:load", app: "demo", dataType: "demo:t", requires: [], produces: [] },
269
+ ]}
270
+ stepsByApp={[]}
271
+ liveKeys={{ "demo:live": 7 }}
272
+ onAddKey={noop}
273
+ onUpdateKey={noop}
274
+ onRemoveKey={noop}
275
+ onAddStep={noop}
276
+ onUpdateStep={noop}
277
+ onRemoveStep={noop}
278
+ status="refreshing"
279
+ />
280
+ ),
281
+ },
282
+ {
283
+ name: "Builder banners + status chips",
284
+ render: () => (
285
+ <div>
286
+ <BuilderBanners
287
+ L={PSEUDO.builder}
288
+ catalogueToolName="get-builder-catalogue"
289
+ refreshError="timeout"
290
+ validationIssues={["demo:id fehlt"]}
291
+ />
292
+ <StatusChip status="idle" L={PSEUDO.builder} />
293
+ <StatusChip status="pending" L={PSEUDO.builder} />
294
+ <StatusChip status="refreshing" L={PSEUDO.builder} />
295
+ </div>
296
+ ),
297
+ },
298
+ ]
299
+
300
+ describe("no English leaks into a German view", () => {
301
+ it("the marked catalog marks every string (the scan can see catalog text)", () => {
302
+ expect(PSEUDO.view.loading).toBe(`⟦${TOOLKIT_MESSAGES.de.view.loading}⟧`)
303
+ expect(PSEUDO.builder.saved("x")).toBe(`⟦${TOOLKIT_MESSAGES.de.builder.saved("x")}⟧`)
304
+ })
305
+
306
+ for (const renderCase of [...RENDER_CASES, ...CHROME_CASES]) {
307
+ it(`${renderCase.name} renders only catalog text and props under de`, () => {
308
+ expect(leaks(renderCase.render())).toEqual([])
309
+ })
310
+ }
311
+
312
+ it("catches a hard-coded string (gate self-test)", () => {
313
+ const Leaky = ({ name }: { name: string }) => (
314
+ <button type="button" aria-label="Close">
315
+ {name} Load more
316
+ </button>
317
+ )
318
+ expect(leaks(<Leaky name="Rechnung" />)).toEqual([
319
+ 'text: "Rechnung Load more" → "Load more"',
320
+ 'aria-label: "Close" → "Close"',
321
+ ])
322
+ })
323
+ })
package/src/index.ts CHANGED
@@ -71,9 +71,13 @@ export type { UseToolQueryOptions } from "./hooks/use-tool-query.js"
71
71
  export { useDebouncedValue } from "./hooks/use-debounced-value.js"
72
72
 
73
73
  // Tone token system — the semantic status tones (ToneVariant + TONE_* maps)
74
- // behind the tone-aware components below. Pure data + class strings.
75
- export { TONE_SOFT, TONE_DOT, TONE_TEXT } from "./lib/tone-utils.js"
74
+ // behind the tone-aware components below. Pure data + class strings. Three
75
+ // roles per tone: colour (TONE_DOT/TONE_BORDER/TONE_ICON), tint (TONE_SOFT,
76
+ // foreground text on it), text on a plain surface (TONE_TEXT, the -ink role).
77
+ export { TONE_SOFT, TONE_DOT, TONE_TEXT, TONE_BORDER, TONE_ICON } from "./lib/tone-utils.js"
76
78
  export type { ToneVariant } from "./lib/tone-utils.js"
79
+ // The kit's opaque focus ring as class strings, for custom controls.
80
+ export { FOCUS_RING, FOCUS_RING_INSET } from "./lib/focus-ring.js"
77
81
 
78
82
  // Composed components — focused widget building blocks assembled from the
79
83
  // primitives. Pure (React + primitives + cn), so they live in the root barrel.
@@ -96,6 +100,14 @@ export type { DrillButtonProps } from "./components/DrillButton.js"
96
100
  export { ListFooter } from "./components/ListFooter.js"
97
101
  export type { ListFooterProps } from "./components/ListFooter.js"
98
102
 
103
+ // Feedback and states — one pattern each for toasts, empty and error states.
104
+ export { ToastProvider, useToast } from "./components/Toast.js"
105
+ export type { ToastApi, ToastOptions, ToastTone } from "./components/Toast.js"
106
+ export { EmptyState } from "./components/EmptyState.js"
107
+ export type { EmptyStateProps, StateHeadingLevel } from "./components/EmptyState.js"
108
+ export { ErrorState } from "./components/ErrorState.js"
109
+ export type { ErrorStateProps, ErrorStateTone } from "./components/ErrorState.js"
110
+
99
111
  // Providers
100
112
  export { AppQueryProvider, useCallTool, queryClient } from "./providers/query-provider.js"
101
113
 
@@ -103,7 +115,23 @@ export { AppQueryProvider, useCallTool, queryClient } from "./providers/query-pr
103
115
  // `mcp-use/react`), so it belongs in this root barrel: a consumer can re-skin
104
116
  // the toolkit's primitives from a single switch without pulling in the host
105
117
  // runtime. See packages/ui/src/theme/ and docs/guides/white-labeling.md.
106
- export { createTheme, ThemeProvider, useTheme, themePresets } from "./theme/index.js"
118
+ // The defaults are the Miragon theme (theme.css over the vendored --cd-*
119
+ // corporate-design tokens); a brand overrides the ROLE_TOKENS only.
120
+ export {
121
+ createTheme,
122
+ resolveThemeVars,
123
+ ROLE_TOKENS,
124
+ ThemeProvider,
125
+ useTheme,
126
+ usePortalContainer,
127
+ themePresets,
128
+ CD_TOKENS,
129
+ CD_TOKENS_VERSION,
130
+ fontFaces,
131
+ GEIST_FONT_FILES,
132
+ adoptHostFontVariables,
133
+ HOST_FONT_VARIABLES,
134
+ } from "./theme/index.js"
107
135
  export type {
108
136
  ThemeTokens,
109
137
  ThemeDefinition,
@@ -113,6 +141,9 @@ export type {
113
141
  ThemeContextValue,
114
142
  ThemeProviderProps,
115
143
  ThemePresetId,
144
+ CdTokenName,
145
+ FontFacesOptions,
146
+ InlineStyleTarget,
116
147
  } from "./theme/index.js"
117
148
 
118
149
  // i18n / localization. Browser-safe (React only, no `mcp-use/react`), so it
@@ -120,8 +151,29 @@ export type {
120
151
  // <LocaleProvider locale=… translator=…> and reads the active locale with
121
152
  // useLocale/useTranslate. Pairs with `createTranslator` from
122
153
  // `@miragon/mcp-toolkit-core`. See packages/ui/src/locale/.
123
- export { LocaleProvider, useLocale, useTranslate } from "./locale/index.js"
124
- export type { LocaleContextValue, LocaleProviderProps, BoundTranslate } from "./locale/index.js"
154
+ export {
155
+ LocaleProvider,
156
+ useLocale,
157
+ useTranslate,
158
+ useToolkitMessages,
159
+ TOOLKIT_MESSAGES,
160
+ formatCount,
161
+ resolveToolkitMessages,
162
+ } from "./locale/index.js"
163
+ export type {
164
+ LocaleContextValue,
165
+ LocaleProviderProps,
166
+ BoundTranslate,
167
+ BuilderMessages,
168
+ CommonMessages,
169
+ ListFooterMessages,
170
+ PropsSheetMessages,
171
+ RendererMessages,
172
+ ToolkitLocale,
173
+ ToolkitMessages,
174
+ ToolkitMessagesOverride,
175
+ ViewMessages,
176
+ } from "./locale/index.js"
125
177
 
126
178
  // NOTE: MCP App shell (McpAppView, WidgetRenderer) is exported from the
127
179
  // `./app` subpath only. Keeping it out of the main barrel prevents consumers
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The toolkit's focus indicator as Tailwind classes: an opaque 2px ring in the
3
+ * `--focus` role (blau-link on light, blau-hell on dark — 3:1 or more against
4
+ * every surface, see `theme/theme-css.test.ts`), offset from the control so it
5
+ * reads on any fill. Every interactive kit control uses it; spread it into a
6
+ * custom control's `className` so keyboard focus looks the same everywhere.
7
+ * (`theme.css` also gives every element without its own ring an opaque
8
+ * `:focus-visible` outline in the same colour.)
9
+ *
10
+ * The ring is a box-shadow, which forced-colors mode (Windows High Contrast)
11
+ * drops. `outline-hidden` — never `outline-none` — hides the outline only
12
+ * outside that mode and keeps a transparent 2px outline in it, which the
13
+ * browser paints in the system colour, so focus stays visible there too.
14
+ */
15
+ export const FOCUS_RING =
16
+ "outline-hidden focus-visible:ring-2 focus-visible:ring-focus focus-visible:ring-offset-2 focus-visible:ring-offset-background"
17
+
18
+ /** {@link FOCUS_RING} drawn inside the element — for cells and rows flush with a container edge. */
19
+ export const FOCUS_RING_INSET =
20
+ "outline-hidden focus-visible:ring-2 focus-visible:ring-focus focus-visible:ring-inset"
@@ -0,0 +1,59 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { DENSE_ICON_STROKE, ICON_STROKE } from "./icon-stroke.js"
3
+
4
+ /**
5
+ * ONE mechanism for the Lucide stroke (CI §11): `LucideProvider` with the two
6
+ * constants of `icon-stroke.ts` — 2.5 for the view (`McpAppView`), 2 in dense
7
+ * chrome bars. No CSS rule (`svg.lucide { stroke-width }`) in the theme, and no
8
+ * per-icon `strokeWidth` in kit code, so the two can never disagree.
9
+ */
10
+
11
+ declare global {
12
+ interface ImportMeta {
13
+ glob(
14
+ pattern: string | string[],
15
+ options: { query: "?raw"; import: "default"; eager: true },
16
+ ): Record<string, string>
17
+ }
18
+ }
19
+
20
+ const STYLESHEETS = import.meta.glob(["../*.css", "../theme/*.css"], {
21
+ query: "?raw",
22
+ import: "default",
23
+ eager: true,
24
+ })
25
+
26
+ const SOURCES = import.meta.glob(["../**/*.tsx", "!../**/*.test.tsx", "!../render-cases*.tsx"], {
27
+ query: "?raw",
28
+ import: "default",
29
+ eager: true,
30
+ })
31
+
32
+ describe("Lucide stroke: one mechanism", () => {
33
+ it("is 2.5 for the view and 2 in dense bars (CI §11)", () => {
34
+ expect(ICON_STROKE).toBe(2.5)
35
+ expect(DENSE_ICON_STROKE).toBe(2)
36
+ })
37
+
38
+ it("is never set in CSS (theme.css, globals.css, fonts.css, the vendored tokens)", () => {
39
+ expect(Object.keys(STYLESHEETS)).toEqual(
40
+ expect.arrayContaining(["../theme.css", "../globals.css"]),
41
+ )
42
+ for (const [file, css] of Object.entries(STYLESHEETS)) {
43
+ expect(css, file).not.toMatch(/stroke-width/)
44
+ }
45
+ })
46
+
47
+ it("is set only on a LucideProvider, from the icon-stroke constants", () => {
48
+ const uses: string[] = []
49
+ for (const [file, source] of Object.entries(SOURCES)) {
50
+ for (const m of source.matchAll(/<(\w+)[^<>]*\bstrokeWidth=\{?([^}\s>]+)\}?/g)) {
51
+ uses.push(`${file}: <${m[1]} strokeWidth=${m[2]}>`)
52
+ expect(m[1], file).toBe("LucideProvider")
53
+ expect(["ICON_STROKE", "DENSE_ICON_STROKE"], file).toContain(m[2])
54
+ }
55
+ }
56
+ // The view and the three dense bars (view toolbar, builder toolbar, pipeline strip).
57
+ expect(uses.length).toBeGreaterThanOrEqual(4)
58
+ })
59
+ })
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Lucide stroke widths of the toolkit's chrome (CI §11, on the 24 px grid).
3
+ * Set once through `LucideProvider`: `McpAppView` applies {@link ICON_STROKE}
4
+ * to everything it renders (its toast stack included), dense bars narrow it
5
+ * to {@link DENSE_ICON_STROKE}. Icons of a consumer's own `lucide-react` follow
6
+ * the provider only when they resolve to the same module instance (dedupe it).
7
+ */
8
+
9
+ /** Default stroke of the toolkit's icons. */
10
+ export const ICON_STROKE = 2.5
11
+
12
+ /** Stroke in dense chrome bars (the view toolbar, the builder's bars). */
13
+ export const DENSE_ICON_STROKE = 2
@@ -0,0 +1,68 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import {
3
+ createToast,
4
+ MAX_VISIBLE_TOASTS,
5
+ TOAST_DURATION_MS,
6
+ TOAST_ERROR_DURATION_MS,
7
+ toastDuration,
8
+ toastReducer,
9
+ type ToastItem,
10
+ } from "./toast-model.js"
11
+
12
+ const item = (id: number): ToastItem => createToast(id, { title: `t${id}` })
13
+
14
+ describe("toastDuration (CI §8: about 2.8 s, errors about 5 s)", () => {
15
+ it("defaults to 2.8 s and gives errors 5 s", () => {
16
+ expect(TOAST_DURATION_MS).toBe(2800)
17
+ expect(TOAST_ERROR_DURATION_MS).toBe(5000)
18
+ expect(toastDuration("success")).toBe(2800)
19
+ expect(toastDuration("info")).toBe(2800)
20
+ expect(toastDuration("warning")).toBe(2800)
21
+ expect(toastDuration("danger")).toBe(5000)
22
+ })
23
+
24
+ it("honours an explicit positive duration and ignores an unusable one", () => {
25
+ expect(toastDuration("danger", 1200)).toBe(1200)
26
+ expect(toastDuration("info", 0)).toBe(2800)
27
+ expect(toastDuration("info", -5)).toBe(2800)
28
+ expect(toastDuration("danger", Number.NaN)).toBe(5000)
29
+ expect(toastDuration("danger", Number.POSITIVE_INFINITY)).toBe(5000)
30
+ })
31
+ })
32
+
33
+ describe("createToast", () => {
34
+ it("defaults to the info tone and drops an empty description", () => {
35
+ expect(createToast(1, { title: "Saved" })).toEqual({
36
+ id: 1,
37
+ tone: "info",
38
+ title: "Saved",
39
+ duration: 2800,
40
+ })
41
+ expect(createToast(2, { title: "x", description: "" })).not.toHaveProperty("description")
42
+ })
43
+
44
+ it("keeps tone, description and the tone's duration", () => {
45
+ expect(createToast(3, { tone: "danger", title: "Failed", description: "503" })).toEqual({
46
+ id: 3,
47
+ tone: "danger",
48
+ title: "Failed",
49
+ description: "503",
50
+ duration: 5000,
51
+ })
52
+ })
53
+ })
54
+
55
+ describe("toastReducer", () => {
56
+ it("appends and keeps at most the newest MAX_VISIBLE_TOASTS", () => {
57
+ expect(MAX_VISIBLE_TOASTS).toBe(3)
58
+ let state: ToastItem[] = []
59
+ for (let id = 1; id <= 4; id++) state = toastReducer(state, { type: "add", toast: item(id) })
60
+ expect(state.map((t) => t.id)).toEqual([2, 3, 4])
61
+ })
62
+
63
+ it("dismisses by id and keeps the list identity when nothing matches", () => {
64
+ const state = [item(1), item(2)]
65
+ expect(toastReducer(state, { type: "dismiss", id: 1 }).map((t) => t.id)).toEqual([2])
66
+ expect(toastReducer(state, { type: "dismiss", id: 9 })).toBe(state)
67
+ })
68
+ })
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Pure model behind `ToastProvider` / `useToast`: the toast list as a reducer
3
+ * plus the timing rules, so both are testable without a DOM (the house path,
4
+ * FITNESS.md) and the component stays a thin view over them.
5
+ */
6
+
7
+ /** Status tone of a toast — the four status roles (`--success` … `--info`). */
8
+ export type ToastTone = "success" | "warning" | "danger" | "info"
9
+
10
+ /** What a caller passes to `useToast().show(...)`. */
11
+ export interface ToastOptions {
12
+ /** Status tone; drives the edge, the icon and the default duration. Default `"info"`. */
13
+ tone?: ToastTone
14
+ /** The message. Say what happened, in one short sentence ("Job retried."). */
15
+ title: string
16
+ /** Optional second line (what you can do next, or the affected object). */
17
+ description?: string
18
+ /** Auto-dismiss after this many ms. Defaults per {@link toastDuration}. */
19
+ duration?: number
20
+ }
21
+
22
+ /** A toast as it sits in the list. */
23
+ export interface ToastItem {
24
+ id: number
25
+ tone: ToastTone
26
+ title: string
27
+ description?: string
28
+ duration: number
29
+ }
30
+
31
+ /** Default lifetime of a toast (CI §8: about 2.8 s). */
32
+ export const TOAST_DURATION_MS = 2800
33
+ /** Lifetime of an error toast (CI §8: about 5 s) — long enough to read the reason. */
34
+ export const TOAST_ERROR_DURATION_MS = 5000
35
+ /** At most this many toasts stack; the oldest leaves first. */
36
+ export const MAX_VISIBLE_TOASTS = 3
37
+
38
+ /** The lifetime of a toast: an explicit positive duration, else the tone's default. */
39
+ export function toastDuration(tone: ToastTone, explicit?: number): number {
40
+ if (explicit !== undefined && Number.isFinite(explicit) && explicit > 0) return explicit
41
+ return tone === "danger" ? TOAST_ERROR_DURATION_MS : TOAST_DURATION_MS
42
+ }
43
+
44
+ /** Normalise caller options into a list item. */
45
+ export function createToast(id: number, options: ToastOptions): ToastItem {
46
+ const tone = options.tone ?? "info"
47
+ return {
48
+ id,
49
+ tone,
50
+ title: options.title,
51
+ ...(options.description ? { description: options.description } : {}),
52
+ duration: toastDuration(tone, options.duration),
53
+ }
54
+ }
55
+
56
+ export type ToastAction = { type: "add"; toast: ToastItem } | { type: "dismiss"; id: number }
57
+
58
+ /** Add appends (dropping the oldest beyond {@link MAX_VISIBLE_TOASTS}); dismiss removes by id. */
59
+ export function toastReducer(state: readonly ToastItem[], action: ToastAction): ToastItem[] {
60
+ if (action.type === "add") return [...state, action.toast].slice(-MAX_VISIBLE_TOASTS)
61
+ const next = state.filter((t) => t.id !== action.id)
62
+ return next.length === state.length ? (state as ToastItem[]) : next
63
+ }