@miragon/mcp-toolkit-ui 2.7.0 → 2.8.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 (387) hide show
  1. package/README.md +7 -4
  2. package/dist/app/adapt-data-widget.d.ts +1 -22
  3. package/dist/app/adapt-data-widget.d.ts.map +1 -1
  4. package/dist/app/adapt-data-widget.js +1 -35
  5. package/dist/app/adapt-data-widget.js.map +1 -1
  6. package/dist/app/builder/BuilderBanners.js +1 -1
  7. package/dist/app/builder/BuilderBanners.js.map +1 -1
  8. package/dist/app/builder/CanvasRow.d.ts +4 -2
  9. package/dist/app/builder/CanvasRow.d.ts.map +1 -1
  10. package/dist/app/builder/CanvasRow.js +21 -7
  11. package/dist/app/builder/CanvasRow.js.map +1 -1
  12. package/dist/app/builder/CatalogueSheet.d.ts.map +1 -1
  13. package/dist/app/builder/CatalogueSheet.js +11 -4
  14. package/dist/app/builder/CatalogueSheet.js.map +1 -1
  15. package/dist/app/builder/CellPropsSheet.d.ts +7 -3
  16. package/dist/app/builder/CellPropsSheet.d.ts.map +1 -1
  17. package/dist/app/builder/CellPropsSheet.js +10 -3
  18. package/dist/app/builder/CellPropsSheet.js.map +1 -1
  19. package/dist/app/builder/DiscardDialog.d.ts +18 -0
  20. package/dist/app/builder/DiscardDialog.d.ts.map +1 -0
  21. package/dist/app/builder/DiscardDialog.js +18 -0
  22. package/dist/app/builder/DiscardDialog.js.map +1 -0
  23. package/dist/app/builder/LayoutSurface.d.ts +33 -9
  24. package/dist/app/builder/LayoutSurface.d.ts.map +1 -1
  25. package/dist/app/builder/LayoutSurface.js +74 -23
  26. package/dist/app/builder/LayoutSurface.js.map +1 -1
  27. package/dist/app/builder/PipelineStrip.d.ts +2 -1
  28. package/dist/app/builder/PipelineStrip.d.ts.map +1 -1
  29. package/dist/app/builder/PipelineStrip.js +26 -13
  30. package/dist/app/builder/PipelineStrip.js.map +1 -1
  31. package/dist/app/builder/SaveDialog.d.ts +9 -1
  32. package/dist/app/builder/SaveDialog.d.ts.map +1 -1
  33. package/dist/app/builder/SaveDialog.js +8 -2
  34. package/dist/app/builder/SaveDialog.js.map +1 -1
  35. package/dist/app/builder/Toolbar.d.ts +12 -4
  36. package/dist/app/builder/Toolbar.d.ts.map +1 -1
  37. package/dist/app/builder/Toolbar.js +18 -7
  38. package/dist/app/builder/Toolbar.js.map +1 -1
  39. package/dist/app/builder/Workspace.d.ts +23 -3
  40. package/dist/app/builder/Workspace.d.ts.map +1 -1
  41. package/dist/app/builder/Workspace.js +58 -8
  42. package/dist/app/builder/Workspace.js.map +1 -1
  43. package/dist/app/builder/builder-model.d.ts +143 -1
  44. package/dist/app/builder/builder-model.d.ts.map +1 -1
  45. package/dist/app/builder/builder-model.js +191 -1
  46. package/dist/app/builder/builder-model.js.map +1 -1
  47. package/dist/app/builder/reveal.d.ts +10 -0
  48. package/dist/app/builder/reveal.d.ts.map +1 -0
  49. package/dist/app/builder/reveal.js +10 -0
  50. package/dist/app/builder/reveal.js.map +1 -0
  51. package/dist/app/builder/use-builder-catalogue.d.ts +4 -1
  52. package/dist/app/builder/use-builder-catalogue.d.ts.map +1 -1
  53. package/dist/app/builder/use-builder-catalogue.js +44 -17
  54. package/dist/app/builder/use-builder-catalogue.js.map +1 -1
  55. package/dist/app/builder/use-builder-session.d.ts +30 -0
  56. package/dist/app/builder/use-builder-session.d.ts.map +1 -0
  57. package/dist/app/builder/use-builder-session.js +58 -0
  58. package/dist/app/builder/use-builder-session.js.map +1 -0
  59. package/dist/app/builder/use-display-resolver.d.ts +17 -0
  60. package/dist/app/builder/use-display-resolver.d.ts.map +1 -0
  61. package/dist/app/builder/use-display-resolver.js +16 -0
  62. package/dist/app/builder/use-display-resolver.js.map +1 -0
  63. package/dist/app/index.d.ts +8 -3
  64. package/dist/app/index.d.ts.map +1 -1
  65. package/dist/app/index.js +8 -2
  66. package/dist/app/index.js.map +1 -1
  67. package/dist/app/layout-builder.d.ts +13 -25
  68. package/dist/app/layout-builder.d.ts.map +1 -1
  69. package/dist/app/layout-builder.js +75 -81
  70. package/dist/app/layout-builder.js.map +1 -1
  71. package/dist/app/mcp-app-view-chrome.d.ts +12 -3
  72. package/dist/app/mcp-app-view-chrome.d.ts.map +1 -1
  73. package/dist/app/mcp-app-view-chrome.js +21 -5
  74. package/dist/app/mcp-app-view-chrome.js.map +1 -1
  75. package/dist/app/mcp-app-view-states.d.ts +11 -3
  76. package/dist/app/mcp-app-view-states.d.ts.map +1 -1
  77. package/dist/app/mcp-app-view-states.js +29 -8
  78. package/dist/app/mcp-app-view-states.js.map +1 -1
  79. package/dist/app/mcp-app-view.d.ts +33 -1
  80. package/dist/app/mcp-app-view.d.ts.map +1 -1
  81. package/dist/app/mcp-app-view.js +181 -127
  82. package/dist/app/mcp-app-view.js.map +1 -1
  83. package/dist/app/mcp-toolkit-app.d.ts +6 -1
  84. package/dist/app/mcp-toolkit-app.d.ts.map +1 -1
  85. package/dist/app/mcp-toolkit-app.js +8 -1
  86. package/dist/app/mcp-toolkit-app.js.map +1 -1
  87. package/dist/app/mcp-use-host-bridge.d.ts +15 -6
  88. package/dist/app/mcp-use-host-bridge.d.ts.map +1 -1
  89. package/dist/app/mcp-use-host-bridge.js +51 -22
  90. package/dist/app/mcp-use-host-bridge.js.map +1 -1
  91. package/dist/app/model-context.d.ts +71 -0
  92. package/dist/app/model-context.d.ts.map +1 -0
  93. package/dist/app/model-context.js +101 -0
  94. package/dist/app/model-context.js.map +1 -0
  95. package/dist/app/use-build-mode-focus.d.ts +33 -0
  96. package/dist/app/use-build-mode-focus.d.ts.map +1 -0
  97. package/dist/app/use-build-mode-focus.js +59 -0
  98. package/dist/app/use-build-mode-focus.js.map +1 -0
  99. package/dist/app/use-view-actions.d.ts +31 -0
  100. package/dist/app/use-view-actions.d.ts.map +1 -0
  101. package/dist/app/use-view-actions.js +81 -0
  102. package/dist/app/use-view-actions.js.map +1 -0
  103. package/dist/app/use-view-lifecycle.d.ts +4 -0
  104. package/dist/app/use-view-lifecycle.d.ts.map +1 -1
  105. package/dist/app/use-view-lifecycle.js +2 -0
  106. package/dist/app/use-view-lifecycle.js.map +1 -1
  107. package/dist/app/view-lifecycle.d.ts +80 -4
  108. package/dist/app/view-lifecycle.d.ts.map +1 -1
  109. package/dist/app/view-lifecycle.js +90 -3
  110. package/dist/app/view-lifecycle.js.map +1 -1
  111. package/dist/app/widget-fixture.d.ts +10 -3
  112. package/dist/app/widget-fixture.d.ts.map +1 -1
  113. package/dist/app/widget-fixture.js +21 -8
  114. package/dist/app/widget-fixture.js.map +1 -1
  115. package/dist/app/widget-props-sheet.d.ts +3 -1
  116. package/dist/app/widget-props-sheet.d.ts.map +1 -1
  117. package/dist/app/widget-props-sheet.js +22 -10
  118. package/dist/app/widget-props-sheet.js.map +1 -1
  119. package/dist/app/widget-renderer.js +1 -1
  120. package/dist/app/widget-renderer.js.map +1 -1
  121. package/dist/components/DrillButton.d.ts +9 -6
  122. package/dist/components/DrillButton.d.ts.map +1 -1
  123. package/dist/components/DrillButton.js +8 -6
  124. package/dist/components/DrillButton.js.map +1 -1
  125. package/dist/components/EmptyState.d.ts.map +1 -1
  126. package/dist/components/EmptyState.js +4 -2
  127. package/dist/components/EmptyState.js.map +1 -1
  128. package/dist/components/ErrorState.d.ts +5 -2
  129. package/dist/components/ErrorState.d.ts.map +1 -1
  130. package/dist/components/ErrorState.js +25 -8
  131. package/dist/components/ErrorState.js.map +1 -1
  132. package/dist/components/FilterBar.d.ts +15 -4
  133. package/dist/components/FilterBar.d.ts.map +1 -1
  134. package/dist/components/FilterBar.js +11 -6
  135. package/dist/components/FilterBar.js.map +1 -1
  136. package/dist/components/KpiGrid.d.ts +66 -21
  137. package/dist/components/KpiGrid.d.ts.map +1 -1
  138. package/dist/components/KpiGrid.js +101 -34
  139. package/dist/components/KpiGrid.js.map +1 -1
  140. package/dist/components/ListFooter.d.ts +31 -4
  141. package/dist/components/ListFooter.d.ts.map +1 -1
  142. package/dist/components/ListFooter.js +30 -11
  143. package/dist/components/ListFooter.js.map +1 -1
  144. package/dist/components/PagedListFooter.d.ts +57 -0
  145. package/dist/components/PagedListFooter.d.ts.map +1 -0
  146. package/dist/components/PagedListFooter.js +27 -0
  147. package/dist/components/PagedListFooter.js.map +1 -0
  148. package/dist/components/Pills.d.ts +27 -7
  149. package/dist/components/Pills.d.ts.map +1 -1
  150. package/dist/components/Pills.js +21 -6
  151. package/dist/components/Pills.js.map +1 -1
  152. package/dist/components/SectionHeading.d.ts +3 -1
  153. package/dist/components/SectionHeading.d.ts.map +1 -1
  154. package/dist/components/SectionHeading.js +6 -2
  155. package/dist/components/SectionHeading.js.map +1 -1
  156. package/dist/components/Toast.d.ts +9 -3
  157. package/dist/components/Toast.d.ts.map +1 -1
  158. package/dist/components/Toast.js +31 -16
  159. package/dist/components/Toast.js.map +1 -1
  160. package/dist/components/WidgetHeader.d.ts +34 -10
  161. package/dist/components/WidgetHeader.d.ts.map +1 -1
  162. package/dist/components/WidgetHeader.js +11 -6
  163. package/dist/components/WidgetHeader.js.map +1 -1
  164. package/dist/hooks/index.d.ts +4 -4
  165. package/dist/hooks/index.d.ts.map +1 -1
  166. package/dist/hooks/index.js +1 -1
  167. package/dist/hooks/index.js.map +1 -1
  168. package/dist/hooks/tool-result-recovery-model.d.ts +155 -0
  169. package/dist/hooks/tool-result-recovery-model.d.ts.map +1 -0
  170. package/dist/hooks/tool-result-recovery-model.js +273 -0
  171. package/dist/hooks/tool-result-recovery-model.js.map +1 -0
  172. package/dist/hooks/use-tool-query.d.ts +56 -14
  173. package/dist/hooks/use-tool-query.d.ts.map +1 -1
  174. package/dist/hooks/use-tool-query.js +102 -25
  175. package/dist/hooks/use-tool-query.js.map +1 -1
  176. package/dist/hooks/use-tool-result-recovery.d.ts +47 -52
  177. package/dist/hooks/use-tool-result-recovery.d.ts.map +1 -1
  178. package/dist/hooks/use-tool-result-recovery.js +58 -120
  179. package/dist/hooks/use-tool-result-recovery.js.map +1 -1
  180. package/dist/hooks/use-view-data.d.ts +52 -10
  181. package/dist/hooks/use-view-data.d.ts.map +1 -1
  182. package/dist/hooks/use-view-data.js +81 -16
  183. package/dist/hooks/use-view-data.js.map +1 -1
  184. package/dist/hooks/use-view-tool-query.d.ts +12 -7
  185. package/dist/hooks/use-view-tool-query.d.ts.map +1 -1
  186. package/dist/hooks/use-view-tool-query.js +15 -14
  187. package/dist/hooks/use-view-tool-query.js.map +1 -1
  188. package/dist/index.d.ts +17 -10
  189. package/dist/index.d.ts.map +1 -1
  190. package/dist/index.js +18 -6
  191. package/dist/index.js.map +1 -1
  192. package/dist/lib/focus-return.d.ts +61 -0
  193. package/dist/lib/focus-return.d.ts.map +1 -0
  194. package/dist/lib/focus-return.js +46 -0
  195. package/dist/lib/focus-return.js.map +1 -0
  196. package/dist/lib/parse-tool-result.d.ts +23 -1
  197. package/dist/lib/parse-tool-result.d.ts.map +1 -1
  198. package/dist/lib/parse-tool-result.js +38 -2
  199. package/dist/lib/parse-tool-result.js.map +1 -1
  200. package/dist/lib/toast-model.d.ts +41 -1
  201. package/dist/lib/toast-model.d.ts.map +1 -1
  202. package/dist/lib/toast-model.js +67 -1
  203. package/dist/lib/toast-model.js.map +1 -1
  204. package/dist/lib/tone-utils.d.ts +58 -6
  205. package/dist/lib/tone-utils.d.ts.map +1 -1
  206. package/dist/lib/tone-utils.js +66 -4
  207. package/dist/lib/tone-utils.js.map +1 -1
  208. package/dist/lib/tool-query.d.ts +107 -0
  209. package/dist/lib/tool-query.d.ts.map +1 -0
  210. package/dist/lib/tool-query.js +182 -0
  211. package/dist/lib/tool-query.js.map +1 -0
  212. package/dist/lib/tool-result-observer.d.ts +108 -0
  213. package/dist/lib/tool-result-observer.d.ts.map +1 -0
  214. package/dist/lib/tool-result-observer.js +158 -0
  215. package/dist/lib/tool-result-observer.js.map +1 -0
  216. package/dist/lib/typography.d.ts +37 -0
  217. package/dist/lib/typography.d.ts.map +1 -0
  218. package/dist/lib/typography.js +41 -0
  219. package/dist/lib/typography.js.map +1 -0
  220. package/dist/lib/utils.d.ts.map +1 -1
  221. package/dist/lib/utils.js +11 -1
  222. package/dist/lib/utils.js.map +1 -1
  223. package/dist/locale/index.d.ts +1 -1
  224. package/dist/locale/index.d.ts.map +1 -1
  225. package/dist/locale/message-types.d.ts +117 -4
  226. package/dist/locale/message-types.d.ts.map +1 -1
  227. package/dist/locale/messages-de.d.ts.map +1 -1
  228. package/dist/locale/messages-de.js +44 -10
  229. package/dist/locale/messages-de.js.map +1 -1
  230. package/dist/locale/messages-en.d.ts.map +1 -1
  231. package/dist/locale/messages-en.js +44 -10
  232. package/dist/locale/messages-en.js.map +1 -1
  233. package/dist/locale/toolkit-messages.d.ts +19 -1
  234. package/dist/locale/toolkit-messages.d.ts.map +1 -1
  235. package/dist/locale/toolkit-messages.js +54 -1
  236. package/dist/locale/toolkit-messages.js.map +1 -1
  237. package/dist/primitives/alert.d.ts +1 -1
  238. package/dist/primitives/alert.d.ts.map +1 -1
  239. package/dist/primitives/alert.js +2 -0
  240. package/dist/primitives/alert.js.map +1 -1
  241. package/dist/primitives/badge.d.ts +1 -1
  242. package/dist/primitives/badge.d.ts.map +1 -1
  243. package/dist/primitives/badge.js +9 -4
  244. package/dist/primitives/badge.js.map +1 -1
  245. package/dist/primitives/button.d.ts +1 -1
  246. package/dist/primitives/button.js +1 -1
  247. package/dist/primitives/button.js.map +1 -1
  248. package/dist/primitives/dialog.d.ts +1 -1
  249. package/dist/primitives/dialog.d.ts.map +1 -1
  250. package/dist/primitives/dialog.js +10 -3
  251. package/dist/primitives/dialog.js.map +1 -1
  252. package/dist/primitives/select.js +1 -1
  253. package/dist/primitives/select.js.map +1 -1
  254. package/dist/primitives/sheet.d.ts +1 -1
  255. package/dist/primitives/sheet.d.ts.map +1 -1
  256. package/dist/primitives/sheet.js +6 -2
  257. package/dist/primitives/sheet.js.map +1 -1
  258. package/dist/providers/query-provider.d.ts +32 -1
  259. package/dist/providers/query-provider.d.ts.map +1 -1
  260. package/dist/providers/query-provider.js +53 -4
  261. package/dist/providers/query-provider.js.map +1 -1
  262. package/dist/render-cases-primitives.js +1 -1
  263. package/dist/render-cases-primitives.js.map +1 -1
  264. package/dist/render-cases.d.ts.map +1 -1
  265. package/dist/render-cases.js +30 -6
  266. package/dist/render-cases.js.map +1 -1
  267. package/dist/render-effects.d.ts +42 -0
  268. package/dist/render-effects.d.ts.map +1 -0
  269. package/dist/render-effects.js +81 -0
  270. package/dist/render-effects.js.map +1 -0
  271. package/dist/theme/create-theme.d.ts +19 -1
  272. package/dist/theme/create-theme.d.ts.map +1 -1
  273. package/dist/theme/create-theme.js +15 -0
  274. package/dist/theme/create-theme.js.map +1 -1
  275. package/package.json +5 -4
  276. package/src/app/adapt-data-widget.tsx +2 -42
  277. package/src/app/builder/BuilderBanners.tsx +1 -1
  278. package/src/app/builder/CanvasRow.tsx +63 -17
  279. package/src/app/builder/CatalogueSheet.tsx +50 -29
  280. package/src/app/builder/CellPropsSheet.tsx +17 -2
  281. package/src/app/builder/DiscardDialog.tsx +59 -0
  282. package/src/app/builder/LayoutSurface.tsx +206 -66
  283. package/src/app/builder/PipelineStrip.tsx +253 -153
  284. package/src/app/builder/SaveDialog.tsx +14 -2
  285. package/src/app/builder/Toolbar.tsx +144 -59
  286. package/src/app/builder/Workspace.tsx +226 -58
  287. package/src/app/builder/builder-model.ts +341 -2
  288. package/src/app/builder/builder-model.ux.test.ts +456 -0
  289. package/src/app/builder/builder-ux.test.tsx +844 -0
  290. package/src/app/builder/reveal.ts +10 -0
  291. package/src/app/builder/use-builder-catalogue.ts +56 -19
  292. package/src/app/builder/use-builder-session.ts +68 -0
  293. package/src/app/builder/use-display-resolver.ts +24 -0
  294. package/src/app/index.ts +12 -2
  295. package/src/app/layout-builder.tsx +178 -191
  296. package/src/app/mcp-app-view-chrome.test.tsx +141 -1
  297. package/src/app/mcp-app-view-chrome.tsx +56 -6
  298. package/src/app/mcp-app-view-states.tsx +51 -11
  299. package/src/app/mcp-app-view.effects.test.tsx +292 -0
  300. package/src/app/mcp-app-view.test.tsx +222 -2
  301. package/src/app/mcp-app-view.tsx +267 -117
  302. package/src/app/mcp-toolkit-app.test.tsx +40 -0
  303. package/src/app/mcp-toolkit-app.tsx +8 -1
  304. package/src/app/mcp-use-host-bridge.test.tsx +180 -0
  305. package/src/app/mcp-use-host-bridge.tsx +67 -24
  306. package/src/app/model-context.test.tsx +116 -0
  307. package/src/app/model-context.tsx +125 -0
  308. package/src/app/use-build-mode-focus.ts +62 -0
  309. package/src/app/use-view-actions.ts +89 -0
  310. package/src/app/use-view-lifecycle.ts +6 -0
  311. package/src/app/view-lifecycle.test.ts +216 -0
  312. package/src/app/view-lifecycle.ts +165 -5
  313. package/src/app/widget-fixture.effects.test.tsx +51 -0
  314. package/src/app/widget-fixture.render.test.tsx +81 -0
  315. package/src/app/widget-fixture.tsx +34 -11
  316. package/src/app/widget-props-sheet.tsx +57 -16
  317. package/src/app/widget-renderer.tsx +1 -1
  318. package/src/components/DrillButton.tsx +23 -10
  319. package/src/components/EmptyState.tsx +6 -4
  320. package/src/components/ErrorState.tsx +31 -9
  321. package/src/components/FilterBar.test.tsx +82 -0
  322. package/src/components/FilterBar.tsx +52 -26
  323. package/src/components/KpiGrid.test.tsx +279 -0
  324. package/src/components/KpiGrid.tsx +297 -90
  325. package/src/components/ListFooter.test.tsx +116 -0
  326. package/src/components/ListFooter.tsx +102 -15
  327. package/src/components/PagedListFooter.test.tsx +140 -0
  328. package/src/components/PagedListFooter.tsx +107 -0
  329. package/src/components/Pills.test.tsx +77 -0
  330. package/src/components/Pills.tsx +63 -10
  331. package/src/components/SectionHeading.tsx +9 -3
  332. package/src/components/Toast.tsx +37 -19
  333. package/src/components/WidgetHeader.test.tsx +78 -0
  334. package/src/components/WidgetHeader.tsx +63 -16
  335. package/src/components/feedback-states.test.tsx +98 -9
  336. package/src/components/tone-figures.test.tsx +8 -4
  337. package/src/hooks/index.ts +6 -3
  338. package/src/hooks/tool-result-recovery-model.test.ts +744 -0
  339. package/src/hooks/tool-result-recovery-model.ts +418 -0
  340. package/src/hooks/use-tool-query.effects.test.tsx +205 -0
  341. package/src/hooks/use-tool-query.ts +156 -33
  342. package/src/hooks/use-tool-result-recovery.effects.test.tsx +141 -0
  343. package/src/hooks/use-tool-result-recovery.test.ts +3 -4
  344. package/src/hooks/use-tool-result-recovery.ts +84 -142
  345. package/src/hooks/use-view-data.test.tsx +118 -9
  346. package/src/hooks/use-view-data.ts +128 -18
  347. package/src/hooks/use-view-tool-query.ts +25 -19
  348. package/src/i18n-coverage.test.tsx +104 -2
  349. package/src/index.ts +48 -11
  350. package/src/lib/focus-return.test.ts +288 -0
  351. package/src/lib/focus-return.ts +96 -0
  352. package/src/lib/parse-tool-result.test.ts +46 -1
  353. package/src/lib/parse-tool-result.ts +40 -2
  354. package/src/lib/toast-model.test.ts +166 -1
  355. package/src/lib/toast-model.ts +104 -2
  356. package/src/lib/tone-utils.test.ts +86 -10
  357. package/src/lib/tone-utils.ts +89 -8
  358. package/src/lib/tool-query.test.ts +569 -0
  359. package/src/lib/tool-query.ts +253 -0
  360. package/src/lib/tool-result-observer.test.ts +296 -0
  361. package/src/lib/tool-result-observer.ts +211 -0
  362. package/src/lib/typography.test.ts +45 -0
  363. package/src/lib/typography.ts +47 -0
  364. package/src/lib/utils.ts +12 -1
  365. package/src/locale/index.ts +1 -0
  366. package/src/locale/message-types.ts +118 -2
  367. package/src/locale/messages-de.ts +49 -10
  368. package/src/locale/messages-en.ts +49 -10
  369. package/src/locale/toolkit-messages.test.ts +133 -0
  370. package/src/locale/toolkit-messages.ts +63 -2
  371. package/src/primitives/alert.tsx +3 -0
  372. package/src/primitives/badge.tsx +9 -4
  373. package/src/primitives/button.tsx +1 -1
  374. package/src/primitives/dialog.tsx +10 -1
  375. package/src/primitives/select.tsx +1 -1
  376. package/src/primitives/sheet.tsx +7 -0
  377. package/src/providers/query-provider.test.tsx +154 -0
  378. package/src/providers/query-provider.tsx +66 -5
  379. package/src/render-cases-primitives.tsx +1 -1
  380. package/src/render-cases.tsx +38 -6
  381. package/src/render-effects.tsx +96 -0
  382. package/src/theme/cd-tokens.test.ts +3 -1
  383. package/src/theme/create-theme.test.ts +10 -0
  384. package/src/theme/create-theme.ts +28 -0
  385. package/src/theme/theme-css.test.ts +51 -0
  386. package/src/theme.css +31 -1
  387. package/ui-catalog.json +448 -41
@@ -0,0 +1,844 @@
1
+ import { isValidElement, type ReactElement, type ReactNode } from "react"
2
+ import { renderToStaticMarkup } from "react-dom/server"
3
+ import ts from "typescript"
4
+ import { describe, expect, it, vi } from "vitest"
5
+ import { createTranslator, type ReachableWidget, type RowDef } from "@miragon/mcp-toolkit-core"
6
+ import { LocaleProvider } from "../../locale/locale-provider.js"
7
+ import { TOOLKIT_MESSAGES } from "../../locale/toolkit-messages.js"
8
+ import { attribute, decodeEntities, openingTags } from "../../render-scan.js"
9
+ import { DialogDescription } from "../../primitives/dialog.js"
10
+ import { Button } from "../../primitives/button.js"
11
+ import { TabsTrigger } from "../../primitives/tabs.js"
12
+ import { LayoutBuilder } from "../layout-builder.js"
13
+ import {
14
+ BUILDER_FOCUS_TARGET,
15
+ builderMountFocusTarget,
16
+ buildModeFocusTarget,
17
+ VIEW_FOCUS_TARGET,
18
+ } from "../use-build-mode-focus.js"
19
+ import { createTabRefocus, type DraftLayout } from "./builder-model.js"
20
+ import { DiscardDialog } from "./DiscardDialog.js"
21
+ import { SaveDialog } from "./SaveDialog.js"
22
+ import { LayoutSurface, TabItem } from "./LayoutSurface.js"
23
+ import { PipelineStrip } from "./PipelineStrip.js"
24
+ import { REVEAL_ON_INTENT } from "./reveal.js"
25
+ import { Toolbar, type ToolbarProps } from "./Toolbar.js"
26
+ import { PaletteEmpty, Workspace, type WorkspaceProps } from "./Workspace.js"
27
+
28
+ /**
29
+ * The builder for people, not developers (U18, toolkit#13/#14), on its SSR
30
+ * markup: widgets by title (the id only as a quiet hint, or as the fallback),
31
+ * no hover-only control, a keyboard rename next to the double-click, the kit
32
+ * focus ring on every control, Apply / Discard / Save as three named actions,
33
+ * the technical parts behind "Advanced", and every builder string from the
34
+ * catalog.
35
+ */
36
+
37
+ declare global {
38
+ interface ImportMeta {
39
+ glob(
40
+ pattern: string | string[],
41
+ options: { query: "?raw"; import: "default"; eager: true },
42
+ ): Record<string, string>
43
+ }
44
+ }
45
+
46
+ const de = TOOLKIT_MESSAGES.de.builder
47
+ const noop = (): void => {}
48
+ const translator = createTranslator({
49
+ de: { "demo.keyed.title": "Aus dem Katalog", "demo.keyed.summary": "Titel per Schlüssel." },
50
+ })
51
+ /**
52
+ * The SSR markup in German, raw: React escapes a `>` inside an attribute
53
+ * (`has-[>svg]:px-2.5`), which keeps the tag scans of `render-scan` honest;
54
+ * `attribute()` and `button()` decode what they return.
55
+ */
56
+ const inGerman = (node: ReactElement) =>
57
+ renderToStaticMarkup(
58
+ <LocaleProvider locale="de" translator={translator}>
59
+ {node}
60
+ </LocaleProvider>,
61
+ )
62
+
63
+ const PALETTE: ReachableWidget[] = [
64
+ {
65
+ id: "demo:orders",
66
+ app: "demo",
67
+ requires: ["demo:customer"],
68
+ size: "full",
69
+ title: { en: "Orders", de: "Bestellungen" },
70
+ summary: { en: "Every open order.", de: "Alle offenen Bestellungen." },
71
+ },
72
+ { id: "demo:keyed", app: "demo", requires: [], size: "half", titleKey: "demo.keyed.title" },
73
+ { id: "demo:raw", app: "demo", requires: [], size: "half" },
74
+ ]
75
+
76
+ function workspace(over: Partial<WorkspaceProps> = {}): WorkspaceProps {
77
+ return {
78
+ L: de,
79
+ paletteByApp: [["demo", PALETTE]],
80
+ appDisplayByName: new Map(),
81
+ paletteCount: PALETTE.length,
82
+ paletteLoading: false,
83
+ onOpenDataSources: noop,
84
+ activeRows: [{ row: [{ widget: "demo:orders" }, { widget: "demo:raw" }] }, { row: [] }],
85
+ focusedRowIndex: 0,
86
+ setFocusedRowIndex: noop,
87
+ dragOverRow: null,
88
+ setDragOverRow: noop,
89
+ onAddRow: noop,
90
+ onRemoveRow: noop,
91
+ onMoveRow: noop,
92
+ onSetCellSpan: noop,
93
+ onRemoveCell: noop,
94
+ onAddWidget: noop,
95
+ onConfigureCell: noop,
96
+ propsSchemaByWidgetId: new Map([["demo:orders", { type: "object" }]]),
97
+ displayByWidgetId: new Map(PALETTE.map((w) => [w.id, w])),
98
+ advanced: false,
99
+ widgets: {},
100
+ widgetProps: { keys: {}, context: { keys: {}, steps: {}, errors: [] } },
101
+ ...over,
102
+ }
103
+ }
104
+
105
+ const TABS: DraftLayout = {
106
+ kind: "tabs",
107
+ tabs: [
108
+ { label: "Übersicht", rows: [{ row: [] }] },
109
+ { label: "Details", rows: [{ row: [] }] },
110
+ ],
111
+ }
112
+
113
+ function surface(draft: DraftLayout, editingTabIdx: number | null = null) {
114
+ return (
115
+ <LayoutSurface
116
+ L={de}
117
+ draft={draft}
118
+ activeTabIndex={0}
119
+ setActiveTabIndex={noop}
120
+ editingTabIdx={editingTabIdx}
121
+ setEditingTabIdx={noop}
122
+ renameRejected={false}
123
+ commitRename={() => true}
124
+ onAddTab={noop}
125
+ onRemoveTab={noop}
126
+ workspace={workspace()}
127
+ />
128
+ )
129
+ }
130
+
131
+ function toolbar(over: Partial<ToolbarProps> = {}) {
132
+ return (
133
+ <Toolbar
134
+ L={de}
135
+ previewMode={false}
136
+ isBusy={false}
137
+ status="idle"
138
+ liveKeys={1}
139
+ reachableCount={3}
140
+ unreachableCount={2}
141
+ advanced={false}
142
+ onToggleAdvanced={noop}
143
+ unsaved={false}
144
+ onOpenCatalogue={noop}
145
+ onOpenSave={noop}
146
+ onDiscard={noop}
147
+ onApply={noop}
148
+ hasAnyCell
149
+ {...over}
150
+ />
151
+ )
152
+ }
153
+
154
+ /** The opening tag of the element carrying `data-slot="<slot>"`, in document order. */
155
+ function slots(html: string, slot: string): string[] {
156
+ return html.match(new RegExp(`<[a-z]+[^>]*data-slot="${slot}"[^>]*>`, "g")) ?? []
157
+ }
158
+
159
+ /**
160
+ * The button a user knows as `name`: its visible text or its `aria-label`,
161
+ * exactly (a tooltip that merely mentions the word does not count).
162
+ */
163
+ function button(html: string, name: string): string | undefined {
164
+ return (html.match(/<button\b[^>]*>[\s\S]*?<\/button>/g) ?? []).find((b) => {
165
+ const text = decodeEntities(b.replace(/<[^>]*>/g, "")).trim()
166
+ return text === name || attribute(openingTags(b, "button")[0] ?? "", "aria-label") === name
167
+ })
168
+ }
169
+
170
+ /** The opening tag of {@link button}, for its attributes. */
171
+ function buttonTag(html: string, name: string): string {
172
+ return openingTags(button(html, name) ?? "", "button")[0] ?? ""
173
+ }
174
+
175
+ /** The slot a `[data-slot="…"]` focus target selects. */
176
+ function slotOf(selector: string): string {
177
+ const slot = /^\[data-slot="([^"]+)"\]$/.exec(selector)?.[1]
178
+ if (!slot) throw new Error(`not a data-slot selector: ${selector}`)
179
+ return slot
180
+ }
181
+
182
+ /**
183
+ * Every element of a component's output, children first-class, WITHOUT
184
+ * rendering any component in it: the event wiring of a hook-free component
185
+ * (its handlers are props) is testable without a DOM.
186
+ */
187
+ function elements(node: ReactNode): ReactElement<Record<string, unknown>>[] {
188
+ const out: ReactElement<Record<string, unknown>>[] = []
189
+ const visit = (n: ReactNode) => {
190
+ if (Array.isArray(n)) {
191
+ for (const child of n as ReactNode[]) visit(child)
192
+ return
193
+ }
194
+ if (!isValidElement<Record<string, unknown>>(n)) return
195
+ out.push(n)
196
+ visit(n.props.children as ReactNode)
197
+ }
198
+ visit(node)
199
+ return out
200
+ }
201
+
202
+ describe("titles: the palette and the canvas speak user words", () => {
203
+ const html = inGerman(<Workspace {...workspace()} />)
204
+ const items = html.match(/<button[^>]*data-slot="builder-palette-item"[\s\S]*?<\/button>/g) ?? []
205
+
206
+ it("names a titled widget by its title in the locale, with its summary and a quiet mono id", () => {
207
+ const orders = items.find((i) => i.includes("demo:orders")) ?? ""
208
+ expect(orders).toMatch(/font-medium">Bestellungen</)
209
+ expect(orders).toContain("Alle offenen Bestellungen.")
210
+ // A quiet mono id at the 13 px floor of the type scale, never 12 px.
211
+ const id = /<span class="([^"]*)">demo:orders</.exec(orders)?.[1]?.split(" ") ?? []
212
+ expect(id).toContain("font-mono")
213
+ expect(id).toContain("text-small")
214
+ })
215
+
216
+ it("resolves a titleKey through the LocaleProvider's translator", () => {
217
+ const keyed = items.find((i) => i.includes("demo:keyed")) ?? ""
218
+ expect(keyed).toMatch(/font-medium">Aus dem Katalog</)
219
+ })
220
+
221
+ it("falls back to the id as the title when there is none, and never shows it twice", () => {
222
+ const raw = items.find((i) => i.includes("demo:raw")) ?? ""
223
+ expect(raw).toMatch(/font-medium">demo:raw</)
224
+ expect(raw.split("demo:raw")).toHaveLength(2)
225
+ })
226
+
227
+ it("keeps the required keys (data-source vocabulary) out of the basic palette", () => {
228
+ expect(html).not.toContain("demo:customer")
229
+ expect(html).not.toContain(`${de.catalogueNeedsKeys} `)
230
+ const advanced = inGerman(<Workspace {...workspace({ advanced: true })} />)
231
+ expect(advanced).toContain(`${de.catalogueNeedsKeys} <span class="font-mono">demo:customer`)
232
+ })
233
+
234
+ it("titles the canvas cells; the id joins them only in advanced mode", () => {
235
+ expect(html).toMatch(/truncate font-medium">Bestellungen</)
236
+ expect(html).toMatch(/truncate font-medium">demo:raw</)
237
+ expect(html).not.toMatch(/truncate font-mono">demo:orders</)
238
+ const advanced = inGerman(<Workspace {...workspace({ advanced: true })} />)
239
+ expect(advanced).toMatch(/truncate font-mono">demo:orders</)
240
+ })
241
+
242
+ it("marks the row new elements go to in words, not by colour alone", () => {
243
+ const rows = slots(html, "builder-row")
244
+ expect(rows).toHaveLength(2)
245
+ expect(attribute(rows[0] ?? "", "data-target")).toBe("")
246
+ expect(attribute(rows[1] ?? "", "data-target")).toBeUndefined()
247
+ expect(html.split(de.rowTarget)).toHaveLength(2)
248
+ })
249
+
250
+ it("offers Add tab next to Add row, outside the palette (no overlap)", () => {
251
+ const withTab = inGerman(<Workspace {...workspace({ onAddTab: noop })} />)
252
+ const aside = /<aside[\s\S]*?<\/aside>/.exec(withTab)?.[0] ?? ""
253
+ expect(aside).not.toContain(de.addTab)
254
+ const canvas = /<section[\s\S]*<\/section>/.exec(withTab)?.[0] ?? ""
255
+ expect(canvas).toContain(`${de.addRow}</button>`)
256
+ expect(canvas).toContain(`${de.addTab}</button>`)
257
+ // The palette scrolls in its own box instead of overflowing a fixed one.
258
+ expect(aside).toMatch(/lg:overflow-y-auto/)
259
+ })
260
+ })
261
+
262
+ describe("palette groups: headed by the app's title, the namespace only in advanced mode", () => {
263
+ const titled = (over: Partial<WorkspaceProps> = {}) =>
264
+ inGerman(
265
+ <Workspace
266
+ {...workspace({
267
+ appDisplayByName: new Map([["demo", { title: { en: "Demo shop", de: "Vorführladen" } }]]),
268
+ ...over,
269
+ })}
270
+ />,
271
+ )
272
+ const headings = (html: string) =>
273
+ (html.match(/<div[^>]*data-slot="builder-palette-group"[^>]*>[\s\S]*?<\/div>/g) ?? []).map(
274
+ (h) =>
275
+ decodeEntities(h.replace(/<[^>]*>/g, " "))
276
+ .replace(/\s+/g, " ")
277
+ .trim(),
278
+ )
279
+
280
+ it("shows no heading for an untitled app (its name is a technical namespace)", () => {
281
+ expect(headings(inGerman(<Workspace {...workspace()} />))).toEqual([])
282
+ })
283
+
284
+ it("heads an untitled app's group with its name in advanced mode", () => {
285
+ expect(headings(inGerman(<Workspace {...workspace({ advanced: true })} />))).toEqual(["demo"])
286
+ })
287
+
288
+ it("heads a titled app's group with its title in the locale, the name joining only in advanced mode", () => {
289
+ expect(headings(titled())).toEqual(["Vorführladen"])
290
+ const advanced = titled({ advanced: true })
291
+ expect(headings(advanced)).toEqual(["Vorführladen demo"])
292
+ expect(advanced).toMatch(/font-mono font-normal">demo</)
293
+ })
294
+
295
+ it("resolves an app's titleKey through the LocaleProvider's translator", () => {
296
+ const keyed = inGerman(
297
+ <Workspace
298
+ {...workspace({ appDisplayByName: new Map([["demo", { titleKey: "demo.keyed.title" }]]) })}
299
+ />,
300
+ )
301
+ expect(headings(keyed)).toEqual(["Aus dem Katalog"])
302
+ })
303
+ })
304
+
305
+ describe("no hover-only controls (CI §12)", () => {
306
+ const html = inGerman(<Workspace {...workspace()} />)
307
+
308
+ it("reveals row actions on hover, focus inside the row and on touch; the target row shows them", () => {
309
+ const actions = slots(html, "builder-row-actions")
310
+ expect(actions).toHaveLength(2)
311
+ expect(attribute(actions[0] ?? "", "class")).toContain("opacity-100")
312
+ expect(attribute(actions[0] ?? "", "class")).not.toContain("opacity-0")
313
+ const quiet = attribute(actions[1] ?? "", "class") ?? ""
314
+ for (const cls of REVEAL_ON_INTENT.split(" ")) expect(quiet).toContain(cls)
315
+ })
316
+
317
+ it("REVEAL_ON_INTENT never hides without its focus and touch companions", () => {
318
+ const classes = REVEAL_ON_INTENT.split(" ")
319
+ expect(classes).toContain("opacity-0")
320
+ expect(classes).toContain("group-focus-within:opacity-100")
321
+ expect(classes).toContain("focus-within:opacity-100")
322
+ expect(classes).toContain("[@media(hover:none)]:opacity-100")
323
+ })
324
+
325
+ it("gives every tab visible rename and remove buttons with the tab's name", () => {
326
+ const tabs = inGerman(surface(TABS))
327
+ for (const label of ["Übersicht", "Details"]) {
328
+ expect(button(tabs, de.renameTabNamed(label))).toBeDefined()
329
+ expect(button(tabs, de.removeTabNamed(label))).toBeDefined()
330
+ }
331
+ const actions = slots(tabs, "builder-tab-actions")
332
+ expect(attribute(actions[0] ?? "", "class")).toContain("opacity-100")
333
+ expect(attribute(actions[0] ?? "", "class")).not.toContain("opacity-0")
334
+ for (const cls of REVEAL_ON_INTENT.split(" ")) {
335
+ expect(attribute(actions[1] ?? "", "class")).toContain(cls)
336
+ }
337
+ })
338
+
339
+ it("names the keyboard rename on every tab, and swaps the tab for a labelled field while renaming", () => {
340
+ const tabs = inGerman(surface(TABS))
341
+ const triggers = openingTags(tabs, "button").filter((t) => attribute(t, "role") === "tab")
342
+ expect(triggers).toHaveLength(2)
343
+ for (const t of triggers) expect(attribute(t, "title")).toBe(de.renameTitle)
344
+ expect(tabs).toContain(de.renameHint)
345
+
346
+ const editing = inGerman(surface(TABS, 1))
347
+ const input = openingTags(editing, "input")[0] ?? ""
348
+ expect(attribute(input, "aria-label")).toBe(de.renameTab)
349
+ expect(attribute(input, "value")).toBe("Details")
350
+ // The field is not nested inside the tab button (no interactive in interactive).
351
+ expect(editing).not.toMatch(/<button[^>]*role="tab"[^>]*>(?:(?!<\/button>)[\s\S])*<input/)
352
+ expect(
353
+ openingTags(editing, "button").filter((t) => attribute(t, "role") === "tab"),
354
+ ).toHaveLength(1)
355
+ })
356
+
357
+ it("puts the kit focus ring on every builder control", () => {
358
+ const markup = [
359
+ html,
360
+ inGerman(<Workspace {...workspace({ advanced: true, onAddTab: noop })} />),
361
+ inGerman(surface(TABS)),
362
+ inGerman(surface(TABS, 0)),
363
+ inGerman(toolbar({ advanced: true })),
364
+ ].join("")
365
+ const controls = [
366
+ ...openingTags(markup, "button"),
367
+ ...openingTags(markup, "input"),
368
+ ...openingTags(markup, "div").filter((t) => attribute(t, "role") === "separator"),
369
+ ...openingTags(markup, "h2").filter((t) => attribute(t, "tabindex") === "-1"),
370
+ ]
371
+ expect(controls.length).toBeGreaterThan(20)
372
+ for (const tag of controls)
373
+ expect(attribute(tag, "class"), tag).toContain("focus-visible:ring-focus")
374
+ })
375
+ })
376
+
377
+ describe("data sources (advanced): pickers name the choice, not the whole option", () => {
378
+ const html = inGerman(
379
+ <PipelineStrip
380
+ L={de}
381
+ keyEntries={[{ name: "demo:customerId", rawValue: "c-1" }]}
382
+ stepEntries={[{ id: "customer", step: "demo:resolve-customer" }]}
383
+ keyCatalog={[
384
+ {
385
+ key: "demo:customerId",
386
+ producedBySteps: [],
387
+ consumedBySteps: ["demo:resolve-customer"],
388
+ consumedByWidgets: [],
389
+ inContext: true,
390
+ },
391
+ ]}
392
+ availableSteps={[
393
+ {
394
+ id: "demo:resolve-customer",
395
+ app: "demo",
396
+ dataType: "demo:customer",
397
+ requires: ["demo:customerId"],
398
+ produces: ["demo:customer"],
399
+ title: { en: "Customer", de: "Kunde" },
400
+ },
401
+ ]}
402
+ stepsByApp={[]}
403
+ liveKeys={{}}
404
+ onAddKey={noop}
405
+ onUpdateKey={noop}
406
+ onRemoveKey={noop}
407
+ onAddStep={noop}
408
+ onUpdateStep={noop}
409
+ onRemoveStep={noop}
410
+ status="idle"
411
+ />,
412
+ )
413
+ const triggerText = (name: string) => {
414
+ const trigger = (
415
+ html.match(/<button\b[^>]*role="combobox"[^>]*>[\s\S]*?<\/button>/g) ?? []
416
+ ).find((b) => attribute(openingTags(b, "button")[0] ?? "", "aria-label") === name)
417
+ return decodeEntities((trigger ?? "").replace(/<[^>]*>/g, "")).trim()
418
+ }
419
+
420
+ it("shows the chosen key without its consumers", () => {
421
+ expect(triggerText(de.keyName)).toBe("demo:customerId")
422
+ })
423
+
424
+ it("shows the chosen step by its title, with its contract in words beneath", () => {
425
+ expect(triggerText(de.stepPickerLabel)).toBe("Kunde")
426
+ expect(html).toContain(`${de.catalogueNeedsKeys} <span class="font-mono">demo:customerId`)
427
+ expect(html).toContain(`${de.stepProvides} <span class="font-mono">demo:customer<`)
428
+ })
429
+ })
430
+
431
+ describe("toolbar: Apply, Discard and Save as dashboard are three different things", () => {
432
+ it("leads with Apply (primary), Discard and Save as dashboard beside it", () => {
433
+ const html = inGerman(toolbar())
434
+ const apply = buttonTag(html, de.apply)
435
+ expect(attribute(apply, "data-variant")).toBe("default")
436
+ expect(attribute(apply, "title")).toBe(de.applyHint)
437
+ expect(attribute(buttonTag(html, de.discard), "title")).toBe(de.discardHint)
438
+ const save = buttonTag(html, de.save)
439
+ expect(attribute(save, "data-variant")).toBe("outline")
440
+ expect(attribute(save, "title")).toBe(de.saveHint)
441
+ })
442
+
443
+ it("has no Apply without a view to apply to, but keeps Discard, which then resets", () => {
444
+ const html = inGerman(toolbar({ onApply: undefined }))
445
+ expect(button(html, de.apply)).toBeUndefined()
446
+ expect(button(html, de.discard)).toBeDefined()
447
+ // Nothing is left there: the tooltip says what Discard does instead.
448
+ expect(attribute(buttonTag(html, de.discard), "title")).toBe(de.discardHintStandalone)
449
+ expect(de.discardHintStandalone).not.toBe(de.discardHint)
450
+ })
451
+
452
+ it("marks unsaved changes in a live region that is always mounted", () => {
453
+ const clean = inGerman(toolbar())
454
+ const region = slots(clean, "builder-unsaved")[0] ?? ""
455
+ expect(attribute(region, "role")).toBe("status")
456
+ expect(clean).not.toContain(de.unsavedChanges)
457
+ expect(inGerman(toolbar({ unsaved: true }))).toContain(de.unsavedChanges)
458
+ })
459
+
460
+ it("keeps the catalogue and the data-source stats behind the Advanced toggle", () => {
461
+ const basic = inGerman(toolbar())
462
+ expect(attribute(buttonTag(basic, de.advanced), "aria-pressed")).toBe("false")
463
+ expect(button(basic, de.catalogue)).toBeUndefined()
464
+ expect(basic).not.toContain(de.statsReachable)
465
+ const advanced = inGerman(toolbar({ advanced: true }))
466
+ expect(attribute(buttonTag(advanced, de.advanced), "aria-pressed")).toBe("true")
467
+ expect(button(advanced, de.catalogue)).toBeDefined()
468
+ expect(advanced).toContain(de.statsReachable)
469
+ })
470
+
471
+ it("names the mode in words next to the title, with no status-tinted tile before it", () => {
472
+ // A mode is no status (CI §3.3), and a tinted icon tile before a title is
473
+ // the pattern WidgetHeader.icon deprecates (anti-slop U2).
474
+ for (const previewMode of [false, true]) {
475
+ const html = inGerman(toolbar({ previewMode }))
476
+ const lead = html.slice(0, html.indexOf("<h2"))
477
+ expect(lead, `previewMode ${previewMode}`).not.toMatch(
478
+ /bg-(?:success|warning|danger|info)-soft/,
479
+ )
480
+ expect(lead, `previewMode ${previewMode}`).not.toContain("<svg")
481
+ expect(html).toContain(previewMode ? de.previewBadge : de.builderBadge)
482
+ }
483
+ })
484
+
485
+ it("titles the builder with the focus target of the hand-off from the view", () => {
486
+ const title = slots(inGerman(toolbar()), slotOf(BUILDER_FOCUS_TARGET))
487
+ expect(title).toHaveLength(1)
488
+ expect(title[0]?.startsWith("<h2")).toBe(true)
489
+ expect(attribute(title[0] ?? "", "tabindex")).toBe("-1")
490
+ })
491
+ })
492
+
493
+ describe("save dialog: points to Apply only where there is one", () => {
494
+ const description = (canApply: boolean) =>
495
+ elements(
496
+ SaveDialog({
497
+ L: de,
498
+ open: true,
499
+ onOpenChange: noop,
500
+ canApply,
501
+ name: "",
502
+ setName: noop,
503
+ description: "",
504
+ setDescription: noop,
505
+ onSubmit: () => Promise.resolve(),
506
+ busy: false,
507
+ error: null,
508
+ }),
509
+ ).find((e) => e.type === DialogDescription)?.props.children
510
+
511
+ it("names Apply as the way to change the view when the builder has one", () => {
512
+ expect(description(true)).toBe(de.saveDialogDescription)
513
+ expect(de.saveDialogDescription).toContain(de.apply)
514
+ })
515
+
516
+ it("leaves Apply out in a builder without a view to apply to", () => {
517
+ expect(description(false)).toBe(de.saveDialogDescriptionStandalone)
518
+ expect(de.saveDialogDescriptionStandalone).not.toContain(de.apply)
519
+ })
520
+ })
521
+
522
+ describe("palette: a loading state first, then the elements or an empty state for people", () => {
523
+ it("shows a loading state, no count and no empty text while the catalogue is on its way", () => {
524
+ const html = inGerman(
525
+ <Workspace {...workspace({ paletteByApp: [], paletteCount: 0, paletteLoading: true })} />,
526
+ )
527
+ expect(slots(html, "builder-palette-loading")).toHaveLength(1)
528
+ expect(html).toMatch(new RegExp(`role="status"[^>]*>${de.paletteLoading}<`))
529
+ expect(html).not.toContain(de.paletteEmpty)
530
+ expect(html).not.toContain('data-slot="badge"')
531
+ })
532
+
533
+ it("says there are no elements yet without data-source words, and offers the data sources", () => {
534
+ const html = inGerman(<Workspace {...workspace({ paletteByApp: [], paletteCount: 0 })} />)
535
+ expect(slots(html, "builder-palette-empty")).toHaveLength(1)
536
+ expect(html).toContain(de.paletteEmpty)
537
+ for (const word of [de.keyName, de.stepsHeader, "Schritt"]) {
538
+ expect(de.paletteEmpty).not.toContain(word)
539
+ }
540
+ expect(button(html, de.openDataSources)).toBeDefined()
541
+ expect(html).not.toContain(de.paletteLoading)
542
+ })
543
+
544
+ it("opens the data sources from the empty palette", () => {
545
+ const onOpenDataSources = vi.fn<() => void>()
546
+ const actions = elements(PaletteEmpty({ L: de, onOpenDataSources })).filter(
547
+ (e) => e.type === Button,
548
+ )
549
+ expect(actions).toHaveLength(1)
550
+ expect(actions[0]?.props.onClick).toBe(onOpenDataSources)
551
+ })
552
+
553
+ it("lists the elements once they are there", () => {
554
+ const html = inGerman(<Workspace {...workspace()} />)
555
+ expect(slots(html, "builder-palette-loading")).toEqual([])
556
+ expect(slots(html, "builder-palette-empty")).toEqual([])
557
+ expect(slots(html, "builder-palette-item")).toHaveLength(PALETTE.length)
558
+ })
559
+ })
560
+
561
+ describe("discard confirmation: says what the view loses", () => {
562
+ const dialog = (changesSaved: boolean, onConfirm = noop) =>
563
+ elements(DiscardDialog({ L: de, open: true, onOpenChange: noop, changesSaved, onConfirm }))
564
+ const description = (changesSaved: boolean) =>
565
+ dialog(changesSaved).find((e) => e.type === DialogDescription)?.props.children
566
+
567
+ it("calls the changes lost when no saved dashboard holds them", () => {
568
+ expect(description(false)).toBe(de.discardDialogDescription)
569
+ })
570
+
571
+ it("says the saved dashboard keeps them when one does", () => {
572
+ expect(description(true)).toBe(de.discardDialogDescriptionSaved)
573
+ expect(de.discardDialogDescriptionSaved).toContain("Dashboard")
574
+ })
575
+
576
+ it("discards on its destructive button only", () => {
577
+ const onConfirm = vi.fn<() => void>()
578
+ const destructive = dialog(false, onConfirm).filter((e) => e.props.variant === "destructive")
579
+ expect(destructive).toHaveLength(1)
580
+ expect(destructive[0]?.props.onClick).toBe(onConfirm)
581
+ })
582
+ })
583
+
584
+ describe("tab rename by keyboard: the focus comes back, also under a new name", () => {
585
+ const base = {
586
+ L: de,
587
+ active: true,
588
+ renameRejected: false,
589
+ errorId: "rename-error",
590
+ onCancelRename: noop,
591
+ onRemove: noop,
592
+ }
593
+ const trigger = (item: ReactNode) => elements(item).find((e) => e.type === TabsTrigger)
594
+ const field = (item: ReactNode) =>
595
+ elements(item).find((e) => typeof e.props.onCommit === "function")
596
+
597
+ it("starts the rename with F2, and with Enter on the active tab only", () => {
598
+ const onStartRename = vi.fn<() => void>()
599
+ const props = { ...base, label: "Details", editing: false, onStartRename }
600
+ const keyDown = (active: boolean, key: string) => {
601
+ const item = TabItem({
602
+ ...props,
603
+ active,
604
+ onCommitRename: () => true,
605
+ triggerRef: noop,
606
+ onRefocus: noop,
607
+ })
608
+ const preventDefault = vi.fn<() => void>()
609
+ ;(trigger(item)?.props.onKeyDown as (e: object) => void)({ key, preventDefault })
610
+ return preventDefault
611
+ }
612
+ expect(keyDown(false, "F2")).toHaveBeenCalledOnce()
613
+ expect(keyDown(true, "Enter")).toHaveBeenCalledOnce()
614
+ expect(onStartRename).toHaveBeenCalledTimes(2)
615
+ expect(keyDown(false, "Enter")).not.toHaveBeenCalled()
616
+ expect(keyDown(true, "ArrowRight")).not.toHaveBeenCalled()
617
+ expect(onStartRename).toHaveBeenCalledTimes(2)
618
+ })
619
+
620
+ it("Enter with a NEW name focuses the renamed tab, which is a new tab (keyed by its label)", () => {
621
+ const refocus = createTabRefocus()
622
+ const onCommitRename = vi.fn<(label: string) => boolean>(() => true)
623
+ const editing = TabItem({
624
+ ...base,
625
+ label: "Details",
626
+ editing: true,
627
+ onStartRename: noop,
628
+ onCommitRename,
629
+ ...refocus.forTab(1),
630
+ })
631
+ expect(
632
+ (field(editing)?.props.onCommit as (v: string, k: boolean) => boolean)("Kosten", true),
633
+ ).toBe(true)
634
+ expect(onCommitRename).toHaveBeenCalledExactlyOnceWith("Kosten")
635
+ // React mounts the renamed tab afresh; its trigger attaches through the surface's hand-back.
636
+ const renamed = TabItem({
637
+ ...base,
638
+ label: "Kosten",
639
+ editing: false,
640
+ onStartRename: noop,
641
+ onCommitRename,
642
+ ...refocus.forTab(1),
643
+ })
644
+ const focus = vi.fn<() => void>()
645
+ ;(trigger(renamed)?.props.ref as (el: object) => void)({ focus })
646
+ expect(focus).toHaveBeenCalledOnce()
647
+ })
648
+
649
+ it("Escape asks for the focus back too; a pointer commit and a rejected name do not", () => {
650
+ const onRefocus = vi.fn<() => void>()
651
+ const onCancelRename = vi.fn<() => void>()
652
+ const item = (accepted: boolean) =>
653
+ TabItem({
654
+ ...base,
655
+ label: "Details",
656
+ editing: true,
657
+ onStartRename: noop,
658
+ onCommitRename: () => accepted,
659
+ onCancelRename,
660
+ triggerRef: noop,
661
+ onRefocus,
662
+ })
663
+ const commit = (accepted: boolean, byKeyboard: boolean) =>
664
+ (field(item(accepted))?.props.onCommit as (v: string, k: boolean) => boolean)("X", byKeyboard)
665
+ expect(commit(true, false)).toBe(true)
666
+ expect(commit(false, true)).toBe(false)
667
+ expect(onRefocus).not.toHaveBeenCalled()
668
+ ;(field(item(true))?.props.onCancel as () => void)()
669
+ expect(onRefocus).toHaveBeenCalledOnce()
670
+ expect(onCancelRename).toHaveBeenCalledOnce()
671
+ })
672
+ })
673
+
674
+ describe("LayoutBuilder: the data sources sit behind Advanced", () => {
675
+ const rows = [{ row: [] }] satisfies RowDef[]
676
+
677
+ it("shows only the layout and the preview by default", () => {
678
+ const html = inGerman(
679
+ <LayoutBuilder
680
+ initialLayout={{ rows }}
681
+ widgets={{}}
682
+ callTool={() => Promise.resolve({})}
683
+ onExit={noop}
684
+ />,
685
+ )
686
+ const tabs = (html.match(/<button[^>]*role="tab"[\s\S]*?<\/button>/g) ?? []).map((t) =>
687
+ t.replace(/<[^>]+>/g, "").trim(),
688
+ )
689
+ expect(tabs).toEqual([de.viewLayoutTab, de.viewPreviewTab])
690
+ expect(button(html, de.apply)).toBeDefined()
691
+ })
692
+
693
+ it("labels Apply with a consumer's override of the deprecated `done`, unless `apply` is set", () => {
694
+ const props = { widgets: {}, callTool: () => Promise.resolve({}), onExit: noop }
695
+ expect(
696
+ button(inGerman(<LayoutBuilder {...props} labels={{ done: "Fertig" }} />), "Fertig"),
697
+ ).toBeDefined()
698
+ const both = inGerman(<LayoutBuilder {...props} labels={{ done: "Fertig", apply: "Los" }} />)
699
+ expect(button(both, "Los")).toBeDefined()
700
+ expect(button(both, "Fertig")).toBeUndefined()
701
+ })
702
+
703
+ it("labels Apply with a LocaleProvider override of the deprecated `done` too", () => {
704
+ const html = renderToStaticMarkup(
705
+ <LocaleProvider locale="en" toolkitMessages={{ builder: { done: "Finish" } }}>
706
+ <LayoutBuilder widgets={{}} callTool={() => Promise.resolve({})} onExit={noop} />
707
+ </LocaleProvider>,
708
+ )
709
+ expect(button(html, "Finish")).toBeDefined()
710
+ expect(button(html, TOOLKIT_MESSAGES.en.builder.apply)).toBeUndefined()
711
+ })
712
+
713
+ it("shows the palette's loading state, not the empty state, before the catalogue answers", () => {
714
+ const html = inGerman(<LayoutBuilder widgets={{}} callTool={() => new Promise(noop)} />)
715
+ expect(slots(html, "builder-palette-loading")).toHaveLength(1)
716
+ expect(html).not.toContain(de.emptyHintTitle)
717
+ expect(html).not.toContain(de.paletteEmpty)
718
+ })
719
+
720
+ it("offers Discard but no Apply when embedded without onExit", () => {
721
+ const html = inGerman(<LayoutBuilder widgets={{}} callTool={() => Promise.resolve({})} />)
722
+ expect(button(html, de.apply)).toBeUndefined()
723
+ expect(button(html, de.discard)).toBeDefined()
724
+ })
725
+ })
726
+
727
+ describe("focus hand-off between the view and the builder", () => {
728
+ it("moves to the builder's title on entry and back to the edit button on exit", () => {
729
+ expect(buildModeFocusTarget(false, true)).toBe(BUILDER_FOCUS_TARGET)
730
+ expect(buildModeFocusTarget(true, false)).toBe(VIEW_FOCUS_TARGET)
731
+ expect(BUILDER_FOCUS_TARGET).toBe('[data-slot="builder-title"]')
732
+ expect(VIEW_FOCUS_TARGET).toBe('[data-slot="view-edit"]')
733
+ })
734
+
735
+ it("stays put when build mode did not switch", () => {
736
+ expect(buildModeFocusTarget(false, false)).toBeNull()
737
+ expect(buildModeFocusTarget(true, true)).toBeNull()
738
+ })
739
+
740
+ it("puts an embedded builder's focus on its title after a Discard reset, not on first mount", () => {
741
+ expect(builderMountFocusTarget(0)).toBeNull()
742
+ expect(builderMountFocusTarget(1)).toBe(BUILDER_FOCUS_TARGET)
743
+ expect(builderMountFocusTarget(5)).toBe(BUILDER_FOCUS_TARGET)
744
+ })
745
+ })
746
+
747
+ // -------------------------------------------------------------------------- //
748
+ // Static gates over the builder sources
749
+ // -------------------------------------------------------------------------- //
750
+
751
+ const VIEW_SOURCE =
752
+ import.meta.glob(["../mcp-app-view.tsx"], { query: "?raw", import: "default", eager: true })[
753
+ "../mcp-app-view.tsx"
754
+ ] ?? ""
755
+
756
+ /** The argument texts of every call to `callee` in a source. */
757
+ function callsTo(file: string, source: string, callee: string): string[][] {
758
+ const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
759
+ const found: string[][] = []
760
+ const visit = (node: ts.Node) => {
761
+ if (ts.isCallExpression(node) && node.expression.getText(sf) === callee) {
762
+ found.push(node.arguments.map((a) => a.getText(sf)))
763
+ }
764
+ ts.forEachChild(node, visit)
765
+ }
766
+ visit(sf)
767
+ return found
768
+ }
769
+
770
+ const BUILDER_SOURCES = import.meta.glob(
771
+ ["./*.tsx", "!./*.test.tsx", "../layout-builder.tsx", "../widget-props-sheet.tsx"],
772
+ { query: "?raw", import: "default", eager: true },
773
+ )
774
+
775
+ /** JSX elements that handle a double-click but no key (a pointer-only rename). */
776
+ function doubleClickWithoutKeys(file: string, source: string): string[] {
777
+ const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
778
+ const found: string[] = []
779
+ const visit = (node: ts.Node) => {
780
+ if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) {
781
+ const names = node.attributes.properties.map((p) =>
782
+ ts.isJsxAttribute(p) ? p.name.getText(sf) : "",
783
+ )
784
+ if (names.includes("onDoubleClick") && !names.includes("onKeyDown")) {
785
+ found.push(`${file}: <${node.tagName.getText(sf)}>`)
786
+ }
787
+ }
788
+ ts.forEachChild(node, visit)
789
+ }
790
+ visit(sf)
791
+ return found
792
+ }
793
+
794
+ describe("builder source gates", () => {
795
+ it("scans the builder sources", () => {
796
+ expect(Object.keys(BUILDER_SOURCES)).toContain("./LayoutSurface.tsx")
797
+ expect(Object.keys(BUILDER_SOURCES)).toContain("../layout-builder.tsx")
798
+ expect(Object.keys(BUILDER_SOURCES).length).toBeGreaterThan(12)
799
+ })
800
+
801
+ it("hides a control only through REVEAL_ON_INTENT (never opacity-0 behind hover alone)", () => {
802
+ const offenders = Object.entries(BUILDER_SOURCES).filter(([, src]) => /\bopacity-0\b/.test(src))
803
+ expect(offenders.map(([file]) => file)).toEqual([])
804
+ })
805
+
806
+ it("wires the focus hand-off: McpAppView across build mode, LayoutBuilder after a reset", () => {
807
+ // McpAppView moves the focus between the edit button and the builder's title.
808
+ expect(callsTo("mcp-app-view.tsx", VIEW_SOURCE, "useBuildModeFocus")).toEqual([
809
+ ["buildMode", "frameRef"],
810
+ ])
811
+ expect(VIEW_SOURCE).toMatch(/<div ref=\{frameRef\}[^>]*data-slot="mcp-app-view"/)
812
+ // LayoutBuilder focuses its title after a Discard reset, through its root element.
813
+ const builder = BUILDER_SOURCES["../layout-builder.tsx"] ?? ""
814
+ expect(callsTo("layout-builder.tsx", builder, "builderMountFocusTarget")).toEqual([
815
+ ["generation"],
816
+ ])
817
+ expect(callsTo("layout-builder.tsx", builder, "useFocusOnMount")).toEqual([["mountFocus"]])
818
+ expect(builder).toMatch(/<div ref=\{rootRef\}/)
819
+ // The gate sees a call it looks for.
820
+ expect(callsTo("self.tsx", "useBuildModeFocus(a, b)", "useBuildModeFocus")).toEqual([
821
+ ["a", "b"],
822
+ ])
823
+ })
824
+
825
+ it("pairs every double-click with a keyboard path", () => {
826
+ const offenders = Object.entries(BUILDER_SOURCES).flatMap(([file, src]) =>
827
+ doubleClickWithoutKeys(file, src),
828
+ )
829
+ expect(offenders).toEqual([])
830
+ expect(
831
+ doubleClickWithoutKeys("self.tsx", "const A = () => <b onDoubleClick={f} />").length,
832
+ ).toBe(1)
833
+ })
834
+
835
+ it("renders every builder and props-sheet catalog string somewhere (no dead copy)", () => {
836
+ const all = Object.values(BUILDER_SOURCES).join("\n")
837
+ const builderKeys = Object.keys(TOOLKIT_MESSAGES.en.builder)
838
+ const unused = builderKeys.filter((key) => !new RegExp(`\\.${key}\\b`).test(all))
839
+ expect(unused).toEqual([])
840
+ const sheet = BUILDER_SOURCES["../widget-props-sheet.tsx"] ?? ""
841
+ const sheetKeys = Object.keys(TOOLKIT_MESSAGES.en.propsSheet)
842
+ expect(sheetKeys.filter((key) => !new RegExp(`\\b${key}\\b`).test(sheet))).toEqual([])
843
+ })
844
+ })