undercity 1.0.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 (382) hide show
  1. package/AGENTS.md +26 -0
  2. package/README.md +58 -0
  3. package/actions/AGENTS.md +41 -0
  4. package/actions/_shared/container.js +16 -0
  5. package/actions/auth/ask-login/action.json +15 -0
  6. package/actions/auth/ask-signup/action.json +14 -0
  7. package/actions/display/clear/action.json +18 -0
  8. package/actions/display/clear/action.test.js +32 -0
  9. package/actions/display/markdown/action.json +12 -0
  10. package/actions/display/markdown/action.test.js +32 -0
  11. package/actions/display/rawHtml/action.json +24 -0
  12. package/actions/display/rawHtml/action.test.js +32 -0
  13. package/actions/display/safeHtml/action.json +24 -0
  14. package/actions/display/safeHtml/action.test.js +32 -0
  15. package/actions/display/text/action.js +9 -0
  16. package/actions/display/text/action.json +12 -0
  17. package/actions/display/text/action.test.js +40 -0
  18. package/actions/display/value/action.json +24 -0
  19. package/actions/display/value/action.test.js +32 -0
  20. package/actions/dom/addClass/action.json +23 -0
  21. package/actions/dom/addClass/action.test.js +32 -0
  22. package/actions/dom/focus/action.json +17 -0
  23. package/actions/dom/focus/action.test.js +32 -0
  24. package/actions/dom/hide/action.json +18 -0
  25. package/actions/dom/hide/action.test.js +32 -0
  26. package/actions/dom/removeClass/action.json +22 -0
  27. package/actions/dom/removeClass/action.test.js +32 -0
  28. package/actions/dom/scroll/action.json +29 -0
  29. package/actions/dom/scroll/action.test.js +32 -0
  30. package/actions/dom/setAttr/action.json +29 -0
  31. package/actions/dom/setAttr/action.test.js +32 -0
  32. package/actions/dom/setHtml/action.json +22 -0
  33. package/actions/dom/setHtml/action.test.js +32 -0
  34. package/actions/dom/setStyle/action.json +29 -0
  35. package/actions/dom/setStyle/action.test.js +32 -0
  36. package/actions/dom/setText/action.json +24 -0
  37. package/actions/dom/setText/action.test.js +32 -0
  38. package/actions/dom/show/action.json +18 -0
  39. package/actions/dom/show/action.test.js +32 -0
  40. package/actions/dom/toggle/action.json +17 -0
  41. package/actions/dom/toggle/action.test.js +32 -0
  42. package/actions/dom/toggleClass/action.json +22 -0
  43. package/actions/dom/toggleClass/action.test.js +32 -0
  44. package/actions/event/emit/action.json +24 -0
  45. package/actions/event/emit/action.test.js +32 -0
  46. package/actions/event/on/action.json +23 -0
  47. package/actions/event/on/action.test.js +32 -0
  48. package/actions/event/waitFor/action.json +28 -0
  49. package/actions/event/waitFor/action.test.js +32 -0
  50. package/actions/forms/bindField/action.js +27 -0
  51. package/actions/forms/bindField/action.json +24 -0
  52. package/actions/forms/bindField/action.test.js +20 -0
  53. package/actions/forms/check/action.json +22 -0
  54. package/actions/forms/check/action.test.js +32 -0
  55. package/actions/forms/clearErrors/action.json +11 -0
  56. package/actions/forms/clearErrors/action.test.js +35 -0
  57. package/actions/forms/clearField/action.json +17 -0
  58. package/actions/forms/clearField/action.test.js +32 -0
  59. package/actions/forms/getField/action.json +24 -0
  60. package/actions/forms/getField/action.test.js +32 -0
  61. package/actions/forms/getRange/action.json +22 -0
  62. package/actions/forms/getRange/action.test.js +32 -0
  63. package/actions/forms/getSelect/action.json +22 -0
  64. package/actions/forms/getSelect/action.test.js +32 -0
  65. package/actions/forms/index.js +140 -0
  66. package/actions/forms/serialize/action.json +24 -0
  67. package/actions/forms/serialize/action.test.js +32 -0
  68. package/actions/forms/setCheck/action.json +23 -0
  69. package/actions/forms/setCheck/action.test.js +32 -0
  70. package/actions/forms/setError/action.json +22 -0
  71. package/actions/forms/setError/action.test.js +32 -0
  72. package/actions/forms/setField/action.js +14 -0
  73. package/actions/forms/setField/action.json +24 -0
  74. package/actions/forms/setField/action.test.js +32 -0
  75. package/actions/forms/submit/action.json +18 -0
  76. package/actions/forms/submit/action.test.js +32 -0
  77. package/actions/forms/validate/action.json +24 -0
  78. package/actions/forms/validate/action.test.js +32 -0
  79. package/actions/http/delete/action.json +22 -0
  80. package/actions/http/delete/action.test.js +32 -0
  81. package/actions/http/get/action.json +24 -0
  82. package/actions/http/get/action.test.js +32 -0
  83. package/actions/http/post/action.json +30 -0
  84. package/actions/http/post/action.test.js +32 -0
  85. package/actions/http/put/action.json +27 -0
  86. package/actions/http/put/action.test.js +32 -0
  87. package/actions/http/upload/action.json +35 -0
  88. package/actions/http/upload/action.test.js +32 -0
  89. package/actions/index.js +306 -0
  90. package/actions/index.json +5 -0
  91. package/actions/input/askChoice/action.json +29 -0
  92. package/actions/input/askChoice/action.test.js +32 -0
  93. package/actions/input/askConfirm/action.json +24 -0
  94. package/actions/input/askConfirm/action.test.js +32 -0
  95. package/actions/input/askDate/action.json +24 -0
  96. package/actions/input/askDate/action.test.js +32 -0
  97. package/actions/input/askEmail/action.json +24 -0
  98. package/actions/input/askEmail/action.test.js +32 -0
  99. package/actions/input/askNumber/action.json +35 -0
  100. package/actions/input/askNumber/action.test.js +32 -0
  101. package/actions/input/askPassword/action.json +24 -0
  102. package/actions/input/askPassword/action.test.js +32 -0
  103. package/actions/input/askText/action.json +36 -0
  104. package/actions/input/askText/action.test.js +32 -0
  105. package/actions/logic/delay/action.json +18 -0
  106. package/actions/logic/delay/action.test.js +32 -0
  107. package/actions/logic/if/action.json +28 -0
  108. package/actions/logic/if/action.test.js +32 -0
  109. package/actions/logic/log/action.json +18 -0
  110. package/actions/logic/log/action.test.js +32 -0
  111. package/actions/logic/random/action.json +36 -0
  112. package/actions/logic/random/action.test.js +32 -0
  113. package/actions/logic/transform/action.json +24 -0
  114. package/actions/logic/transform/action.test.js +32 -0
  115. package/actions/media/askAudioUpload/action.json +30 -0
  116. package/actions/media/askAudioUpload/action.test.js +32 -0
  117. package/actions/media/askFileUpload/action.json +36 -0
  118. package/actions/media/askFileUpload/action.test.js +32 -0
  119. package/actions/media/askImageUpload/action.json +37 -0
  120. package/actions/media/askImageUpload/action.test.js +32 -0
  121. package/actions/media/askVideoUpload/action.js +74 -0
  122. package/actions/media/askVideoUpload/action.json +44 -0
  123. package/actions/media/askVideoUpload/action.test.js +51 -0
  124. package/actions/media/captureWebcam/action.json +24 -0
  125. package/actions/media/captureWebcam/action.test.js +32 -0
  126. package/actions/nav/back/action.json +11 -0
  127. package/actions/nav/back/action.test.js +35 -0
  128. package/actions/nav/goto/action.json +18 -0
  129. package/actions/nav/goto/action.test.js +32 -0
  130. package/actions/nav/redirect/action.json +28 -0
  131. package/actions/nav/redirect/action.test.js +32 -0
  132. package/actions/nav/reload/action.json +11 -0
  133. package/actions/nav/reload/action.test.js +35 -0
  134. package/actions/nav/reset/action.json +11 -0
  135. package/actions/nav/reset/action.test.js +35 -0
  136. package/actions/render/alert/action.js +12 -0
  137. package/actions/render/alert/action.json +36 -0
  138. package/actions/render/alert/action.test.js +32 -0
  139. package/actions/render/button/action.js +15 -0
  140. package/actions/render/button/action.json +44 -0
  141. package/actions/render/button/action.test.js +37 -0
  142. package/actions/render/clear/action.js +7 -0
  143. package/actions/render/clear/action.json +11 -0
  144. package/actions/render/clear/action.test.js +35 -0
  145. package/actions/render/divider/action.js +8 -0
  146. package/actions/render/divider/action.json +11 -0
  147. package/actions/render/divider/action.test.js +35 -0
  148. package/actions/render/field/action.js +17 -0
  149. package/actions/render/field/action.json +59 -0
  150. package/actions/render/field/action.test.js +57 -0
  151. package/actions/render/link/action.js +20 -0
  152. package/actions/render/link/action.json +30 -0
  153. package/actions/render/link/action.test.js +32 -0
  154. package/actions/render/markdown/action.json +18 -0
  155. package/actions/render/markdown/action.test.js +32 -0
  156. package/actions/render/paragraph/action.js +9 -0
  157. package/actions/render/paragraph/action.json +32 -0
  158. package/actions/render/paragraph/action.test.js +32 -0
  159. package/actions/render/section/action.js +10 -0
  160. package/actions/render/section/action.json +18 -0
  161. package/actions/render/section/action.test.js +32 -0
  162. package/actions/render/subtitle/action.js +9 -0
  163. package/actions/render/subtitle/action.json +18 -0
  164. package/actions/render/subtitle/action.test.js +32 -0
  165. package/actions/render/title/action.js +9 -0
  166. package/actions/render/title/action.json +30 -0
  167. package/actions/render/title/action.test.js +44 -0
  168. package/actions/session/clear/action.json +11 -0
  169. package/actions/session/clear/action.test.js +35 -0
  170. package/actions/session/load/action.json +22 -0
  171. package/actions/session/load/action.test.js +32 -0
  172. package/actions/session/local/action.json +22 -0
  173. package/actions/session/local/action.test.js +32 -0
  174. package/actions/session/save/action.json +22 -0
  175. package/actions/session/save/action.test.js +32 -0
  176. package/actions/ui/accordion/action.json +23 -0
  177. package/actions/ui/accordion/action.test.js +32 -0
  178. package/actions/ui/badge/action.json +22 -0
  179. package/actions/ui/badge/action.test.js +32 -0
  180. package/actions/ui/collapse/action.json +23 -0
  181. package/actions/ui/collapse/action.test.js +32 -0
  182. package/actions/ui/hideModal/action.json +17 -0
  183. package/actions/ui/hideModal/action.test.js +32 -0
  184. package/actions/ui/loading/action.json +18 -0
  185. package/actions/ui/loading/action.test.js +32 -0
  186. package/actions/ui/modal/action.json +18 -0
  187. package/actions/ui/modal/action.test.js +32 -0
  188. package/actions/ui/progress/action.json +24 -0
  189. package/actions/ui/progress/action.test.js +32 -0
  190. package/actions/ui/toast/action.json +29 -0
  191. package/actions/ui/toast/action.test.js +32 -0
  192. package/actions/ui/tooltip/action.json +17 -0
  193. package/actions/ui/tooltip/action.test.js +32 -0
  194. package/actions/user/carry/action.json +25 -0
  195. package/actions/user/carry/action.test.js +32 -0
  196. package/actions/user/check/action.json +24 -0
  197. package/actions/user/check/action.test.js +32 -0
  198. package/actions/user/clear/action.json +11 -0
  199. package/actions/user/clear/action.test.js +35 -0
  200. package/actions/user/delete/action.json +17 -0
  201. package/actions/user/delete/action.test.js +32 -0
  202. package/actions/user/dump/action.json +11 -0
  203. package/actions/user/dump/action.test.js +35 -0
  204. package/actions/user/get/action.json +24 -0
  205. package/actions/user/get/action.test.js +32 -0
  206. package/actions/user/merge/action.json +18 -0
  207. package/actions/user/merge/action.test.js +32 -0
  208. package/actions/user/set/action.json +24 -0
  209. package/actions/user/set/action.test.js +32 -0
  210. package/generator/base/css/bootstrap.min.css +6 -0
  211. package/generator/base/icons/app-indicator.svg +4 -0
  212. package/generator/base/icons/backpack.svg +4 -0
  213. package/generator/base/icons/broadcast.svg +3 -0
  214. package/generator/base/icons/bullseye.svg +6 -0
  215. package/generator/base/icons/chat-dots.svg +4 -0
  216. package/generator/base/icons/check-circle.svg +4 -0
  217. package/generator/base/icons/clipboard-check.svg +5 -0
  218. package/generator/base/icons/clipboard.svg +4 -0
  219. package/generator/base/icons/copy.svg +3 -0
  220. package/generator/base/icons/cursor.svg +3 -0
  221. package/generator/base/icons/diamond.svg +3 -0
  222. package/generator/base/icons/exclamation-triangle.svg +4 -0
  223. package/generator/base/icons/film.svg +3 -0
  224. package/generator/base/icons/floppy.svg +4 -0
  225. package/generator/base/icons/gear-wide-connected.svg +3 -0
  226. package/generator/base/icons/gear.svg +4 -0
  227. package/generator/base/icons/globe.svg +3 -0
  228. package/generator/base/icons/image.svg +4 -0
  229. package/generator/base/icons/layout-text-window.svg +4 -0
  230. package/generator/base/icons/lightning-charge.svg +3 -0
  231. package/generator/base/icons/magic.svg +3 -0
  232. package/generator/base/icons/pencil-square.svg +4 -0
  233. package/generator/base/icons/record-circle.svg +4 -0
  234. package/generator/base/icons/robot.svg +4 -0
  235. package/generator/base/icons/shield-check.svg +4 -0
  236. package/generator/base/icons/shield-lock.svg +4 -0
  237. package/generator/base/icons/signpost.svg +3 -0
  238. package/generator/base/icons/stars.svg +3 -0
  239. package/generator/base/icons/type.svg +3 -0
  240. package/generator/base/js/bootstrap.bundle.min.js +7 -0
  241. package/package.json +14 -0
  242. package/packages/undercity-http-server/index.js +249 -0
  243. package/packages/undercity-http-server/package.json +10 -0
  244. package/packages/undercity-parser/index.js +323 -0
  245. package/packages/undercity-parser/lexer.js +128 -0
  246. package/packages/undercity-parser/package.json +11 -0
  247. package/plugins/forms.js +397 -0
  248. package/plugins/index.js +83 -0
  249. package/plugins/multipage.js +165 -0
  250. package/plugins/wizard.js +239 -0
  251. package/projects/asd/project.json +1031 -0
  252. package/projects/test-1/project.json +335 -0
  253. package/projects/test-a/project.json +456 -0
  254. package/public/icons/arrows-angle-expand.svg +3 -0
  255. package/public/icons/arrows-fullscreen.svg +3 -0
  256. package/public/icons/bezier2.svg +3 -0
  257. package/public/icons/bootstrap/app-indicator.svg +4 -0
  258. package/public/icons/bootstrap/arrow-clockwise.svg +4 -0
  259. package/public/icons/bootstrap/arrow-counterclockwise.svg +4 -0
  260. package/public/icons/bootstrap/arrow-left.svg +3 -0
  261. package/public/icons/bootstrap/arrows-angle-expand.svg +3 -0
  262. package/public/icons/bootstrap/arrows-fullscreen.svg +3 -0
  263. package/public/icons/bootstrap/backpack.svg +4 -0
  264. package/public/icons/bootstrap/bezier2.svg +3 -0
  265. package/public/icons/bootstrap/bookmark-check.svg +4 -0
  266. package/public/icons/bootstrap/bookmark-plus.svg +4 -0
  267. package/public/icons/bootstrap/box-arrow-right.svg +4 -0
  268. package/public/icons/bootstrap/box-arrow-up.svg +4 -0
  269. package/public/icons/bootstrap/broadcast.svg +3 -0
  270. package/public/icons/bootstrap/bullseye.svg +6 -0
  271. package/public/icons/bootstrap/chat-dots.svg +4 -0
  272. package/public/icons/bootstrap/check-circle.svg +4 -0
  273. package/public/icons/bootstrap/check2.svg +3 -0
  274. package/public/icons/bootstrap/clipboard-check.svg +5 -0
  275. package/public/icons/bootstrap/clipboard.svg +4 -0
  276. package/public/icons/bootstrap/clock-history.svg +5 -0
  277. package/public/icons/bootstrap/command.svg +3 -0
  278. package/public/icons/bootstrap/copy.svg +3 -0
  279. package/public/icons/bootstrap/cursor.svg +3 -0
  280. package/public/icons/bootstrap/diagram-3.svg +3 -0
  281. package/public/icons/bootstrap/diamond.svg +3 -0
  282. package/public/icons/bootstrap/exclamation-triangle.svg +4 -0
  283. package/public/icons/bootstrap/eye.svg +4 -0
  284. package/public/icons/bootstrap/file-earmark-plus.svg +4 -0
  285. package/public/icons/bootstrap/film.svg +3 -0
  286. package/public/icons/bootstrap/floppy.svg +4 -0
  287. package/public/icons/bootstrap/folder2-open.svg +3 -0
  288. package/public/icons/bootstrap/gear-wide-connected.svg +3 -0
  289. package/public/icons/bootstrap/gear.svg +4 -0
  290. package/public/icons/bootstrap/globe.svg +3 -0
  291. package/public/icons/bootstrap/grid-3x3-gap.svg +3 -0
  292. package/public/icons/bootstrap/house-door.svg +3 -0
  293. package/public/icons/bootstrap/image.svg +4 -0
  294. package/public/icons/bootstrap/layout-text-window.svg +4 -0
  295. package/public/icons/bootstrap/lightning-charge.svg +3 -0
  296. package/public/icons/bootstrap/magic.svg +3 -0
  297. package/public/icons/bootstrap/pencil-square.svg +4 -0
  298. package/public/icons/bootstrap/pencil.svg +3 -0
  299. package/public/icons/bootstrap/play.svg +3 -0
  300. package/public/icons/bootstrap/plus-circle.svg +4 -0
  301. package/public/icons/bootstrap/plus-lg.svg +3 -0
  302. package/public/icons/bootstrap/record-circle.svg +4 -0
  303. package/public/icons/bootstrap/robot.svg +4 -0
  304. package/public/icons/bootstrap/save.svg +3 -0
  305. package/public/icons/bootstrap/scissors.svg +3 -0
  306. package/public/icons/bootstrap/shield-check.svg +4 -0
  307. package/public/icons/bootstrap/shield-lock.svg +4 -0
  308. package/public/icons/bootstrap/signpost.svg +3 -0
  309. package/public/icons/bootstrap/stars.svg +3 -0
  310. package/public/icons/bootstrap/stop-circle.svg +4 -0
  311. package/public/icons/bootstrap/terminal.svg +4 -0
  312. package/public/icons/bootstrap/trash3.svg +3 -0
  313. package/public/icons/bootstrap/type.svg +3 -0
  314. package/public/icons/bootstrap/x-circle.svg +4 -0
  315. package/public/icons/bootstrap/x-lg.svg +3 -0
  316. package/public/icons/bootstrap/zoom-in.svg +5 -0
  317. package/public/icons/bootstrap/zoom-out.svg +5 -0
  318. package/public/icons/bullseye.svg +6 -0
  319. package/public/icons/check2.svg +3 -0
  320. package/public/icons/cursor.svg +3 -0
  321. package/public/icons/diamond.svg +3 -0
  322. package/public/icons/eye.svg +4 -0
  323. package/public/icons/file-earmark-plus.svg +4 -0
  324. package/public/icons/floppy.svg +4 -0
  325. package/public/icons/gear.svg +4 -0
  326. package/public/icons/lightning-charge.svg +3 -0
  327. package/public/icons/pencil.svg +3 -0
  328. package/public/icons/play.svg +3 -0
  329. package/public/icons/plus-circle.svg +4 -0
  330. package/public/icons/record-circle.svg +4 -0
  331. package/public/icons/robot.svg +4 -0
  332. package/public/icons/save.svg +3 -0
  333. package/public/icons/stop-circle.svg +4 -0
  334. package/public/icons/terminal.svg +4 -0
  335. package/public/icons/trash3.svg +3 -0
  336. package/public/icons/x-circle.svg +4 -0
  337. package/public/icons/zoom-in.svg +5 -0
  338. package/public/icons/zoom-out.svg +5 -0
  339. package/public/index.html +424 -0
  340. package/public/testbench.html +899 -0
  341. package/scripts/extract-actions.js +128 -0
  342. package/server.js +11 -0
  343. package/src/emitter.js +48 -0
  344. package/src/generator/css.js +135 -0
  345. package/src/generator/index.js +122 -0
  346. package/src/generator/md-renderer-src.js +77 -0
  347. package/src/generator/page.js +300 -0
  348. package/src/generator/runtime.js +1632 -0
  349. package/src/generator/templates.js +508 -0
  350. package/src/ide/action-library.js +856 -0
  351. package/src/ide/af-icons.js +127 -0
  352. package/src/ide/app.js +1375 -0
  353. package/src/ide/command-line/commands.js +242 -0
  354. package/src/ide/command-line/index.js +329 -0
  355. package/src/ide/command-line/parser.js +21 -0
  356. package/src/ide/css/ide.css +1501 -0
  357. package/src/ide/graph.js +282 -0
  358. package/src/ide/history.js +46 -0
  359. package/src/ide/map-builder.js +583 -0
  360. package/src/ide/project-api.js +39 -0
  361. package/src/ide/savant-chat.js +513 -0
  362. package/src/ide/savant.js +1287 -0
  363. package/src/ide/thing-library.js +89 -0
  364. package/src/ide/undercity-map.js +978 -0
  365. package/src/lib/icons.js +72 -0
  366. package/src/lib/scope.js +88 -0
  367. package/src/lib/signal.js +155 -0
  368. package/src/lib/state-machine.js +113 -0
  369. package/src/server/index.js +96 -0
  370. package/src/server/routes/actions.js +144 -0
  371. package/src/server/routes/ai.js +176 -0
  372. package/src/server/routes/generate.js +54 -0
  373. package/src/server/routes/projects.js +106 -0
  374. package/src/server/routes/reset.js +30 -0
  375. package/src/server/routes/submit.js +30 -0
  376. package/src/server/routes/templates.js +139 -0
  377. package/src/server/routes/things.js +33 -0
  378. package/templates/auth-flow.json +335 -0
  379. package/templates/blank.json +39 -0
  380. package/things/auth-server/thing.json +17 -0
  381. package/things/persona-live/thing.json +20 -0
  382. package/things/workflow/thing.json +15 -0
@@ -0,0 +1,583 @@
1
+ /**
2
+ * map-builder.js — SVG map builder for the Undercity IDE.
3
+ *
4
+ * Renders the flow graph as a zoomable/pannable SVG with:
5
+ * ● Room — circle, cyan border
6
+ * ◆ Diamond — rotated square, yellow border
7
+ * ◎ Terminal — double circle, green border
8
+ *
9
+ * Tools: select | room | diamond | terminal | connect | delete
10
+ *
11
+ * Events emitted (use .on()):
12
+ * nodeSelected(node)
13
+ * nodeDeselected()
14
+ * nodeDoubleClicked(node)
15
+ * edgeCreated(edge)
16
+ * contextMenu({ node, x, y })
17
+ */
18
+
19
+ import { Emitter, on as domOn, Disposable } from '/src/lib/signal.js';
20
+ import { Scope } from '/src/lib/scope.js';
21
+ import { Graph, NodeType } from '/src/ide/graph.js';
22
+
23
+ const SVG_NS = 'http://www.w3.org/2000/svg';
24
+ const GRID = 40;
25
+ const NODE_R = 18; // room/terminal radius
26
+ const DIA_H = 20; // diamond half-height
27
+ const ENTRY_BADGE_R = 5;
28
+
29
+ // ── Arrowhead marker ──────────────────────────────────────────────────────────
30
+ function makeArrowMarker(svg) {
31
+ const defs = el(SVG_NS, 'defs');
32
+ const marker = el(SVG_NS, 'marker');
33
+ attr(marker, { id: 'arrowhead', markerWidth: 8, markerHeight: 6,
34
+ refX: 6, refY: 3, orient: 'auto' });
35
+ const poly = el(SVG_NS, 'polygon');
36
+ attr(poly, { points: '0 0, 8 3, 0 6', fill: 'var(--sol-base01)' });
37
+ marker.appendChild(poly);
38
+ defs.appendChild(marker);
39
+ svg.prepend(defs);
40
+ }
41
+
42
+ // ── Small helpers ─────────────────────────────────────────────────────────────
43
+ function el(ns, tag) { return document.createElementNS(ns, tag); }
44
+ function attr(el, obj) { for (const [k, v] of Object.entries(obj)) el.setAttribute(k, v); }
45
+ function snap(v) { return Math.round(v / GRID) * GRID; }
46
+
47
+ // ── MapBuilder ────────────────────────────────────────────────────────────────
48
+ export class MapBuilder extends Emitter {
49
+ /** @type {Graph} */ graph;
50
+ /** @type {SVGSVGElement} */ svg;
51
+ /** @type {SVGGElement} */ layerGrid;
52
+ /** @type {SVGGElement} */ layerEdges;
53
+ /** @type {SVGGElement} */ layerNodes;
54
+ /** @type {SVGGElement} */ layerLabels;
55
+ /** @type {SVGGElement} */ layerTemp;
56
+
57
+ #scope = new Scope();
58
+ #tool = 'select';
59
+ #viewBox = { x: 0, y: 0, w: 1200, h: 700 };
60
+
61
+ // dragging
62
+ #dragNode = null;
63
+ #dragOffset = { x: 0, y: 0 };
64
+ #panning = false;
65
+ #panStart = { x: 0, y: 0, vx: 0, vy: 0 };
66
+
67
+ // connecting
68
+ #connecting = false;
69
+ #connFrom = null;
70
+ #tempLine = null;
71
+
72
+ // selection
73
+ #selected = null;
74
+
75
+ // DOM → graph id maps
76
+ #nodeEls = new Map(); // nodeId → { group, shape, label }
77
+ #edgeEls = new Map(); // edgeId → { line, label }
78
+
79
+ constructor(svgEl, graph) {
80
+ super();
81
+ this.svg = svgEl;
82
+ this.graph = graph;
83
+
84
+ this.#buildLayers();
85
+ makeArrowMarker(this.svg);
86
+ this.#applyViewBox();
87
+ this.#renderGrid();
88
+ this.#bindGraphEvents();
89
+ this.#bindSVGEvents();
90
+ }
91
+
92
+ // ── Layers ─────────────────────────────────────────────────────────────────
93
+ #buildLayers() {
94
+ for (const id of ['layer-grid','layer-edges','layer-nodes','layer-labels','layer-temp']) {
95
+ const g = el(SVG_NS, 'g');
96
+ g.id = id;
97
+ this.svg.appendChild(g);
98
+ }
99
+ this.layerGrid = this.svg.querySelector('#layer-grid');
100
+ this.layerEdges = this.svg.querySelector('#layer-edges');
101
+ this.layerNodes = this.svg.querySelector('#layer-nodes');
102
+ this.layerLabels = this.svg.querySelector('#layer-labels');
103
+ this.layerTemp = this.svg.querySelector('#layer-temp');
104
+ }
105
+
106
+ // ── Grid ──────────────────────────────────────────────────────────────────
107
+ #renderGrid() {
108
+ this.layerGrid.innerHTML = '';
109
+ const vb = this.#viewBox;
110
+ const pad = GRID * 3;
111
+ const x0 = Math.floor((vb.x - pad) / GRID) * GRID;
112
+ const y0 = Math.floor((vb.y - pad) / GRID) * GRID;
113
+ const x1 = Math.ceil((vb.x + vb.w + pad) / GRID) * GRID;
114
+ const y1 = Math.ceil((vb.y + vb.h + pad) / GRID) * GRID;
115
+
116
+ const bg = el(SVG_NS, 'rect');
117
+ attr(bg, { x: x0, y: y0, width: x1 - x0, height: y1 - y0, fill: 'var(--sol-base03)' });
118
+ this.layerGrid.appendChild(bg);
119
+
120
+ for (let x = x0; x <= x1; x += GRID) {
121
+ const major = x % (GRID * 5) === 0;
122
+ const line = el(SVG_NS, 'line');
123
+ attr(line, { x1: x, y1: y0, x2: x, y2: y1,
124
+ stroke: major ? 'var(--sol-base01)' : 'var(--sol-base02)',
125
+ 'stroke-width': major ? 0.8 : 0.4, opacity: 0.6 });
126
+ this.layerGrid.appendChild(line);
127
+ }
128
+ for (let y = y0; y <= y1; y += GRID) {
129
+ const major = y % (GRID * 5) === 0;
130
+ const line = el(SVG_NS, 'line');
131
+ attr(line, { x1: x0, y1: y, x2: x1, y2: y,
132
+ stroke: major ? 'var(--sol-base01)' : 'var(--sol-base02)',
133
+ 'stroke-width': major ? 0.8 : 0.4, opacity: 0.6 });
134
+ this.layerGrid.appendChild(line);
135
+ }
136
+ }
137
+
138
+ // ── ViewBox ───────────────────────────────────────────────────────────────
139
+ #applyViewBox() {
140
+ const vb = this.#viewBox;
141
+ this.svg.setAttribute('viewBox', `${vb.x} ${vb.y} ${vb.w} ${vb.h}`);
142
+ }
143
+
144
+ // ── Graph event wiring ────────────────────────────────────────────────────
145
+ #bindGraphEvents() {
146
+ const g = this.graph;
147
+ this.#scope.add(g.on('nodeAdded', n => this.#renderNode(n)));
148
+ this.#scope.add(g.on('nodeUpdated', n => this.#refreshNode(n)));
149
+ this.#scope.add(g.on('nodeRemoved', ({id})=> this.#removeNodeEl(id)));
150
+ this.#scope.add(g.on('edgeAdded', e => this.#renderEdge(e)));
151
+ this.#scope.add(g.on('edgeRemoved', ({id})=> this.#removeEdgeEl(id)));
152
+ this.#scope.add(g.on('entryChanged', () => this.#refreshAllEntryBadges()));
153
+ this.#scope.add(g.on('graphLoaded', () => this.#renderAll()));
154
+ }
155
+
156
+ // ── SVG event wiring ──────────────────────────────────────────────────────
157
+ #bindSVGEvents() {
158
+ const s = this.svg;
159
+ this.#scope.add(domOn(s, 'mousedown', e => this.#onMouseDown(e)));
160
+ this.#scope.add(domOn(s, 'mousemove', e => this.#onMouseMove(e)));
161
+ this.#scope.add(domOn(s, 'mouseup', e => this.#onMouseUp(e)));
162
+ this.#scope.add(domOn(s, 'wheel', e => this.#onWheel(e), { passive: false }));
163
+ this.#scope.add(domOn(s, 'contextmenu', e => this.#onContextMenu(e)));
164
+ this.#scope.add(domOn(s, 'dblclick', e => this.#onDblClick(e)));
165
+ }
166
+
167
+ // ── Coordinate helpers ────────────────────────────────────────────────────
168
+ svgPoint(clientX, clientY) {
169
+ const r = this.svg.getBoundingClientRect();
170
+ const vb = this.#viewBox;
171
+ return {
172
+ x: (clientX - r.left) / r.width * vb.w + vb.x,
173
+ y: (clientY - r.top) / r.height * vb.h + vb.y,
174
+ };
175
+ }
176
+
177
+ // ── Tool ──────────────────────────────────────────────────────────────────
178
+ setTool(tool) {
179
+ this.#tool = tool;
180
+ this.svg.className.baseVal = this.svg.className.baseVal
181
+ .replace(/mode-\S+/g, '').trim();
182
+ if (tool !== 'select') this.svg.classList.add(`mode-${tool}`);
183
+ this.#cancelConnect();
184
+ }
185
+
186
+ // ── Mouse events ──────────────────────────────────────────────────────────
187
+ #onMouseDown(e) {
188
+ if (e.button !== 0) return;
189
+ const pos = this.svgPoint(e.clientX, e.clientY);
190
+ const nodeId = e.target.closest('[data-nid]')?.dataset.nid;
191
+ const node = nodeId ? this.graph.nodes.get(nodeId) : null;
192
+
193
+ if (this.#tool === 'connect') {
194
+ if (node) { this.#startConnect(node, pos); }
195
+ return;
196
+ }
197
+
198
+ if (this.#tool === 'select') {
199
+ if (node) {
200
+ this.#selectNode(node);
201
+ this.#dragNode = node;
202
+ this.#dragOffset = { x: pos.x - node.x.value, y: pos.y - node.y.value };
203
+ e.preventDefault();
204
+ } else {
205
+ this.#deselectNode();
206
+ this.#panning = true;
207
+ this.#panStart = { x: e.clientX, y: e.clientY, vx: this.#viewBox.x, vy: this.#viewBox.y };
208
+ this.svg.classList.add('dragging');
209
+ e.preventDefault();
210
+ }
211
+ return;
212
+ }
213
+
214
+ // Creation tools
215
+ if (['room','diamond','terminal'].includes(this.#tool)) {
216
+ const sp = { x: snap(pos.x), y: snap(pos.y) };
217
+ this.graph.addNode({ type: this.#tool, x: sp.x, y: sp.y });
218
+ }
219
+
220
+ if (this.#tool === 'delete' && node) {
221
+ this.graph.removeNode(nodeId);
222
+ }
223
+ }
224
+
225
+ #onMouseMove(e) {
226
+ const pos = this.svgPoint(e.clientX, e.clientY);
227
+
228
+ if (this.#dragNode) {
229
+ const nx = snap(pos.x - this.#dragOffset.x);
230
+ const ny = snap(pos.y - this.#dragOffset.y);
231
+ this.#dragNode.x.value = nx;
232
+ this.#dragNode.y.value = ny;
233
+ this.#refreshEdgesForNode(this.#dragNode.id);
234
+ return;
235
+ }
236
+
237
+ if (this.#panning) {
238
+ const r = this.svg.getBoundingClientRect();
239
+ const dx = (e.clientX - this.#panStart.x) / r.width * this.#viewBox.w;
240
+ const dy = (e.clientY - this.#panStart.y) / r.height * this.#viewBox.h;
241
+ this.#viewBox.x = this.#panStart.vx - dx;
242
+ this.#viewBox.y = this.#panStart.vy - dy;
243
+ this.#applyViewBox();
244
+ this.#renderGrid();
245
+ return;
246
+ }
247
+
248
+ if (this.#connecting && this.#tempLine) {
249
+ attr(this.#tempLine, { x2: pos.x, y2: pos.y });
250
+ }
251
+ }
252
+
253
+ #onMouseUp(e) {
254
+ if (this.#dragNode) {
255
+ this.#dragNode = null;
256
+ return;
257
+ }
258
+
259
+ if (this.#panning) {
260
+ this.#panning = false;
261
+ this.svg.classList.remove('dragging');
262
+ return;
263
+ }
264
+
265
+ if (this.#connecting) {
266
+ const nodeId = e.target.closest('[data-nid]')?.dataset.nid;
267
+ if (nodeId && nodeId !== this.#connFrom.id) {
268
+ const edge = this.graph.addEdge(this.#connFrom.id, nodeId);
269
+ this.emit('edgeCreated', edge);
270
+ }
271
+ this.#cancelConnect();
272
+ }
273
+ }
274
+
275
+ #onWheel(e) {
276
+ e.preventDefault();
277
+ const pos = this.svgPoint(e.clientX, e.clientY);
278
+ const f = e.deltaY > 0 ? 1.12 : 0.88;
279
+ const vb = this.#viewBox;
280
+ vb.w *= f;
281
+ vb.h *= f;
282
+ vb.x += (pos.x - vb.x) * (1 - f);
283
+ vb.y += (pos.y - vb.y) * (1 - f);
284
+ this.#applyViewBox();
285
+ this.#renderGrid();
286
+ }
287
+
288
+ #onContextMenu(e) {
289
+ e.preventDefault();
290
+ const nodeId = e.target.closest('[data-nid]')?.dataset.nid;
291
+ if (!nodeId) return;
292
+ const node = this.graph.nodes.get(nodeId);
293
+ if (!node) return;
294
+ this.emit('contextMenu', { node, x: e.clientX, y: e.clientY });
295
+ }
296
+
297
+ #onDblClick(e) {
298
+ const nodeId = e.target.closest('[data-nid]')?.dataset.nid;
299
+ if (!nodeId) return;
300
+ const node = this.graph.nodes.get(nodeId);
301
+ if (node) this.emit('nodeDoubleClicked', node);
302
+ }
303
+
304
+ // ── Connection helpers ────────────────────────────────────────────────────
305
+ #startConnect(node, pos) {
306
+ this.#connecting = true;
307
+ this.#connFrom = node;
308
+ this.#tempLine = el(SVG_NS, 'line');
309
+ attr(this.#tempLine, { class: 'temp-edge', x1: node.x.value, y1: node.y.value, x2: pos.x, y2: pos.y });
310
+ this.layerTemp.appendChild(this.#tempLine);
311
+ }
312
+
313
+ #cancelConnect() {
314
+ this.#connecting = false;
315
+ this.#connFrom = null;
316
+ this.#tempLine?.remove();
317
+ this.#tempLine = null;
318
+ }
319
+
320
+ // ── Selection ─────────────────────────────────────────────────────────────
321
+ #selectNode(node) {
322
+ if (this.#selected?.id === node.id) return;
323
+ this.#deselectNode();
324
+ this.#selected = node;
325
+ const els = this.#nodeEls.get(node.id);
326
+ els?.group.classList.add('selected');
327
+ this.emit('nodeSelected', node);
328
+ }
329
+
330
+ #deselectNode() {
331
+ if (!this.#selected) return;
332
+ const els = this.#nodeEls.get(this.#selected.id);
333
+ els?.group.classList.remove('selected');
334
+ this.#selected = null;
335
+ this.emit('nodeDeselected');
336
+ }
337
+
338
+ selectNode(id) {
339
+ const node = this.graph.nodes.get(id);
340
+ if (node) this.#selectNode(node);
341
+ }
342
+
343
+ deselect() { this.#deselectNode(); }
344
+
345
+ get selectedNode() { return this.#selected; }
346
+
347
+ // ── Render: node ─────────────────────────────────────────────────────────
348
+ #renderNode(node) {
349
+ // Group
350
+ const group = el(SVG_NS, 'g');
351
+ group.classList.add('node-group', `node-${node.type}`);
352
+ attr(group, { 'data-nid': node.id, cursor: 'pointer' });
353
+
354
+ // Shape
355
+ const shape = this.#makeShape(node);
356
+ group.appendChild(shape);
357
+
358
+ // Entry badge
359
+ if (node.meta.isEntry) {
360
+ const badge = this.#makeEntryBadge(node.x.value, node.y.value);
361
+ badge.classList.add('entry-badge');
362
+ group.appendChild(badge);
363
+ }
364
+
365
+ this.layerNodes.appendChild(group);
366
+
367
+ // Label (separate layer so it sits above edges)
368
+ const label = el(SVG_NS, 'text');
369
+ label.classList.add('node-label');
370
+ attr(label, { x: node.x.value, y: node.y.value + (node.type === NodeType.TERMINAL ? 0 : 0), 'data-nid': node.id, 'pointer-events': 'none' });
371
+ label.textContent = node.label.value;
372
+ this.layerLabels.appendChild(label);
373
+
374
+ // Subscribe to signal changes
375
+ const scope = this.#scope.scope(`node-${node.id}`);
376
+ scope.add(node.x.subscribe(nx => {
377
+ this.#updateShapePos(node, shape, nx, node.y.value);
378
+ attr(label, { x: nx });
379
+ this.#refreshEdgesForNode(node.id);
380
+ }, false));
381
+ scope.add(node.y.subscribe(ny => {
382
+ this.#updateShapePos(node, shape, node.x.value, ny);
383
+ attr(label, { y: ny });
384
+ this.#refreshEdgesForNode(node.id);
385
+ }, false));
386
+ scope.add(node.label.subscribe(lbl => { label.textContent = lbl; }, false));
387
+
388
+ this.#nodeEls.set(node.id, { group, shape, label });
389
+ }
390
+
391
+ #makeShape(node) {
392
+ const x = node.x.value, y = node.y.value;
393
+ switch (node.type) {
394
+ case NodeType.ROOM: {
395
+ const c = el(SVG_NS, 'circle');
396
+ attr(c, { cx: x, cy: y, r: NODE_R, class: 'body' });
397
+ return c;
398
+ }
399
+ case NodeType.DIAMOND: {
400
+ const p = el(SVG_NS, 'polygon');
401
+ p.classList.add('body');
402
+ this.#setDiamondPoints(p, x, y);
403
+ return p;
404
+ }
405
+ case NodeType.TERMINAL: {
406
+ const g = el(SVG_NS, 'g');
407
+ const outer = el(SVG_NS, 'circle');
408
+ attr(outer, { cx: x, cy: y, r: NODE_R, class: 'body' });
409
+ const inner = el(SVG_NS, 'circle');
410
+ attr(inner, { cx: x, cy: y, r: NODE_R - 5, class: 'inner' });
411
+ g.appendChild(outer);
412
+ g.appendChild(inner);
413
+ return g;
414
+ }
415
+ }
416
+ }
417
+
418
+ #setDiamondPoints(poly, x, y) {
419
+ const w = DIA_H * 1.6, h = DIA_H;
420
+ attr(poly, { points: `${x},${y-h} ${x+w},${y} ${x},${y+h} ${x-w},${y}` });
421
+ }
422
+
423
+ #updateShapePos(node, shape, x, y) {
424
+ switch (node.type) {
425
+ case NodeType.ROOM:
426
+ attr(shape, { cx: x, cy: y });
427
+ break;
428
+ case NodeType.DIAMOND:
429
+ this.#setDiamondPoints(shape, x, y);
430
+ break;
431
+ case NodeType.TERMINAL:
432
+ attr(shape.children[0], { cx: x, cy: y });
433
+ attr(shape.children[1], { cx: x, cy: y });
434
+ break;
435
+ }
436
+ // Entry badge
437
+ const badge = shape.closest('.node-group')?.querySelector('.entry-badge');
438
+ if (badge) attr(badge, { cx: x + NODE_R - 4, cy: y - NODE_R + 4 });
439
+ }
440
+
441
+ #makeEntryBadge(x, y) {
442
+ const c = el(SVG_NS, 'circle');
443
+ attr(c, { cx: x + NODE_R - 4, cy: y - NODE_R + 4, r: ENTRY_BADGE_R,
444
+ fill: 'var(--sol-violet)', stroke: 'none' });
445
+ return c;
446
+ }
447
+
448
+ #refreshNode(node) {
449
+ // Re-render from scratch (label/type changes)
450
+ this.#removeNodeEl(node.id);
451
+ this.#renderNode(node);
452
+ this.#refreshEdgesForNode(node.id);
453
+ }
454
+
455
+ #removeNodeEl(id) {
456
+ const els = this.#nodeEls.get(id);
457
+ if (!els) return;
458
+ els.group.remove();
459
+ els.label.remove();
460
+ this.#nodeEls.delete(id);
461
+ this.#scope.scope(`node-${id}`).dispose();
462
+ if (this.#selected?.id === id) {
463
+ this.#selected = null;
464
+ this.emit('nodeDeselected');
465
+ }
466
+ }
467
+
468
+ #refreshAllEntryBadges() {
469
+ for (const node of this.graph.nodes.values()) {
470
+ const els = this.#nodeEls.get(node.id);
471
+ if (!els) continue;
472
+ els.group.querySelector('.entry-badge')?.remove();
473
+ if (node.meta.isEntry) {
474
+ const badge = this.#makeEntryBadge(node.x.value, node.y.value);
475
+ badge.classList.add('entry-badge');
476
+ els.group.appendChild(badge);
477
+ }
478
+ }
479
+ }
480
+
481
+ // ── Render: edge ─────────────────────────────────────────────────────────
482
+ #renderEdge(edge) {
483
+ const from = this.graph.nodes.get(edge.fromId);
484
+ const to = this.graph.nodes.get(edge.toId);
485
+ if (!from || !to) return;
486
+
487
+ const line = el(SVG_NS, 'line');
488
+ line.classList.add('edge-line');
489
+ attr(line, {
490
+ 'data-eid': edge.id,
491
+ x1: from.x.value,
492
+ y1: from.y.value,
493
+ x2: to.x.value,
494
+ y2: to.y.value,
495
+ 'marker-end': 'url(#arrowhead)',
496
+ 'stroke-width': 2,
497
+ });
498
+
499
+ // Shorten endpoints so arrow doesn't overlap node circles
500
+ this.#shortenEdge(line, from, to);
501
+
502
+ const midX = (from.x.value + to.x.value) / 2;
503
+ const midY = (from.y.value + to.y.value) / 2;
504
+
505
+ const label = el(SVG_NS, 'text');
506
+ label.classList.add('edge-label');
507
+ attr(label, { x: midX, y: midY - 7 });
508
+ label.textContent = edge.label.value;
509
+
510
+ this.layerEdges.appendChild(line);
511
+ this.layerLabels.appendChild(label);
512
+
513
+ const scope = this.#scope.scope(`edge-${edge.id}`);
514
+ scope.add(edge.label.subscribe(lbl => { label.textContent = lbl; }, false));
515
+
516
+ this.#edgeEls.set(edge.id, { line, label });
517
+ }
518
+
519
+ #shortenEdge(line, from, to) {
520
+ const dx = to.x.value - from.x.value;
521
+ const dy = to.y.value - from.y.value;
522
+ const d = Math.hypot(dx, dy) || 1;
523
+ const r1 = from.type === NodeType.DIAMOND ? DIA_H * 1.6 : NODE_R;
524
+ const r2 = to.type === NodeType.DIAMOND ? DIA_H * 1.6 : NODE_R + 4;
525
+ attr(line, {
526
+ x1: from.x.value + (dx / d) * r1,
527
+ y1: from.y.value + (dy / d) * r1,
528
+ x2: to.x.value - (dx / d) * r2,
529
+ y2: to.y.value - (dy / d) * r2,
530
+ });
531
+ }
532
+
533
+ #refreshEdgesForNode(nodeId) {
534
+ for (const [eid, edge] of this.graph.edges) {
535
+ if (edge.fromId !== nodeId && edge.toId !== nodeId) continue;
536
+ const from = this.graph.nodes.get(edge.fromId);
537
+ const to = this.graph.nodes.get(edge.toId);
538
+ const els = this.#edgeEls.get(eid);
539
+ if (!from || !to || !els) continue;
540
+ this.#shortenEdge(els.line, from, to);
541
+ const midX = (from.x.value + to.x.value) / 2;
542
+ const midY = (from.y.value + to.y.value) / 2;
543
+ attr(els.label, { x: midX, y: midY - 7 });
544
+ }
545
+ }
546
+
547
+ #removeEdgeEl(id) {
548
+ const els = this.#edgeEls.get(id);
549
+ if (!els) return;
550
+ els.line.remove();
551
+ els.label.remove();
552
+ this.#edgeEls.delete(id);
553
+ this.#scope.scope(`edge-${id}`).dispose();
554
+ }
555
+
556
+ // ── Render all (after graph load) ─────────────────────────────────────────
557
+ #renderAll() {
558
+ this.layerNodes.innerHTML = '';
559
+ this.layerEdges.innerHTML = '';
560
+ this.layerLabels.innerHTML = '';
561
+ this.#nodeEls.clear();
562
+ this.#edgeEls.clear();
563
+
564
+ for (const node of this.graph.nodes.values()) this.#renderNode(node);
565
+ for (const edge of this.graph.edges.values()) this.#renderEdge(edge);
566
+ }
567
+
568
+ // ── Center view on graph ──────────────────────────────────────────────────
569
+ fitView() {
570
+ if (this.graph.nodes.size === 0) return;
571
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
572
+ for (const n of this.graph.nodes.values()) {
573
+ minX = Math.min(minX, n.x.value); minY = Math.min(minY, n.y.value);
574
+ maxX = Math.max(maxX, n.x.value); maxY = Math.max(maxY, n.y.value);
575
+ }
576
+ const pad = 80;
577
+ this.#viewBox = { x: minX - pad, y: minY - pad, w: maxX - minX + pad * 2, h: maxY - minY + pad * 2 };
578
+ this.#applyViewBox();
579
+ this.#renderGrid();
580
+ }
581
+
582
+ dispose() { this.#scope.dispose(); }
583
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * project-api.js — REST client for the Undercity server.
3
+ * All calls return the parsed JSON body or throw on failure.
4
+ */
5
+
6
+ const BASE = '/api';
7
+
8
+ async function req(method, path, body) {
9
+ const opts = { method, headers: {} };
10
+ if (body !== undefined) {
11
+ opts.headers['Content-Type'] = 'application/json';
12
+ opts.body = JSON.stringify(body);
13
+ }
14
+ const r = await fetch(BASE + path, opts);
15
+ if (!r.ok) {
16
+ const msg = await r.text().catch(() => r.statusText);
17
+ throw new Error(`[API] ${method} ${path} → ${r.status}: ${msg}`);
18
+ }
19
+ return r.json();
20
+ }
21
+
22
+ export const API = {
23
+ listProjects: () => req('GET', '/projects'),
24
+ getProject: id => req('GET', `/projects/${id}`),
25
+ createProject: data => req('POST', '/projects', data),
26
+ saveProject: (id, d) => req('PUT', `/projects/${id}`, d),
27
+ deleteProject: id => req('DELETE', `/projects/${id}`),
28
+ generateProject: id => req('POST', `/projects/${id}/generate`),
29
+
30
+ /** Ask the local AI (localhost:8191) to generate an action definition. */
31
+ generateAction: prompt => req('POST', '/ai/generate-action', { prompt }),
32
+
33
+ /** Template gallery */
34
+ listTemplates: () => req('GET', '/templates'),
35
+ getTemplate: id => req('GET', `/templates/${id}`),
36
+ saveTemplate: data => req('POST', '/templates', data),
37
+ updateTemplate: (id, data) => req('PUT', `/templates/${id}`, data),
38
+ deleteTemplate: id => req('DELETE', `/templates/${id}`),
39
+ };