@miragon/mcp-toolkit-ui 0.10.1

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 (320) hide show
  1. package/README.md +36 -0
  2. package/dist/app/adapt-data-widget.d.ts +33 -0
  3. package/dist/app/adapt-data-widget.d.ts.map +1 -0
  4. package/dist/app/adapt-data-widget.js +35 -0
  5. package/dist/app/adapt-data-widget.js.map +1 -0
  6. package/dist/app/builder/CatalogueSheet.d.ts +13 -0
  7. package/dist/app/builder/CatalogueSheet.d.ts.map +1 -0
  8. package/dist/app/builder/CatalogueSheet.js +33 -0
  9. package/dist/app/builder/CatalogueSheet.js.map +1 -0
  10. package/dist/app/builder/PipelineStrip.d.ts +22 -0
  11. package/dist/app/builder/PipelineStrip.d.ts.map +1 -0
  12. package/dist/app/builder/PipelineStrip.js +31 -0
  13. package/dist/app/builder/PipelineStrip.js.map +1 -0
  14. package/dist/app/builder/PreviewPane.d.ts +13 -0
  15. package/dist/app/builder/PreviewPane.d.ts.map +1 -0
  16. package/dist/app/builder/PreviewPane.js +21 -0
  17. package/dist/app/builder/PreviewPane.js.map +1 -0
  18. package/dist/app/builder/SaveDialog.d.ts +14 -0
  19. package/dist/app/builder/SaveDialog.d.ts.map +1 -0
  20. package/dist/app/builder/SaveDialog.js +14 -0
  21. package/dist/app/builder/SaveDialog.js.map +1 -0
  22. package/dist/app/builder/Toolbar.d.ts +17 -0
  23. package/dist/app/builder/Toolbar.d.ts.map +1 -0
  24. package/dist/app/builder/Toolbar.js +15 -0
  25. package/dist/app/builder/Toolbar.js.map +1 -0
  26. package/dist/app/builder/Workspace.d.ts +28 -0
  27. package/dist/app/builder/Workspace.d.ts.map +1 -0
  28. package/dist/app/builder/Workspace.js +117 -0
  29. package/dist/app/builder/Workspace.js.map +1 -0
  30. package/dist/app/builder/builder-model.d.ts +86 -0
  31. package/dist/app/builder/builder-model.d.ts.map +1 -0
  32. package/dist/app/builder/builder-model.js +174 -0
  33. package/dist/app/builder/builder-model.js.map +1 -0
  34. package/dist/app/builder/builder-reducer.d.ts +71 -0
  35. package/dist/app/builder/builder-reducer.d.ts.map +1 -0
  36. package/dist/app/builder/builder-reducer.js +201 -0
  37. package/dist/app/builder/builder-reducer.js.map +1 -0
  38. package/dist/app/builder/labels.d.ts +78 -0
  39. package/dist/app/builder/labels.d.ts.map +1 -0
  40. package/dist/app/builder/labels.js +71 -0
  41. package/dist/app/builder/labels.js.map +1 -0
  42. package/dist/app/builder/status-chip.d.ts +10 -0
  43. package/dist/app/builder/status-chip.d.ts.map +1 -0
  44. package/dist/app/builder/status-chip.js +16 -0
  45. package/dist/app/builder/status-chip.js.map +1 -0
  46. package/dist/app/host-bridge.d.ts +211 -0
  47. package/dist/app/host-bridge.d.ts.map +1 -0
  48. package/dist/app/host-bridge.js +247 -0
  49. package/dist/app/host-bridge.js.map +1 -0
  50. package/dist/app/index.d.ts +16 -0
  51. package/dist/app/index.d.ts.map +1 -0
  52. package/dist/app/index.js +18 -0
  53. package/dist/app/index.js.map +1 -0
  54. package/dist/app/layout-builder.d.ts +48 -0
  55. package/dist/app/layout-builder.d.ts.map +1 -0
  56. package/dist/app/layout-builder.js +418 -0
  57. package/dist/app/layout-builder.js.map +1 -0
  58. package/dist/app/mcp-app-view.d.ts +43 -0
  59. package/dist/app/mcp-app-view.d.ts.map +1 -0
  60. package/dist/app/mcp-app-view.js +169 -0
  61. package/dist/app/mcp-app-view.js.map +1 -0
  62. package/dist/app/mcp-toolkit-app.d.ts +18 -0
  63. package/dist/app/mcp-toolkit-app.d.ts.map +1 -0
  64. package/dist/app/mcp-toolkit-app.js +22 -0
  65. package/dist/app/mcp-toolkit-app.js.map +1 -0
  66. package/dist/app/use-host-actions.d.ts +57 -0
  67. package/dist/app/use-host-actions.d.ts.map +1 -0
  68. package/dist/app/use-host-actions.js +42 -0
  69. package/dist/app/use-host-actions.js.map +1 -0
  70. package/dist/app/widget-fixture.d.ts +180 -0
  71. package/dist/app/widget-fixture.d.ts.map +1 -0
  72. package/dist/app/widget-fixture.js +273 -0
  73. package/dist/app/widget-fixture.js.map +1 -0
  74. package/dist/app/widget-props-sheet.d.ts +20 -0
  75. package/dist/app/widget-props-sheet.d.ts.map +1 -0
  76. package/dist/app/widget-props-sheet.js +87 -0
  77. package/dist/app/widget-props-sheet.js.map +1 -0
  78. package/dist/app/widget-renderer.d.ts +87 -0
  79. package/dist/app/widget-renderer.d.ts.map +1 -0
  80. package/dist/app/widget-renderer.js +105 -0
  81. package/dist/app/widget-renderer.js.map +1 -0
  82. package/dist/components/DrillButton.d.ts +22 -0
  83. package/dist/components/DrillButton.d.ts.map +1 -0
  84. package/dist/components/DrillButton.js +13 -0
  85. package/dist/components/DrillButton.js.map +1 -0
  86. package/dist/components/FilterBar.d.ts +28 -0
  87. package/dist/components/FilterBar.d.ts.map +1 -0
  88. package/dist/components/FilterBar.js +12 -0
  89. package/dist/components/FilterBar.js.map +1 -0
  90. package/dist/components/GridLayout.d.ts +26 -0
  91. package/dist/components/GridLayout.d.ts.map +1 -0
  92. package/dist/components/GridLayout.js +31 -0
  93. package/dist/components/GridLayout.js.map +1 -0
  94. package/dist/components/GroupCard.d.ts +21 -0
  95. package/dist/components/GroupCard.d.ts.map +1 -0
  96. package/dist/components/GroupCard.js +24 -0
  97. package/dist/components/GroupCard.js.map +1 -0
  98. package/dist/components/KpiGrid.d.ts +43 -0
  99. package/dist/components/KpiGrid.d.ts.map +1 -0
  100. package/dist/components/KpiGrid.js +35 -0
  101. package/dist/components/KpiGrid.js.map +1 -0
  102. package/dist/components/ListFooter.d.ts +22 -0
  103. package/dist/components/ListFooter.d.ts.map +1 -0
  104. package/dist/components/ListFooter.js +13 -0
  105. package/dist/components/ListFooter.js.map +1 -0
  106. package/dist/components/Pills.d.ts +25 -0
  107. package/dist/components/Pills.d.ts.map +1 -0
  108. package/dist/components/Pills.js +17 -0
  109. package/dist/components/Pills.js.map +1 -0
  110. package/dist/components/SectionHeading.d.ts +16 -0
  111. package/dist/components/SectionHeading.d.ts.map +1 -0
  112. package/dist/components/SectionHeading.js +10 -0
  113. package/dist/components/SectionHeading.js.map +1 -0
  114. package/dist/components/WidgetHeader.d.ts +21 -0
  115. package/dist/components/WidgetHeader.d.ts.map +1 -0
  116. package/dist/components/WidgetHeader.js +11 -0
  117. package/dist/components/WidgetHeader.js.map +1 -0
  118. package/dist/hooks/index.d.ts +11 -0
  119. package/dist/hooks/index.d.ts.map +1 -0
  120. package/dist/hooks/index.js +7 -0
  121. package/dist/hooks/index.js.map +1 -0
  122. package/dist/hooks/use-debounced-value.d.ts +7 -0
  123. package/dist/hooks/use-debounced-value.d.ts.map +1 -0
  124. package/dist/hooks/use-debounced-value.js +15 -0
  125. package/dist/hooks/use-debounced-value.js.map +1 -0
  126. package/dist/hooks/use-mobile.d.ts +6 -0
  127. package/dist/hooks/use-mobile.d.ts.map +1 -0
  128. package/dist/hooks/use-mobile.js +19 -0
  129. package/dist/hooks/use-mobile.js.map +1 -0
  130. package/dist/hooks/use-tool-query.d.ts +21 -0
  131. package/dist/hooks/use-tool-query.d.ts.map +1 -0
  132. package/dist/hooks/use-tool-query.js +47 -0
  133. package/dist/hooks/use-tool-query.js.map +1 -0
  134. package/dist/hooks/use-tool-result-recovery.d.ts +54 -0
  135. package/dist/hooks/use-tool-result-recovery.d.ts.map +1 -0
  136. package/dist/hooks/use-tool-result-recovery.js +93 -0
  137. package/dist/hooks/use-tool-result-recovery.js.map +1 -0
  138. package/dist/hooks/use-view-data.d.ts +19 -0
  139. package/dist/hooks/use-view-data.d.ts.map +1 -0
  140. package/dist/hooks/use-view-data.js +18 -0
  141. package/dist/hooks/use-view-data.js.map +1 -0
  142. package/dist/hooks/use-view-tool-query.d.ts +18 -0
  143. package/dist/hooks/use-view-tool-query.d.ts.map +1 -0
  144. package/dist/hooks/use-view-tool-query.js +29 -0
  145. package/dist/hooks/use-view-tool-query.js.map +1 -0
  146. package/dist/index.d.ts +47 -0
  147. package/dist/index.d.ts.map +1 -0
  148. package/dist/index.js +56 -0
  149. package/dist/index.js.map +1 -0
  150. package/dist/lib/parse-tool-result.d.ts +39 -0
  151. package/dist/lib/parse-tool-result.d.ts.map +1 -0
  152. package/dist/lib/parse-tool-result.js +66 -0
  153. package/dist/lib/parse-tool-result.js.map +1 -0
  154. package/dist/lib/tone-utils.d.ts +18 -0
  155. package/dist/lib/tone-utils.d.ts.map +1 -0
  156. package/dist/lib/tone-utils.js +25 -0
  157. package/dist/lib/tone-utils.js.map +1 -0
  158. package/dist/lib/utils.d.ts +3 -0
  159. package/dist/lib/utils.d.ts.map +1 -0
  160. package/dist/lib/utils.js +6 -0
  161. package/dist/lib/utils.js.map +1 -0
  162. package/dist/locale/index.d.ts +3 -0
  163. package/dist/locale/index.d.ts.map +1 -0
  164. package/dist/locale/index.js +7 -0
  165. package/dist/locale/index.js.map +1 -0
  166. package/dist/locale/locale-provider.d.ts +50 -0
  167. package/dist/locale/locale-provider.d.ts.map +1 -0
  168. package/dist/locale/locale-provider.js +47 -0
  169. package/dist/locale/locale-provider.js.map +1 -0
  170. package/dist/primitives/alert.d.ts +10 -0
  171. package/dist/primitives/alert.d.ts.map +1 -0
  172. package/dist/primitives/alert.js +25 -0
  173. package/dist/primitives/alert.js.map +1 -0
  174. package/dist/primitives/badge.d.ts +8 -0
  175. package/dist/primitives/badge.d.ts.map +1 -0
  176. package/dist/primitives/badge.js +34 -0
  177. package/dist/primitives/badge.js.map +1 -0
  178. package/dist/primitives/button.d.ts +11 -0
  179. package/dist/primitives/button.d.ts.map +1 -0
  180. package/dist/primitives/button.js +36 -0
  181. package/dist/primitives/button.js.map +1 -0
  182. package/dist/primitives/card.d.ts +12 -0
  183. package/dist/primitives/card.d.ts.map +1 -0
  184. package/dist/primitives/card.js +25 -0
  185. package/dist/primitives/card.js.map +1 -0
  186. package/dist/primitives/dialog.d.ts +18 -0
  187. package/dist/primitives/dialog.d.ts.map +1 -0
  188. package/dist/primitives/dialog.js +38 -0
  189. package/dist/primitives/dialog.js.map +1 -0
  190. package/dist/primitives/input.d.ts +4 -0
  191. package/dist/primitives/input.d.ts.map +1 -0
  192. package/dist/primitives/input.js +7 -0
  193. package/dist/primitives/input.js.map +1 -0
  194. package/dist/primitives/scroll-area.d.ts +6 -0
  195. package/dist/primitives/scroll-area.d.ts.map +1 -0
  196. package/dist/primitives/scroll-area.js +11 -0
  197. package/dist/primitives/scroll-area.js.map +1 -0
  198. package/dist/primitives/select.d.ts +16 -0
  199. package/dist/primitives/select.d.ts.map +1 -0
  200. package/dist/primitives/select.js +38 -0
  201. package/dist/primitives/select.js.map +1 -0
  202. package/dist/primitives/separator.d.ts +4 -0
  203. package/dist/primitives/separator.d.ts.map +1 -0
  204. package/dist/primitives/separator.js +9 -0
  205. package/dist/primitives/separator.js.map +1 -0
  206. package/dist/primitives/sheet.d.ts +15 -0
  207. package/dist/primitives/sheet.d.ts.map +1 -0
  208. package/dist/primitives/sheet.js +41 -0
  209. package/dist/primitives/sheet.js.map +1 -0
  210. package/dist/primitives/skeleton.d.ts +3 -0
  211. package/dist/primitives/skeleton.d.ts.map +1 -0
  212. package/dist/primitives/skeleton.js +7 -0
  213. package/dist/primitives/skeleton.js.map +1 -0
  214. package/dist/primitives/switch.d.ts +5 -0
  215. package/dist/primitives/switch.d.ts.map +1 -0
  216. package/dist/primitives/switch.js +8 -0
  217. package/dist/primitives/switch.js.map +1 -0
  218. package/dist/primitives/table.d.ts +11 -0
  219. package/dist/primitives/table.d.ts.map +1 -0
  220. package/dist/primitives/table.js +28 -0
  221. package/dist/primitives/table.js.map +1 -0
  222. package/dist/primitives/tabs.d.ts +12 -0
  223. package/dist/primitives/tabs.d.ts.map +1 -0
  224. package/dist/primitives/tabs.js +29 -0
  225. package/dist/primitives/tabs.js.map +1 -0
  226. package/dist/providers/query-provider.d.ts +11 -0
  227. package/dist/providers/query-provider.d.ts.map +1 -0
  228. package/dist/providers/query-provider.js +20 -0
  229. package/dist/providers/query-provider.js.map +1 -0
  230. package/dist/theme/create-theme.d.ts +130 -0
  231. package/dist/theme/create-theme.d.ts.map +1 -0
  232. package/dist/theme/create-theme.js +65 -0
  233. package/dist/theme/create-theme.js.map +1 -0
  234. package/dist/theme/index.d.ts +7 -0
  235. package/dist/theme/index.d.ts.map +1 -0
  236. package/dist/theme/index.js +7 -0
  237. package/dist/theme/index.js.map +1 -0
  238. package/dist/theme/presets.d.ts +25 -0
  239. package/dist/theme/presets.d.ts.map +1 -0
  240. package/dist/theme/presets.js +68 -0
  241. package/dist/theme/presets.js.map +1 -0
  242. package/dist/theme/theme-provider.d.ts +69 -0
  243. package/dist/theme/theme-provider.d.ts.map +1 -0
  244. package/dist/theme/theme-provider.js +79 -0
  245. package/dist/theme/theme-provider.js.map +1 -0
  246. package/package.json +71 -0
  247. package/src/app/adapt-data-widget.tsx +51 -0
  248. package/src/app/builder/CatalogueSheet.tsx +230 -0
  249. package/src/app/builder/PipelineStrip.tsx +260 -0
  250. package/src/app/builder/PreviewPane.tsx +72 -0
  251. package/src/app/builder/SaveDialog.tsx +95 -0
  252. package/src/app/builder/Toolbar.tsx +95 -0
  253. package/src/app/builder/Workspace.tsx +483 -0
  254. package/src/app/builder/builder-model.test.ts +351 -0
  255. package/src/app/builder/builder-model.ts +213 -0
  256. package/src/app/builder/builder-reducer.test.ts +441 -0
  257. package/src/app/builder/builder-reducer.ts +289 -0
  258. package/src/app/builder/labels.ts +139 -0
  259. package/src/app/builder/status-chip.tsx +37 -0
  260. package/src/app/host-bridge.test.ts +206 -0
  261. package/src/app/host-bridge.tsx +371 -0
  262. package/src/app/index.ts +51 -0
  263. package/src/app/layout-builder.tsx +872 -0
  264. package/src/app/mcp-app-view.tsx +367 -0
  265. package/src/app/mcp-toolkit-app.tsx +25 -0
  266. package/src/app/use-host-actions.test.ts +16 -0
  267. package/src/app/use-host-actions.tsx +88 -0
  268. package/src/app/widget-fixture.test.ts +108 -0
  269. package/src/app/widget-fixture.tsx +441 -0
  270. package/src/app/widget-props-sheet.tsx +285 -0
  271. package/src/app/widget-renderer.test.ts +42 -0
  272. package/src/app/widget-renderer.tsx +200 -0
  273. package/src/components/DrillButton.tsx +37 -0
  274. package/src/components/FilterBar.tsx +68 -0
  275. package/src/components/GridLayout.tsx +49 -0
  276. package/src/components/GroupCard.tsx +52 -0
  277. package/src/components/KpiGrid.tsx +134 -0
  278. package/src/components/ListFooter.tsx +48 -0
  279. package/src/components/Pills.tsx +45 -0
  280. package/src/components/SectionHeading.tsx +26 -0
  281. package/src/components/WidgetHeader.tsx +51 -0
  282. package/src/globals.css +168 -0
  283. package/src/hooks/index.ts +17 -0
  284. package/src/hooks/use-debounced-value.ts +15 -0
  285. package/src/hooks/use-mobile.ts +24 -0
  286. package/src/hooks/use-tool-query.ts +63 -0
  287. package/src/hooks/use-tool-result-recovery.test.ts +65 -0
  288. package/src/hooks/use-tool-result-recovery.ts +144 -0
  289. package/src/hooks/use-view-data.ts +31 -0
  290. package/src/hooks/use-view-tool-query.ts +38 -0
  291. package/src/index.ts +134 -0
  292. package/src/lib/parse-tool-result.test.ts +139 -0
  293. package/src/lib/parse-tool-result.ts +89 -0
  294. package/src/lib/tone-utils.test.ts +35 -0
  295. package/src/lib/tone-utils.ts +38 -0
  296. package/src/lib/utils.ts +6 -0
  297. package/src/locale/index.ts +7 -0
  298. package/src/locale/locale-provider.tsx +81 -0
  299. package/src/primitives/alert.tsx +60 -0
  300. package/src/primitives/badge.tsx +49 -0
  301. package/src/primitives/button.tsx +62 -0
  302. package/src/primitives/card.tsx +92 -0
  303. package/src/primitives/dialog.tsx +144 -0
  304. package/src/primitives/input.tsx +21 -0
  305. package/src/primitives/scroll-area.tsx +54 -0
  306. package/src/primitives/select.tsx +173 -0
  307. package/src/primitives/separator.tsx +21 -0
  308. package/src/primitives/sheet.tsx +134 -0
  309. package/src/primitives/skeleton.tsx +13 -0
  310. package/src/primitives/switch.tsx +24 -0
  311. package/src/primitives/table.tsx +87 -0
  312. package/src/primitives/tabs.tsx +79 -0
  313. package/src/providers/query-provider.tsx +38 -0
  314. package/src/theme/create-theme.test.ts +108 -0
  315. package/src/theme/create-theme.ts +158 -0
  316. package/src/theme/index.ts +9 -0
  317. package/src/theme/presets.ts +82 -0
  318. package/src/theme/theme-provider.tsx +152 -0
  319. package/src/ui-catalog.test.ts +112 -0
  320. package/ui-catalog.json +1280 -0
@@ -0,0 +1,351 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import type { LayoutConfig } from "@miragon/mcp-toolkit-core"
3
+ import {
4
+ cloneRows,
5
+ defaultStepIdFor,
6
+ draftToLayout,
7
+ emptyRows,
8
+ entriesToKeys,
9
+ formatLiveKeyValue,
10
+ initDraft,
11
+ keysToEntries,
12
+ parseKeyValue,
13
+ sizeToSpan,
14
+ toPipelineContext,
15
+ valueTypeLabel,
16
+ type DraftLayout,
17
+ type KeyEntry,
18
+ type WireContext,
19
+ } from "./builder-model.js"
20
+
21
+ describe("emptyRows", () => {
22
+ it("returns a single empty row", () => {
23
+ expect(emptyRows()).toEqual([{ row: [] }])
24
+ })
25
+
26
+ it("returns a fresh array each call (no shared reference)", () => {
27
+ expect(emptyRows()).not.toBe(emptyRows())
28
+ })
29
+ })
30
+
31
+ describe("cloneRows", () => {
32
+ it("deep-clones rows and cells", () => {
33
+ const rows = [{ row: [{ widget: "w", span: 6, props: { a: 1 } }] }]
34
+ const clone = cloneRows(rows)
35
+ expect(clone).toEqual(rows)
36
+ expect(clone).not.toBe(rows)
37
+ expect(clone[0]).not.toBe(rows[0])
38
+ expect(clone[0]!.row[0]).not.toBe(rows[0]!.row[0])
39
+ })
40
+ })
41
+
42
+ describe("initDraft", () => {
43
+ it("defaults to a single empty rows draft when given undefined", () => {
44
+ expect(initDraft(undefined)).toEqual({ kind: "rows", rows: [{ row: [] }] })
45
+ })
46
+
47
+ it("normalizes a legacy flat-array layout into a rows draft", () => {
48
+ const layout: LayoutConfig = [{ row: [{ widget: "a" }] }]
49
+ expect(initDraft(layout)).toEqual({ kind: "rows", rows: [{ row: [{ widget: "a" }] }] })
50
+ })
51
+
52
+ it("keeps a { rows } layout as a rows draft", () => {
53
+ const layout: LayoutConfig = { rows: [{ row: [{ widget: "a", span: 4 }] }] }
54
+ expect(initDraft(layout)).toEqual({ kind: "rows", rows: [{ row: [{ widget: "a", span: 4 }] }] })
55
+ })
56
+
57
+ it("maps a { tabs } layout into a tabs draft", () => {
58
+ const layout: LayoutConfig = {
59
+ tabs: [{ label: "T1", rows: [{ row: [{ widget: "a" }] }] }],
60
+ }
61
+ expect(initDraft(layout)).toEqual({
62
+ kind: "tabs",
63
+ tabs: [{ label: "T1", rows: [{ row: [{ widget: "a" }] }] }],
64
+ })
65
+ })
66
+
67
+ it("falls back to an empty rows draft for an empty tabs layout", () => {
68
+ expect(initDraft({ tabs: [] })).toEqual({ kind: "rows", rows: [{ row: [] }] })
69
+ })
70
+
71
+ it("clones the source rows so later draft edits do not mutate the input", () => {
72
+ const layout: LayoutConfig = { rows: [{ row: [{ widget: "a" }] }] }
73
+ const draft = initDraft(layout)
74
+ if (draft.kind !== "rows") throw new Error("expected rows draft")
75
+ draft.rows[0]!.row.push({ widget: "b" })
76
+ expect(layout).toEqual({ rows: [{ row: [{ widget: "a" }] }] })
77
+ })
78
+ })
79
+
80
+ describe("draftToLayout", () => {
81
+ it("emits a { rows } layout from a rows draft", () => {
82
+ const draft: DraftLayout = { kind: "rows", rows: [{ row: [{ widget: "a", span: 6 }] }] }
83
+ expect(draftToLayout(draft)).toEqual({ rows: [{ row: [{ widget: "a", span: 6 }] }] })
84
+ })
85
+
86
+ it("emits a { tabs } layout from a tabs draft", () => {
87
+ const draft: DraftLayout = {
88
+ kind: "tabs",
89
+ tabs: [{ label: "T1", rows: [{ row: [{ widget: "a" }] }] }],
90
+ }
91
+ expect(draftToLayout(draft)).toEqual({
92
+ tabs: [{ label: "T1", rows: [{ row: [{ widget: "a" }] }] }],
93
+ })
94
+ })
95
+
96
+ it("clones rows so the emitted layout is detached from the draft", () => {
97
+ const draft: DraftLayout = { kind: "rows", rows: [{ row: [{ widget: "a" }] }] }
98
+ const layout = draftToLayout(draft) as { rows: { row: { widget: string }[] }[] }
99
+ layout.rows[0]!.row.push({ widget: "b" })
100
+ expect(draft.rows).toEqual([{ row: [{ widget: "a" }] }])
101
+ })
102
+
103
+ it("round-trips initDraft -> draftToLayout for tabs", () => {
104
+ const layout: LayoutConfig = {
105
+ tabs: [
106
+ { label: "One", rows: [{ row: [{ widget: "a", span: 3 }] }] },
107
+ { label: "Two", rows: [{ row: [] }] },
108
+ ],
109
+ }
110
+ expect(draftToLayout(initDraft(layout))).toEqual(layout)
111
+ })
112
+ })
113
+
114
+ describe("parseKeyValue", () => {
115
+ it("returns empty string for blank/whitespace input", () => {
116
+ expect(parseKeyValue("")).toBe("")
117
+ expect(parseKeyValue(" ")).toBe("")
118
+ })
119
+
120
+ it("keeps bare words as strings", () => {
121
+ expect(parseKeyValue("hello")).toBe("hello")
122
+ })
123
+
124
+ it("keeps bare numbers as strings (IDs are strings here)", () => {
125
+ expect(parseKeyValue("1")).toBe("1")
126
+ expect(parseKeyValue("42")).toBe("42")
127
+ })
128
+
129
+ it("parses JSON literals true/false/null", () => {
130
+ expect(parseKeyValue("true")).toBe(true)
131
+ expect(parseKeyValue("false")).toBe(false)
132
+ expect(parseKeyValue("null")).toBeNull()
133
+ })
134
+
135
+ it("parses explicit JSON objects, arrays and quoted strings", () => {
136
+ expect(parseKeyValue('{"id":1}')).toEqual({ id: 1 })
137
+ expect(parseKeyValue("[1,2]")).toEqual([1, 2])
138
+ expect(parseKeyValue('"quoted"')).toBe("quoted")
139
+ })
140
+
141
+ it("preserves the raw string when an obvious-JSON marker fails to parse", () => {
142
+ expect(parseKeyValue("{not json")).toBe("{not json")
143
+ expect(parseKeyValue("[oops")).toBe("[oops")
144
+ })
145
+
146
+ it("does not parse a leading-whitespace number as JSON", () => {
147
+ expect(parseKeyValue(" 7 ")).toBe(" 7 ")
148
+ })
149
+ })
150
+
151
+ describe("keysToEntries", () => {
152
+ it("returns [] for undefined", () => {
153
+ expect(keysToEntries(undefined)).toEqual([])
154
+ })
155
+
156
+ it("leaves plain strings as raw values", () => {
157
+ expect(keysToEntries({ id: "abc" })).toEqual([{ name: "id", rawValue: "abc" }])
158
+ })
159
+
160
+ it("JSON-encodes non-string values", () => {
161
+ expect(keysToEntries({ n: 5, b: true, o: { x: 1 } })).toEqual([
162
+ { name: "n", rawValue: "5" },
163
+ { name: "b", rawValue: "true" },
164
+ { name: "o", rawValue: '{"x":1}' },
165
+ ])
166
+ })
167
+
168
+ it("wraps strings whose re-parse would change type (e.g. the literal 'true')", () => {
169
+ expect(keysToEntries({ flag: "true" })).toEqual([{ name: "flag", rawValue: '"true"' }])
170
+ })
171
+
172
+ it("wraps numeric-looking strings only if they would re-parse to a different value", () => {
173
+ // "1" re-parses to the string "1" (bare numbers stay strings), so no wrapping.
174
+ expect(keysToEntries({ id: "1" })).toEqual([{ name: "id", rawValue: "1" }])
175
+ })
176
+
177
+ it("wraps strings that look like JSON objects", () => {
178
+ expect(keysToEntries({ s: '{"a":1}' })).toEqual([{ name: "s", rawValue: '"{\\"a\\":1}"' }])
179
+ })
180
+ })
181
+
182
+ describe("entriesToKeys", () => {
183
+ it("skips entries with blank names", () => {
184
+ const entries: KeyEntry[] = [
185
+ { name: "", rawValue: "x" },
186
+ { name: " ", rawValue: "y" },
187
+ { name: "k", rawValue: "v" },
188
+ ]
189
+ expect(entriesToKeys(entries)).toEqual({ k: "v" })
190
+ })
191
+
192
+ it("parses each raw value via parseKeyValue", () => {
193
+ const entries: KeyEntry[] = [
194
+ { name: "flag", rawValue: "true" },
195
+ { name: "obj", rawValue: '{"a":1}' },
196
+ { name: "id", rawValue: "7" },
197
+ ]
198
+ expect(entriesToKeys(entries)).toEqual({ flag: true, obj: { a: 1 }, id: "7" })
199
+ })
200
+
201
+ it("last write wins on duplicate names", () => {
202
+ const entries: KeyEntry[] = [
203
+ { name: "k", rawValue: "first" },
204
+ { name: "k", rawValue: "second" },
205
+ ]
206
+ expect(entriesToKeys(entries)).toEqual({ k: "second" })
207
+ })
208
+ })
209
+
210
+ describe("parseKeyValue <-> keysToEntries <-> entriesToKeys round-trip", () => {
211
+ // Values that survive a full edit cycle unchanged.
212
+ const stableCases: { label: string; keys: Record<string, unknown> }[] = [
213
+ { label: "plain string id", keys: { id: "abc" } },
214
+ { label: "numeric-looking string id", keys: { id: "1" } },
215
+ { label: "literal-string 'true'", keys: { flag: "true" } },
216
+ { label: "literal-string 'null'", keys: { x: "null" } },
217
+ { label: "real boolean", keys: { active: true } },
218
+ { label: "real null", keys: { nothing: null } },
219
+ { label: "object", keys: { scope: { processKey: "leasing" } } },
220
+ { label: "array", keys: { ids: [1, 2, 3] } },
221
+ { label: "string that looks like json", keys: { tpl: '{"a":1}' } },
222
+ ]
223
+
224
+ for (const { label, keys } of stableCases) {
225
+ it(`preserves ${label} through one edit cycle`, () => {
226
+ expect(entriesToKeys(keysToEntries(keys))).toEqual(keys)
227
+ })
228
+ }
229
+
230
+ // Every case (stable or lossy) is idempotent from the second cycle on, since
231
+ // the first cycle normalizes the value into its stable string/JSON form.
232
+ const allCases: { label: string; keys: Record<string, unknown> }[] = [
233
+ ...stableCases,
234
+ { label: "real top-level number (degrades to string)", keys: { count: 5 } },
235
+ { label: "mixed bag", keys: { id: "1", flag: "true", n: 2, o: { a: 1 } } },
236
+ ]
237
+
238
+ for (const { label, keys } of allCases) {
239
+ it(`is idempotent across a second cycle for ${label}`, () => {
240
+ const once = entriesToKeys(keysToEntries(keys))
241
+ const twice = entriesToKeys(keysToEntries(once))
242
+ expect(twice).toEqual(once)
243
+ })
244
+ }
245
+
246
+ it("documents that a bare top-level number degrades to a string by design", () => {
247
+ // parseKeyValue intentionally keeps bare numbers as strings (IDs are
248
+ // strings ~99% of the time), so a real number survives only as its text.
249
+ expect(entriesToKeys(keysToEntries({ count: 5 }))).toEqual({ count: "5" })
250
+ })
251
+ })
252
+
253
+ describe("formatLiveKeyValue", () => {
254
+ it("returns undefined for undefined and empty string", () => {
255
+ expect(formatLiveKeyValue(undefined)).toBeUndefined()
256
+ expect(formatLiveKeyValue("")).toBeUndefined()
257
+ })
258
+
259
+ it("prefixes plain strings with 'live: '", () => {
260
+ expect(formatLiveKeyValue("abc")).toBe("live: abc")
261
+ })
262
+
263
+ it("JSON-stringifies non-string values", () => {
264
+ expect(formatLiveKeyValue(42)).toBe("live: 42")
265
+ expect(formatLiveKeyValue({ a: 1 })).toBe('live: {"a":1}')
266
+ expect(formatLiveKeyValue(null)).toBe("live: null")
267
+ })
268
+
269
+ it("returns undefined when JSON.stringify throws (circular)", () => {
270
+ const circular: Record<string, unknown> = {}
271
+ circular.self = circular
272
+ expect(formatLiveKeyValue(circular)).toBeUndefined()
273
+ })
274
+ })
275
+
276
+ describe("valueTypeLabel", () => {
277
+ it("labels empty input as str", () => {
278
+ expect(valueTypeLabel("")).toBe("str")
279
+ })
280
+
281
+ it("labels null/array/object/primitives", () => {
282
+ expect(valueTypeLabel("null")).toBe("null")
283
+ expect(valueTypeLabel("[1,2]")).toBe("arr")
284
+ expect(valueTypeLabel('{"a":1}')).toBe("obj")
285
+ expect(valueTypeLabel("hello")).toBe("str")
286
+ expect(valueTypeLabel("true")).toBe("bool")
287
+ })
288
+
289
+ it("labels bare numbers as str because they stay strings", () => {
290
+ expect(valueTypeLabel("5")).toBe("str")
291
+ })
292
+
293
+ it("labels explicitly quoted JSON strings as str", () => {
294
+ expect(valueTypeLabel('"x"')).toBe("str")
295
+ })
296
+ })
297
+
298
+ describe("sizeToSpan", () => {
299
+ it("maps known sizes", () => {
300
+ expect(sizeToSpan("quarter")).toBe(3)
301
+ expect(sizeToSpan("third")).toBe(4)
302
+ expect(sizeToSpan("half")).toBe(6)
303
+ expect(sizeToSpan("full")).toBe(12)
304
+ expect(sizeToSpan("header")).toBe(12)
305
+ })
306
+
307
+ it("defaults unknown sizes to 12", () => {
308
+ expect(sizeToSpan("wat")).toBe(12)
309
+ expect(sizeToSpan("")).toBe(12)
310
+ })
311
+ })
312
+
313
+ describe("defaultStepIdFor", () => {
314
+ it("uses the part after the colon as the base id", () => {
315
+ expect(defaultStepIdFor("items-app:load", new Set())).toBe("load")
316
+ })
317
+
318
+ it("sanitizes ids without a colon", () => {
319
+ expect(defaultStepIdFor("load.step!", new Set())).toBe("load-step-")
320
+ })
321
+
322
+ it("suffixes -2, -3, … when the base is taken", () => {
323
+ expect(defaultStepIdFor("app:load", new Set(["load"]))).toBe("load-2")
324
+ expect(defaultStepIdFor("app:load", new Set(["load", "load-2"]))).toBe("load-3")
325
+ })
326
+ })
327
+
328
+ describe("toPipelineContext", () => {
329
+ it("inflates wire stepData into runtime steps with _step set", () => {
330
+ const wire: WireContext = {
331
+ keys: { a: 1 },
332
+ stepIds: ["s1"],
333
+ stepData: {
334
+ s1: { data: { hi: true }, keys: { k: 1 }, _app: "app", _dataType: "type" },
335
+ },
336
+ errors: [{ stepId: "s2", reason: "boom" }],
337
+ }
338
+ expect(toPipelineContext(wire)).toEqual({
339
+ steps: {
340
+ s1: { data: { hi: true }, keys: { k: 1 }, _app: "app", _dataType: "type", _step: "s1" },
341
+ },
342
+ keys: { a: 1 },
343
+ errors: [{ stepId: "s2", reason: "boom" }],
344
+ })
345
+ })
346
+
347
+ it("produces an empty steps map when there is no stepData", () => {
348
+ const wire: WireContext = { keys: {}, stepIds: [], stepData: {}, errors: [] }
349
+ expect(toPipelineContext(wire)).toEqual({ steps: {}, keys: {}, errors: [] })
350
+ })
351
+ })
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Pure, browser-safe model helpers for the visual LayoutBuilder.
3
+ *
4
+ * Everything here is free of React and `mcp-use`: plain functions over the
5
+ * builder's draft state and the key/step editor. Extracting them out of
6
+ * `layout-builder.tsx` makes the trickiest logic — the
7
+ * `parseKeyValue ↔ keysToEntries ↔ entriesToKeys` round-trip and
8
+ * `draftToLayout` — unit-testable in isolation.
9
+ */
10
+ import type { LayoutConfig, PipelineContext, RowDef } from "@miragon/mcp-toolkit-core"
11
+ import { normalizeLayout } from "@miragon/mcp-toolkit-core"
12
+
13
+ // -------------------------------------------------------------------------- //
14
+ // Draft types
15
+ // -------------------------------------------------------------------------- //
16
+
17
+ export interface DraftTab {
18
+ label: string
19
+ rows: RowDef[]
20
+ }
21
+
22
+ export type DraftLayout = { kind: "rows"; rows: RowDef[] } | { kind: "tabs"; tabs: DraftTab[] }
23
+
24
+ export interface KeyEntry {
25
+ name: string
26
+ rawValue: string
27
+ }
28
+
29
+ /**
30
+ * Wire shape of the `context` payload `get-builder-catalogue` returns.
31
+ * Carries `stepIds` plus keyed `stepData` rather than the runtime
32
+ * `PipelineContext` shape; `toPipelineContext` inflates it.
33
+ */
34
+ export interface WireContext {
35
+ keys: Record<string, unknown>
36
+ stepIds: string[]
37
+ stepData: Record<
38
+ string,
39
+ { data: unknown; keys: Record<string, unknown>; _app: string; _dataType: string }
40
+ >
41
+ errors: { stepId: string; reason: string }[]
42
+ }
43
+
44
+ // -------------------------------------------------------------------------- //
45
+ // Draft <-> layout conversions
46
+ // -------------------------------------------------------------------------- //
47
+
48
+ export function emptyRows(): RowDef[] {
49
+ return [{ row: [] }]
50
+ }
51
+
52
+ export function cloneRows(rows: RowDef[]): RowDef[] {
53
+ return rows.map((r) => ({ row: r.row.map((c) => ({ ...c })) }))
54
+ }
55
+
56
+ export function initDraft(layout: LayoutConfig | undefined): DraftLayout {
57
+ if (!layout) return { kind: "rows", rows: emptyRows() }
58
+ const normalized = normalizeLayout(layout)
59
+ if ("tabs" in normalized) {
60
+ if (normalized.tabs.length === 0) return { kind: "rows", rows: emptyRows() }
61
+ return {
62
+ kind: "tabs",
63
+ tabs: normalized.tabs.map((t) => ({ label: t.label, rows: cloneRows(t.rows) })),
64
+ }
65
+ }
66
+ return { kind: "rows", rows: cloneRows(normalized.rows) }
67
+ }
68
+
69
+ export function draftToLayout(draft: DraftLayout): LayoutConfig {
70
+ if (draft.kind === "tabs") {
71
+ return { tabs: draft.tabs.map((t) => ({ label: t.label, rows: cloneRows(t.rows) })) }
72
+ }
73
+ return { rows: cloneRows(draft.rows) }
74
+ }
75
+
76
+ // -------------------------------------------------------------------------- //
77
+ // Key value parsing
78
+ // -------------------------------------------------------------------------- //
79
+
80
+ /**
81
+ * Parses a value the user typed into a key field.
82
+ *
83
+ * Only treats the input as JSON when there's an *obvious* JSON marker
84
+ * (`{`, `[`, `"`) or it's a literal `true` / `false` / `null`. Bare
85
+ * numbers and bare words stay as strings, because IDs in this builder
86
+ * are strings ~99% of the time and `id = "1"` typed without quotes was
87
+ * silently turning into the number `1` and breaking widgets.
88
+ *
89
+ * Users who really want a primitive number / boolean / object can still
90
+ * type `[1,2]`, `{"id":1}`, `true` etc. with explicit JSON syntax.
91
+ */
92
+ export function parseKeyValue(raw: string): unknown {
93
+ const trimmed = raw.trim()
94
+ if (trimmed === "") return ""
95
+ const first = trimmed[0]
96
+ const looksLikeJson = first === "{" || first === "[" || first === '"'
97
+ const isLiteral = trimmed === "true" || trimmed === "false" || trimmed === "null"
98
+ if (!looksLikeJson && !isLiteral) return raw
99
+ try {
100
+ return JSON.parse(trimmed)
101
+ } catch {
102
+ return raw
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Inverse of `parseKeyValue`. Round-trip safe: any value whose plain
108
+ * string form would change type when re-parsed (e.g. the literal string
109
+ * `"true"`) is wrapped in JSON quotes so the next edit cycle preserves
110
+ * the original type.
111
+ */
112
+ export function keysToEntries(keys: Record<string, unknown> | undefined): KeyEntry[] {
113
+ if (!keys) return []
114
+ return Object.entries(keys).map(([name, value]) => {
115
+ if (typeof value === "string") {
116
+ const reparsed = parseKeyValue(value)
117
+ if (typeof reparsed !== "string" || reparsed !== value) {
118
+ return { name, rawValue: JSON.stringify(value) }
119
+ }
120
+ return { name, rawValue: value }
121
+ }
122
+ return { name, rawValue: JSON.stringify(value) }
123
+ })
124
+ }
125
+
126
+ export function entriesToKeys(entries: KeyEntry[]): Record<string, unknown> {
127
+ const out: Record<string, unknown> = {}
128
+ for (const entry of entries) {
129
+ if (!entry.name.trim()) continue
130
+ out[entry.name] = parseKeyValue(entry.rawValue)
131
+ }
132
+ return out
133
+ }
134
+
135
+ /**
136
+ * Compact preview of a value as it currently lives in the pipeline.
137
+ * Used as the placeholder when the editor's `rawValue` is empty so the
138
+ * user can see what the server is actually working with right now
139
+ * without the editor overwriting their (empty) input.
140
+ */
141
+ export function formatLiveKeyValue(value: unknown): string | undefined {
142
+ if (value === undefined) return undefined
143
+ if (value === "") return undefined
144
+ if (typeof value === "string") return `live: ${value}`
145
+ try {
146
+ return `live: ${JSON.stringify(value)}`
147
+ } catch {
148
+ return undefined
149
+ }
150
+ }
151
+
152
+ export function valueTypeLabel(raw: string): string {
153
+ if (raw === "") return "str"
154
+ const v = parseKeyValue(raw)
155
+ if (v === null) return "null"
156
+ if (Array.isArray(v)) return "arr"
157
+ if (typeof v === "object") return "obj"
158
+ switch (typeof v) {
159
+ case "string":
160
+ return "str"
161
+ case "number":
162
+ return "num"
163
+ case "boolean":
164
+ return "bool"
165
+ default:
166
+ return (typeof v as string).slice(0, 4)
167
+ }
168
+ }
169
+
170
+ // -------------------------------------------------------------------------- //
171
+ // Layout sizing & step ids
172
+ // -------------------------------------------------------------------------- //
173
+
174
+ export function sizeToSpan(size: string): number {
175
+ switch (size) {
176
+ case "quarter":
177
+ return 3
178
+ case "third":
179
+ return 4
180
+ case "half":
181
+ return 6
182
+ case "full":
183
+ case "header":
184
+ return 12
185
+ default:
186
+ return 12
187
+ }
188
+ }
189
+
190
+ export function defaultStepIdFor(stepId: string, takenIds: Set<string>): string {
191
+ const base = stepId.split(":")[1] || stepId.replace(/[^a-z0-9]/gi, "-")
192
+ if (!takenIds.has(base)) return base
193
+ let i = 2
194
+ while (takenIds.has(`${base}-${i}`)) i++
195
+ return `${base}-${i}`
196
+ }
197
+
198
+ // -------------------------------------------------------------------------- //
199
+ // Context inflation
200
+ // -------------------------------------------------------------------------- //
201
+
202
+ /**
203
+ * Inflates the wire-shape `context` (which carries `stepIds` and
204
+ * keyed `stepData`) into the runtime `PipelineContext` shape that
205
+ * `WidgetRenderer` expects.
206
+ */
207
+ export function toPipelineContext(ctx: WireContext): PipelineContext {
208
+ const steps: PipelineContext["steps"] = {}
209
+ for (const [id, result] of Object.entries(ctx.stepData)) {
210
+ steps[id] = { ...result, _step: id }
211
+ }
212
+ return { steps, keys: ctx.keys, errors: ctx.errors }
213
+ }