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,1287 @@
1
+ /**
2
+ * savant.js — Bottom-pane Savant UI.
3
+ *
4
+ * Layout: [Event Tabs] | Categories | Actions | Workflow
5
+ *
6
+ * Workflow shows the step list for the selected node's current event.
7
+ * Clicking an action card appends it to the workflow.
8
+ * Steps support inline param editing and drag-to-reorder.
9
+ *
10
+ * ACTION_LIBRARY starts empty. Categories are registered exclusively via
11
+ * App.use(plugin) → app.registerActions(catId, def) → savant.registerCategory().
12
+ * If actions/index.js has nothing registered, no categories appear.
13
+ *
14
+ * The AI section at the bottom of categories lets the user describe
15
+ * a new action in plain language — the server calls localhost:8191 and
16
+ * returns a definition placed in the correct category by ID prefix,
17
+ * marked with a ✦ badge so the user knows it is AI-generated.
18
+ */
19
+
20
+ import { Signal, Emitter } from '/src/lib/signal.js';
21
+ import { Scope } from '/src/lib/scope.js';
22
+ import { SavantChat } from '/src/ide/savant-chat.js';
23
+
24
+ function escH(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
25
+ import { renderAfIcon } from '/src/lib/icons.js';
26
+
27
+ /** Copy MCP JSON commands to clipboard and show a transient toast. */
28
+ function _copyMcpJson(cmds) {
29
+ const json = JSON.stringify(cmds, null, 2);
30
+ navigator.clipboard.writeText(json).then(() => {
31
+ const t = document.createElement('div');
32
+ t.textContent = `Copied ${cmds.length} command${cmds.length !== 1 ? 's' : ''} to clipboard`;
33
+ t.style.cssText = 'position:fixed;bottom:16px;left:50%;transform:translateX(-50%);background:var(--sol-cyan,#2aa198);color:#002b36;padding:6px 16px;border-radius:6px;font-size:12px;z-index:9999;pointer-events:none';
34
+ document.body.appendChild(t);
35
+ setTimeout(() => t.remove(), 2200);
36
+ }).catch(() => {
37
+ // Fallback: open in a modal-style textarea
38
+ const win = window.open('', '_blank', 'width=640,height=480');
39
+ if (win) { win.document.write(`<pre style="font:13px monospace;padding:16px">${json.replace(/</g,'&lt;')}</pre>`); }
40
+ });
41
+ }
42
+ import { THING_LIBRARY, getThingEvents } from '/src/ide/thing-library.js';
43
+ import { API } from '/src/ide/project-api.js';
44
+
45
+ // ── Action Library (populated exclusively via registerCategory) ───────────────
46
+ // No static import from action-library.js. All categories arrive via App.use().
47
+ const ACTION_LIBRARY = {};
48
+
49
+ function findAction(actionId) {
50
+ for (const cat of Object.values(ACTION_LIBRARY)) {
51
+ if (cat.actions?.[actionId]) return cat.actions[actionId];
52
+ }
53
+ return null;
54
+ }
55
+
56
+ // ── Savant ───────────────────────────────────────────────────────────────────
57
+ export class Savant extends Emitter {
58
+ #scope = new Scope();
59
+ #nodeScope = new Scope(); // reset when node changes
60
+ #node = null;
61
+ #thingCtx = null; // { thingDef, parentNode } — set when editing a Thing
62
+ #event = 'onEnter';
63
+ #category = null;
64
+ #customActions = {}; // id → def (AI-generated or project-level)
65
+ #chat = null;
66
+ _projectId = '';
67
+ // Step UI mode stored in memory, never persisted to project.json
68
+ // Key format: "${nodeId}:${event}:${stepIndex}"
69
+ #stepModes = new Map();
70
+ #collapsedSteps = new Set();
71
+
72
+ // DOM refs
73
+ #catPane; #actPane; #actList; #actPreview; #wfPane; #wfTitle; #wfSteps;
74
+ #eventTabs; #breadcrumb;
75
+ #aiInput; #aiBtn;
76
+ // Currently previewed action
77
+ #previewedAction = null; // { actionId, def }
78
+
79
+ constructor(containerEl, { customActions = {} } = {}) {
80
+ super();
81
+ this.#catPane = containerEl.querySelector('#cat-pane');
82
+ this.#actPane = containerEl.querySelector('#act-pane');
83
+ this.#actList = containerEl.querySelector('#act-list');
84
+ this.#actPreview = containerEl.querySelector('#act-preview');
85
+ this.#wfPane = containerEl.querySelector('#wf-pane');
86
+ this.#wfTitle = containerEl.querySelector('#wf-title');
87
+ this.#wfSteps = containerEl.querySelector('#wf-steps');
88
+ this.#eventTabs = containerEl.querySelector('#event-tabs');
89
+ this.#breadcrumb = containerEl.querySelector('#savant-breadcrumb');
90
+ this.#customActions = customActions;
91
+
92
+ // Wire workflow help toggle
93
+ const wfHelpBtn = document.getElementById('wf-help-btn');
94
+ const wfHelpPanel = document.getElementById('wf-help-panel');
95
+ wfHelpBtn?.addEventListener('click', () => {
96
+ const isHidden = wfHelpPanel.hidden;
97
+ wfHelpPanel.hidden = !isHidden;
98
+ wfHelpBtn.setAttribute('aria-expanded', String(isHidden));
99
+ wfHelpBtn.classList.toggle('active', isHidden);
100
+ });
101
+
102
+ // Wire workspace export button — exports current node's workflow as MCP addStep JSON
103
+ document.getElementById('wf-workspace-btn')?.addEventListener('click', () => {
104
+ if (!this.#node) { return; }
105
+ const nodeName = this.#node.label?.value ?? this.#node.label ?? 'Room';
106
+ const payload = this.#node.payload?.peek() ?? {};
107
+ const eventKeys = Object.keys(payload).filter(k => Array.isArray(payload[k]) && payload[k].length);
108
+ if (!eventKeys.length) { return; }
109
+ const cmds = eventKeys.flatMap(event =>
110
+ payload[event].map(step => ({ cmd: 'addStep', node: nodeName, event, step }))
111
+ );
112
+ _copyMcpJson(cmds);
113
+ });
114
+
115
+ // Wire preview "Add" button
116
+ containerEl.querySelector('#act-preview-add')?.addEventListener('click', () => {
117
+ if (this.#previewedAction) {
118
+ this.#addStep(this.#previewedAction.actionId, this.#previewedAction.def);
119
+ }
120
+ });
121
+
122
+ this.#buildEventTabs();
123
+ this.#buildCategories();
124
+ this.#selectCategory(Object.keys(ACTION_LIBRARY)[0]);
125
+ this.#renderWorkflow();
126
+
127
+ const chatEl = containerEl.querySelector('#chat-pane');
128
+ if (chatEl) {
129
+ this.#chat = new SavantChat(chatEl);
130
+ }
131
+ }
132
+
133
+ // ── Public API ─────────────────────────────────────────────────────────────
134
+ setNode(node) {
135
+ this.#nodeScope.dispose();
136
+ this.#thingCtx = null;
137
+ this.#node = node;
138
+ this.#event = 'onEnter';
139
+ this.#updateBreadcrumb();
140
+ if (node) {
141
+ this.#nodeScope.add(
142
+ node.label.subscribe(() => this.#updateBreadcrumb(), false)
143
+ );
144
+ this.#nodeScope.add(
145
+ node.payload.subscribe(() => {
146
+ this.#buildEventTabs();
147
+ this.#renderWorkflow();
148
+ }, false)
149
+ );
150
+ }
151
+ this.#buildEventTabs();
152
+ this.#renderWorkflow();
153
+ this.#updateChatContext();
154
+ }
155
+
156
+ setEvent(event) {
157
+ this.#event = event;
158
+ this.#updateEventTabsUI();
159
+ this.#renderWorkflow();
160
+ this.#updateChatContext();
161
+ }
162
+
163
+ /**
164
+ * Switch the Savant into Thing-editing mode.
165
+ * thingDef = { id, type, config, events } (plain object from node.things)
166
+ * parentNode = the GraphNode that owns this thing
167
+ */
168
+ setThing(thingDef, parentNode) {
169
+ this.#thingCtx = { thingDef, parentNode };
170
+ this.#nodeScope.dispose();
171
+ this.#node = this.#makeThingProxy(thingDef, parentNode);
172
+ this.#event = 'onEnter';
173
+ this.#updateBreadcrumb();
174
+ // Push: re-render whenever the thing's payload signal changes
175
+ this.#nodeScope.add(
176
+ this.#node.payload.subscribe(() => {
177
+ this.#buildEventTabs();
178
+ this.#renderWorkflow();
179
+ }, false)
180
+ );
181
+ this.#buildEventTabs();
182
+ this.#renderWorkflow();
183
+ this.#updateChatContext();
184
+ }
185
+
186
+ #updateBreadcrumb() {
187
+ if (!this.#breadcrumb) return;
188
+ if (!this.#node) {
189
+ this.#breadcrumb.innerHTML = '<span class="bc-item bc-idle">No node selected</span>';
190
+ return;
191
+ }
192
+ if (this.#node._isThing && this.#thingCtx) {
193
+ const { thingDef, parentNode } = this.#thingCtx;
194
+ const roomLabel = parentNode.label?.value ?? parentNode.label ?? 'Room';
195
+ const thingLabel = THING_LIBRARY[thingDef.type]?.label ?? thingDef.type;
196
+ this.#breadcrumb.innerHTML =
197
+ `<span class="bc-item bc-room">${escH(roomLabel)}</span>` +
198
+ `<span class="bc-sep">/</span>` +
199
+ `<span class="bc-item bc-thing">${escH(thingLabel)}</span>`;
200
+ } else {
201
+ const label = this.#node.label?.value ?? this.#node.label ?? 'Room';
202
+ const type = this.#node.type ?? '';
203
+ this.#breadcrumb.innerHTML =
204
+ `<span class="bc-item bc-room">${escH(label)}</span>` +
205
+ `<span class="bc-type">${type}</span>`;
206
+ }
207
+ }
208
+
209
+ /** Wrap a thingDef so it looks like a GraphNode to the Savant. */
210
+ #makeThingProxy(thingDef, parentNode) {
211
+ // Build a payload Signal from the thing's events object
212
+ const payloadSig = new Signal({ ...thingDef.events });
213
+ // Build event tabs from the thing type's defaultEvents + any custom keys
214
+ const lib = THING_LIBRARY[thingDef.type] ?? {};
215
+ const canAdd = lib.canAddEvents ?? false;
216
+
217
+ const proxy = {
218
+ id: thingDef.id,
219
+ type: 'room', // so workflow renders normally
220
+ label: { value: thingDef.id, subscribe: (_cb, _i) => () => {} },
221
+ payload: payloadSig,
222
+ routes: { peek: () => [] },
223
+ things: { peek: () => [] },
224
+ _isThing: true,
225
+ _canAddEvents: canAdd,
226
+ _thingType: thingDef.type,
227
+
228
+ // GraphNode-compatible step mutation methods
229
+ addStep(event, step) {
230
+ const p = { ...payloadSig.peek() };
231
+ p[event] = [...(p[event] ?? []), step];
232
+ payloadSig.value = p;
233
+ thingDef.events = p;
234
+ parentNode.updateThing(thingDef.id, { events: p });
235
+ },
236
+ insertStep(event, index, step) {
237
+ const p = { ...payloadSig.peek() };
238
+ const steps = [...(p[event] ?? [])];
239
+ steps.splice(index, 0, step);
240
+ p[event] = steps;
241
+ payloadSig.value = p;
242
+ thingDef.events = p;
243
+ parentNode.updateThing(thingDef.id, { events: p });
244
+ },
245
+ removeStep(event, index) {
246
+ const p = { ...payloadSig.peek() };
247
+ p[event] = (p[event] ?? []).filter((_, i) => i !== index);
248
+ payloadSig.value = p;
249
+ thingDef.events = p;
250
+ parentNode.updateThing(thingDef.id, { events: p });
251
+ },
252
+ updateStep(event, index, step) {
253
+ const p = { ...payloadSig.peek() };
254
+ p[event] = (p[event] ?? []).map((s, i) => i === index ? { ...s, ...step } : s);
255
+ payloadSig.value = p;
256
+ thingDef.events = p;
257
+ parentNode.updateThing(thingDef.id, { events: p });
258
+ },
259
+ moveStep(event, from, to) {
260
+ const p = { ...payloadSig.peek() };
261
+ const steps = [...(p[event] ?? [])];
262
+ const [item] = steps.splice(from, 1);
263
+ steps.splice(to, 0, item);
264
+ p[event] = steps;
265
+ payloadSig.value = p;
266
+ thingDef.events = p;
267
+ parentNode.updateThing(thingDef.id, { events: p });
268
+ },
269
+ };
270
+
271
+ return proxy;
272
+ }
273
+
274
+ addCustomAction(id, def) {
275
+ this.#customActions[id] = def;
276
+ // Route to the correct category by ID prefix (e.g. "room.myAction" → room category).
277
+ // Falls back to the first registered category if no prefix match.
278
+ const prefixCat = id.includes('.') ? id.split('.')[0] : null;
279
+ const targetCat = (prefixCat && ACTION_LIBRARY[prefixCat])
280
+ ? prefixCat
281
+ : Object.keys(ACTION_LIBRARY)[0];
282
+ if (targetCat && ACTION_LIBRARY[targetCat]) {
283
+ ACTION_LIBRARY[targetCat].actions[id] = { ...def, _aiGenerated: true };
284
+ if (this.#category === targetCat) this.#renderActions(targetCat);
285
+ }
286
+ this.emit('customActionsChanged', this.#customActions);
287
+ }
288
+
289
+ getCustomActions() { return { ...this.#customActions }; }
290
+
291
+ /**
292
+ * Register (or replace) an entire action category.
293
+ * Called by category plugins installed via App.use().
294
+ */
295
+ registerCategory(catId, def) {
296
+ ACTION_LIBRARY[catId] = { ...def };
297
+ this.#buildCategories();
298
+ this.#selectCategory(this.#category ?? Object.keys(ACTION_LIBRARY)[0]);
299
+ // Keep chat's action catalog in sync
300
+ this.#chat?.setActionLibrary(ACTION_LIBRARY);
301
+ }
302
+
303
+ /**
304
+ * @deprecated — server-side mergePlugins was removed in v2. Actions come exclusively
305
+ * from App.use(ActionsPlugin). This stub is retained to avoid hard errors if old code
306
+ * calls it, but it is a no-op.
307
+ */
308
+ mergePlugins(plugins = {}) {
309
+ // no-op in v2 — use App.use(ActionsPlugin) instead
310
+ console.warn('[Savant] mergePlugins() is deprecated and has no effect in v2. Use App.use(ActionsPlugin).');
311
+ }
312
+
313
+ // ── Event tabs ─────────────────────────────────────────────────────────────
314
+
315
+ static #LIFECYCLE = [
316
+ { key: 'onEnter', label: 'Enter' },
317
+ { key: 'onExit', label: 'Exit' },
318
+ { key: 'onBack', label: 'Back' },
319
+ { key: 'onReset', label: 'Reset' },
320
+ { key: 'onUnload', label: 'Unload' },
321
+ ];
322
+ static #LIFECYCLE_KEYS = new Set(['onEnter','onExit','onBack','onReset','onUnload']);
323
+
324
+ #buildEventTabs() {
325
+ const container = this.#eventTabs;
326
+ container.innerHTML = '';
327
+
328
+ if (!this.#node) { this.#updateEventTabsUI(); return; }
329
+
330
+ if (this.#node._isThing) {
331
+ // Thing mode — show defaultEvents from THING_LIBRARY, plus any custom keys
332
+ const thingType = this.#node._thingType;
333
+ const defaultEvts = getThingEvents(thingType);
334
+ const payload = this.#node.payload.peek();
335
+
336
+ for (const { key, label, fixed } of defaultEvts) {
337
+ container.appendChild(this.#makeTab(key, label, !fixed));
338
+ }
339
+ // Extra event keys not in defaultEvents
340
+ const defaultKeys = new Set(defaultEvts.map(e => e.key));
341
+ for (const key of Object.keys(payload)) {
342
+ if (!defaultKeys.has(key)) {
343
+ container.appendChild(this.#makeTab(key, key, true));
344
+ }
345
+ }
346
+ if (this.#node._canAddEvents) {
347
+ container.appendChild(this.#makeAddTabBtn());
348
+ }
349
+ } else {
350
+ // Room mode — lifecycle tabs
351
+ for (const { key, label } of Savant.#LIFECYCLE) {
352
+ container.appendChild(this.#makeTab(key, label));
353
+ }
354
+
355
+ // Custom event-listener tabs (non-lifecycle payload keys)
356
+ const payload = this.#node.payload.peek();
357
+ for (const key of Object.keys(payload)) {
358
+ if (!Savant.#LIFECYCLE_KEYS.has(key)) {
359
+ container.appendChild(this.#makeTab(key, key, true));
360
+ }
361
+ }
362
+
363
+ // "+" button — only for room nodes (not diamonds)
364
+ if (this.#node.type !== 'diamond') {
365
+ container.appendChild(this.#makeAddTabBtn());
366
+ }
367
+ }
368
+
369
+ this.#updateEventTabsUI();
370
+ }
371
+
372
+ #makeAddTabBtn() {
373
+ const addBtn = document.createElement('button');
374
+ addBtn.type = 'button';
375
+ addBtn.className = 'evt-tab evt-tab-add';
376
+ addBtn.title = 'Add event listener';
377
+ addBtn.textContent = '+';
378
+ addBtn.addEventListener('click', () => this.#spawnTabInput(addBtn));
379
+ return addBtn;
380
+ }
381
+
382
+ #spawnTabInput(addBtn) {
383
+ // Only one inline input at a time
384
+ if (this.#eventTabs.querySelector('.evt-tab-input')) return;
385
+
386
+ const input = document.createElement('input');
387
+ input.type = 'text';
388
+ input.className = 'evt-tab evt-tab-input';
389
+ input.placeholder = 'eventName';
390
+ input.spellcheck = false;
391
+
392
+ const commit = () => {
393
+ const raw = input.value.trim().replace(/\s+/g, '_');
394
+ input.remove();
395
+ if (!raw) return;
396
+ if (!this.#node._isThing && Savant.#LIFECYCLE_KEYS.has(raw)) return;
397
+ const p = this.#node.payload.peek();
398
+ if (p[raw] !== undefined) { this.setEvent(raw); return; }
399
+ const np = { ...p, [raw]: [] };
400
+ if (this.#node._isThing && this.#thingCtx) {
401
+ const { thingDef, parentNode } = this.#thingCtx;
402
+ thingDef.events = np;
403
+ parentNode.updateThing(thingDef.id, { events: np });
404
+ }
405
+ this.#node.payload.value = np;
406
+ this.setEvent(raw);
407
+ };
408
+
409
+ input.addEventListener('keydown', e => {
410
+ if (e.key === 'Enter') { e.preventDefault(); commit(); }
411
+ if (e.key === 'Escape') { e.preventDefault(); input.remove(); }
412
+ });
413
+ // blur fires when focus leaves — but commit() removes the input which triggers another blur,
414
+ // so guard against double-fire with a flag
415
+ input.addEventListener('blur', () => { input.remove(); });
416
+
417
+ this.#eventTabs.insertBefore(input, addBtn);
418
+ input.focus();
419
+ }
420
+
421
+ #makeTab(key, label, removable = false) {
422
+ const btn = document.createElement('button');
423
+ btn.type = 'button';
424
+ btn.className = 'evt-tab position-relative' + (key === this.#event ? ' active' : '');
425
+ btn.dataset.event = key;
426
+ btn.textContent = label;
427
+ btn.addEventListener('click', () => this.setEvent(key));
428
+ if (removable) {
429
+ const x = document.createElement('span');
430
+ x.className = 'evt-tab-remove';
431
+ x.textContent = '×';
432
+ x.title = 'Remove listener';
433
+ x.addEventListener('click', e => {
434
+ e.stopPropagation();
435
+ const p = { ...this.#node.payload.peek() };
436
+ delete p[key];
437
+ if (this.#node._isThing && this.#thingCtx) {
438
+ // Persist the change into the parent node's things array
439
+ const { thingDef, parentNode } = this.#thingCtx;
440
+ thingDef.events = p;
441
+ parentNode.updateThing(thingDef.id, { events: p });
442
+ }
443
+ // Setting .value fires the subscription → #buildEventTabs + #renderWorkflow
444
+ this.#node.payload.value = p;
445
+ this.emit('payload:changed');
446
+ if (this.#event === key) this.setEvent('onEnter');
447
+ });
448
+ btn.appendChild(x);
449
+ }
450
+ return btn;
451
+ }
452
+
453
+ #updateEventTabsUI() {
454
+ const payload = this.#node?.payload?.peek() ?? {};
455
+ this.#eventTabs.querySelectorAll('.evt-tab').forEach(btn => {
456
+ const key = btn.dataset.event;
457
+ btn.classList.toggle('active', key === this.#event);
458
+
459
+ // Remove old badge
460
+ btn.querySelector('.evt-badge')?.remove();
461
+
462
+ // Count steps for this event (routes count for onEnter on diamonds)
463
+ let count = 0;
464
+ if (this.#node?.type === 'diamond' && key === 'onEnter') {
465
+ count = (this.#node.routes?.peek() ?? []).length;
466
+ } else {
467
+ count = (payload[key] ?? []).length;
468
+ }
469
+
470
+ if (count > 0) {
471
+ const badge = document.createElement('span');
472
+ badge.className = 'evt-badge position-absolute top-0 start-100 translate-middle badge rounded-pill';
473
+ badge.textContent = count > 99 ? '99+' : count;
474
+ badge.setAttribute('aria-hidden', 'true');
475
+ btn.appendChild(badge);
476
+ }
477
+ });
478
+ if (this.#wfTitle) {
479
+ const map = { onEnter:'ENTER', onExit:'EXIT', onBack:'BACK', onReset:'RESET', onUnload:'UNLOAD' };
480
+ this.#wfTitle.textContent = `WORKFLOW · ${map[this.#event] ?? this.#event}`;
481
+ }
482
+ }
483
+
484
+ // ── Categories ─────────────────────────────────────────────────────────────
485
+ #buildCategories() {
486
+ this.#catPane.innerHTML = '';
487
+
488
+ const header = document.createElement('div');
489
+ header.className = 'cat-header';
490
+ header.textContent = 'Categories';
491
+ this.#catPane.appendChild(header);
492
+
493
+ for (const [catId, cat] of Object.entries(ACTION_LIBRARY)) {
494
+ this.#catPane.appendChild(this.#makeCatItem(catId, cat.icon, cat.label));
495
+ }
496
+
497
+ // AI input section
498
+ const aiSection = document.createElement('div');
499
+ aiSection.className = 'cat-ai-section';
500
+ aiSection.innerHTML = `
501
+ <textarea class="cat-ai-input" rows="2" placeholder="Describe a new action… e.g. 'Video upload with thumbnail frame selector'"></textarea>
502
+ <button class="cat-ai-btn">${renderAfIcon('magic')}<span>Generate Action</span></button>
503
+ `;
504
+ this.#catPane.appendChild(aiSection);
505
+
506
+ this.#aiInput = aiSection.querySelector('.cat-ai-input');
507
+ this.#aiBtn = aiSection.querySelector('.cat-ai-btn');
508
+ this.#aiBtn.addEventListener('click', () => this.#generateAIAction());
509
+ }
510
+
511
+ #makeCatItem(catId, icon, label) {
512
+ const item = document.createElement('div');
513
+ item.className = 'cat-item';
514
+ item.dataset.cat = catId;
515
+ item.innerHTML = `${renderAfIcon(icon, { class: 'cat-icon' })}<span>${label}</span>`;
516
+ item.addEventListener('click', () => this.#selectCategory(catId));
517
+ return item;
518
+ }
519
+
520
+ #selectCategory(catId) {
521
+ this.#category = catId;
522
+ this.#catPane.querySelectorAll('.cat-item').forEach(el => {
523
+ el.classList.toggle('active', el.dataset.cat === catId);
524
+ });
525
+ this.#renderActions(catId);
526
+ }
527
+
528
+ // ── Actions pane ───────────────────────────────────────────────────────────
529
+ #renderActions(catId) {
530
+ this.#actList.innerHTML = '';
531
+
532
+ const actions = ACTION_LIBRARY[catId]?.actions ?? {};
533
+
534
+ for (const [actionId, def] of Object.entries(actions)) {
535
+ const card = document.createElement('div');
536
+ card.className = 'act-card';
537
+ if (def._aiGenerated) card.classList.add('act-card--ai');
538
+ card.draggable = true;
539
+ card.dataset.actionId = actionId;
540
+ const nameEl = document.createElement('div');
541
+ nameEl.className = 'act-card-name';
542
+ nameEl.textContent = def.label;
543
+ if (def._aiGenerated) {
544
+ const badge = document.createElement('span');
545
+ badge.className = 'act-card-ai-badge';
546
+ badge.title = 'AI-generated action';
547
+ badge.textContent = '✦';
548
+ nameEl.appendChild(badge);
549
+ }
550
+ const descEl = document.createElement('div');
551
+ descEl.className = 'act-card-desc';
552
+ descEl.textContent = def.desc ?? '';
553
+ card.append(nameEl, descEl);
554
+ // Click → preview (not add)
555
+ card.addEventListener('click', () => this.#showPreview(actionId, def, card));
556
+ // Drag → allow dropping onto workflow
557
+ card.addEventListener('dragstart', e => {
558
+ e.dataTransfer.setData('text/plain', `action:${actionId}`);
559
+ e.dataTransfer.effectAllowed = 'copy';
560
+ card.classList.add('dragging');
561
+ });
562
+ card.addEventListener('dragend', () => card.classList.remove('dragging'));
563
+ this.#actList.appendChild(card);
564
+ }
565
+ }
566
+
567
+ // ── Action preview ─────────────────────────────────────────────────────────
568
+ #showPreview(actionId, def, cardEl) {
569
+ // Deselect previous
570
+ this.#actList.querySelectorAll('.act-card.selected').forEach(c => c.classList.remove('selected'));
571
+ cardEl?.classList.add('selected');
572
+ this.#previewedAction = { actionId, def };
573
+
574
+ const previewEmpty = document.getElementById('act-preview-empty');
575
+ const previewBody = document.getElementById('act-preview-body');
576
+ const nameEl = document.getElementById('act-preview-name');
577
+ const descEl = document.getElementById('act-preview-desc');
578
+ const paramsEl = document.getElementById('act-preview-params');
579
+
580
+ previewEmpty.style.display = 'none';
581
+ previewBody.style.display = '';
582
+
583
+ nameEl.textContent = def.label ?? actionId;
584
+ descEl.textContent = def.desc ?? '';
585
+
586
+ // Render param inputs as a visual preview (read-only-ish labels + types)
587
+ paramsEl.innerHTML = '';
588
+ for (const param of (def.params ?? [])) {
589
+ if (param.name === 'into') continue; // meta param
590
+ const row = document.createElement('div');
591
+ row.className = 'act-preview-param';
592
+ const lbl = document.createElement('span');
593
+ lbl.className = 'act-preview-param-label';
594
+ lbl.textContent = param.label ?? param.name;
595
+ const typ = document.createElement('span');
596
+ typ.className = 'act-preview-param-type';
597
+ typ.textContent = param.type ?? 'text';
598
+ if (param.default !== undefined) {
599
+ typ.textContent += ` = ${JSON.stringify(param.default)}`;
600
+ } else if (param.placeholder) {
601
+ typ.textContent += ` — ${param.placeholder}`;
602
+ }
603
+ row.append(lbl, typ);
604
+ paramsEl.appendChild(row);
605
+ }
606
+ if (!(def.params?.length)) {
607
+ paramsEl.innerHTML = '<span style="color:var(--text-muted);font-size:11px">No parameters</span>';
608
+ }
609
+ }
610
+
611
+ // ── Workflow ───────────────────────────────────────────────────────────────
612
+ #getSteps() {
613
+ if (!this.#node) return [];
614
+ const p = this.#node.payload.peek();
615
+ return p[this.#event] ?? [];
616
+ }
617
+
618
+ #renderWorkflow() {
619
+ this.#updateEventTabsUI();
620
+ this.#wfSteps.innerHTML = '';
621
+
622
+ if (!this.#node) {
623
+ this.#wfSteps.innerHTML = `<div class="wf-empty">Select a room or diamond<br>on the map to edit its flow.</div>`;
624
+ return;
625
+ }
626
+
627
+ if (this.#node.type === 'diamond' && this.#event === 'onEnter') {
628
+ // Show routes editor for diamonds
629
+ this.#renderRoutesEditor();
630
+ return;
631
+ }
632
+
633
+ const steps = this.#getSteps();
634
+ if (steps.length === 0) {
635
+ const empty = document.createElement('div');
636
+ empty.className = 'wf-empty wf-drop-target';
637
+ empty.textContent = 'No steps yet. Drag an action here or click to add.';
638
+ this.#setupDropZone(empty, 0);
639
+ this.#wfSteps.appendChild(empty);
640
+ return;
641
+ }
642
+
643
+ // Render steps with drop zones between each one
644
+ this.#wfSteps.appendChild(this.#makeDropZone(0));
645
+ steps.forEach((step, i) => {
646
+ this.#wfSteps.appendChild(this.#makeStepCard(step, i));
647
+ this.#wfSteps.appendChild(this.#makeDropZone(i + 1));
648
+ });
649
+ }
650
+
651
+ #makeDropZone(insertIndex) {
652
+ const dz = document.createElement('div');
653
+ dz.className = 'wf-drop-zone';
654
+ this.#setupDropZone(dz, insertIndex);
655
+ return dz;
656
+ }
657
+
658
+ #setupDropZone(el, insertIndex) {
659
+ el.addEventListener('dragover', e => {
660
+ const data = e.dataTransfer.types.includes('text/plain');
661
+ if (data) { e.preventDefault(); el.classList.add('drag-over'); }
662
+ });
663
+ el.addEventListener('dragleave', () => el.classList.remove('drag-over'));
664
+ el.addEventListener('drop', e => {
665
+ e.preventDefault();
666
+ el.classList.remove('drag-over');
667
+ const raw = e.dataTransfer.getData('text/plain');
668
+ if (raw.startsWith('action:')) {
669
+ const actionId = raw.slice(7);
670
+ const def = this.#findActionDef(actionId);
671
+ if (def) this.#addStepAt(actionId, def, insertIndex);
672
+ }
673
+ // step reorder drops are handled by the step card itself
674
+ });
675
+ }
676
+
677
+ #findActionDef(actionId) {
678
+ for (const cat of Object.values(ACTION_LIBRARY)) {
679
+ if (cat.actions?.[actionId]) return cat.actions[actionId];
680
+ }
681
+ return this.#customActions[actionId] ?? null;
682
+ }
683
+
684
+ // ── Step card ──────────────────────────────────────────────────────────────
685
+ // Each step card has three display modes (toggled via the pill switcher):
686
+ // Basic — plain-language form, no code fields, friendly labels
687
+ // Configure — all params with explicit type-matched inputs (default)
688
+ // JSON — raw JSON editor for power users
689
+
690
+ #stepModeKey(index) { return `${this.#node?.id}:${this.#event}:${index}`; }
691
+
692
+ #getStepMode(step, index) {
693
+ // Prefer memory map; fall back to any legacy _uiMode saved in step data
694
+ return this.#stepModes.get(this.#stepModeKey(index)) ?? step._uiMode ?? 'basic';
695
+ }
696
+
697
+ #setStepMode(index, mode) {
698
+ this.#stepModes.set(this.#stepModeKey(index), mode);
699
+ // Re-render the workflow to reflect the new mode (no project.json mutation)
700
+ this.#renderWorkflow();
701
+ }
702
+
703
+ #makeStepCard(step, index) {
704
+ const def = findAction(step.action) ?? this.#customActions[step.action] ?? null;
705
+
706
+ // Unregistered action — render a degraded "not loaded" card
707
+ if (!def) {
708
+ const card = document.createElement('div');
709
+ card.className = 'step-card step-card-unloaded';
710
+ card.innerHTML = `
711
+ <div class="step-header">
712
+ <span class="step-drag-handle" draggable="true" title="Drag to reorder">⠿</span>
713
+ <span class="step-number">${index + 1}</span>
714
+ <span class="step-action-name step-unloaded-name">Action not loaded</span>
715
+ <code class="step-unloaded-id">${escH(step.action)}</code>
716
+ <div class="step-controls">
717
+ <button class="step-btn del" title="Delete">${renderAfIcon('x-lg')}</button>
718
+ </div>
719
+ </div>`;
720
+ const dragHandle = card.querySelector('.step-drag-handle');
721
+ dragHandle.addEventListener('dragstart', e => {
722
+ e.dataTransfer.setData('text/plain', String(index));
723
+ e.dataTransfer.effectAllowed = 'move';
724
+ card.style.opacity = '0.4';
725
+ });
726
+ dragHandle.addEventListener('dragend', () => { card.style.opacity = ''; });
727
+ card.addEventListener('dragover', e => { e.preventDefault(); card.style.outline = '1px solid var(--accent)'; });
728
+ card.addEventListener('dragleave', () => { card.style.outline = ''; });
729
+ card.addEventListener('drop', e => {
730
+ e.preventDefault(); card.style.outline = '';
731
+ const raw = e.dataTransfer.getData('text/plain');
732
+ if (raw.startsWith('action:')) return;
733
+ const fromIdx = parseInt(raw);
734
+ if (!isNaN(fromIdx) && fromIdx !== index) this.#node.moveStep(this.#event, fromIdx, index);
735
+ });
736
+ card.querySelector('.step-btn.del').addEventListener('click', () => {
737
+ this.#node.removeStep(this.#event, index);
738
+ });
739
+ return card;
740
+ }
741
+
742
+ const params = def.params ?? [];
743
+ const card = document.createElement('div');
744
+ const isCollapsed = this.#collapsedSteps.has(this.#stepModeKey(index));
745
+ card.className = 'step-card' + (isCollapsed ? ' collapsed' : '');
746
+
747
+ const mode = this.#getStepMode(step, index);
748
+
749
+ const header = document.createElement('div');
750
+ header.className = 'step-header';
751
+ header.innerHTML = `
752
+ <span class="step-drag-handle" draggable="true" title="Drag to reorder">⠿</span>
753
+ <span class="step-number">${index + 1}</span>
754
+ <span class="step-action-name">${def.label ?? step.action}</span>
755
+ <button class="step-collapse-btn" title="${isCollapsed ? 'Expand' : 'Collapse'}">${isCollapsed ? '▸' : '▾'}</button>
756
+ <div class="step-mode-pills">
757
+ <button class="step-mode-pill${mode === 'basic' ? ' active' : ''}" data-mode="basic" title="Simple view">Basic</button>
758
+ <button class="step-mode-pill${mode === 'configure' ? ' active' : ''}" data-mode="configure" title="All parameters">Configure</button>
759
+ <button class="step-mode-pill${mode === 'json' ? ' active' : ''}" data-mode="json" title="Raw JSON">JSON</button>
760
+ </div>
761
+ <div class="step-controls">
762
+ <button class="step-btn" title="Move up">↑</button>
763
+ <button class="step-btn" title="Move down">↓</button>
764
+ <button class="step-btn del" title="Delete">${renderAfIcon('x-lg')}</button>
765
+ </div>`;
766
+
767
+ const paramsDiv = document.createElement('div');
768
+ paramsDiv.className = 'step-params open';
769
+ paramsDiv.style.display = isCollapsed ? 'none' : '';
770
+
771
+ this.#renderStepParamsInMode(paramsDiv, step, index, def, mode);
772
+
773
+ card.appendChild(header);
774
+ card.appendChild(paramsDiv);
775
+
776
+ // Collapse button
777
+ const collapseBtn = header.querySelector('.step-collapse-btn');
778
+ collapseBtn.addEventListener('click', e => {
779
+ e.stopPropagation();
780
+ const key = this.#stepModeKey(index);
781
+ if (this.#collapsedSteps.has(key)) this.#collapsedSteps.delete(key);
782
+ else this.#collapsedSteps.add(key);
783
+ this.#renderWorkflow();
784
+ });
785
+
786
+ // Mode pill switching
787
+ header.querySelectorAll('.step-mode-pill').forEach(pill => {
788
+ pill.addEventListener('click', (e) => {
789
+ e.stopPropagation();
790
+ const newMode = pill.dataset.mode;
791
+ this.#setStepMode(index, newMode);
792
+ });
793
+ });
794
+
795
+ // Button handlers
796
+ const [upBtn, downBtn, delBtn] = header.querySelectorAll('.step-btn');
797
+ delBtn.addEventListener('click', () => { this.#node.removeStep(this.#event, index); });
798
+ upBtn.addEventListener('click', () => { if (index > 0) this.#node.moveStep(this.#event, index, index - 1); });
799
+ downBtn.addEventListener('click',() => {
800
+ const steps = this.#getSteps();
801
+ if (index < steps.length - 1) this.#node.moveStep(this.#event, index, index + 1);
802
+ });
803
+
804
+ // Drag-to-reorder — only the handle initiates drag so inputs stay interactive
805
+ const dragHandle = header.querySelector('.step-drag-handle');
806
+ dragHandle.addEventListener('dragstart', e => {
807
+ e.dataTransfer.setData('text/plain', String(index));
808
+ e.dataTransfer.effectAllowed = 'move';
809
+ card.style.opacity = '0.4';
810
+ });
811
+ dragHandle.addEventListener('dragend', () => { card.style.opacity = ''; });
812
+ card.addEventListener('dragover', e => { e.preventDefault(); card.style.outline = '1px solid var(--accent)'; });
813
+ card.addEventListener('dragleave', () => { card.style.outline = ''; });
814
+ card.addEventListener('drop', e => {
815
+ e.preventDefault(); card.style.outline = '';
816
+ const raw = e.dataTransfer.getData('text/plain');
817
+ if (raw.startsWith('action:')) return; // handled by drop zones
818
+ const fromIdx = parseInt(raw);
819
+ if (!isNaN(fromIdx) && fromIdx !== index) this.#node.moveStep(this.#event, fromIdx, index);
820
+ });
821
+
822
+ return card;
823
+ }
824
+
825
+ #renderStepParamsInMode(container, step, index, def, mode) {
826
+ container.innerHTML = '';
827
+ if (mode === 'json') {
828
+ this.#renderJsonMode(container, step, index);
829
+ } else if (mode === 'basic') {
830
+ this.#renderBasicMode(container, step, index, def);
831
+ } else {
832
+ this.#renderConfigureMode(container, step, index, def);
833
+ }
834
+ }
835
+
836
+ // ── Basic mode — friendly, no code fields ──────────────────────────────────
837
+ #renderBasicMode(container, step, index, def) {
838
+ const params = (def.params ?? []).filter(p => p.type !== 'code' && p.type !== 'textarea');
839
+
840
+ if (params.length === 0 && def.desc) {
841
+ const note = document.createElement('div');
842
+ note.className = 'step-basic-desc';
843
+ note.textContent = def.desc;
844
+ container.appendChild(note);
845
+ return;
846
+ }
847
+
848
+ if (params.length === 0) {
849
+ const note = document.createElement('div');
850
+ note.className = 'step-basic-desc';
851
+ note.textContent = 'No configuration needed — this action runs automatically.';
852
+ container.appendChild(note);
853
+ return;
854
+ }
855
+
856
+ for (const p of params) {
857
+ const row = document.createElement('div');
858
+ row.className = 'param-row';
859
+ const lbl = document.createElement('label');
860
+ lbl.className = 'param-label';
861
+ lbl.textContent = p.label;
862
+
863
+ const hint = document.createElement('span');
864
+ hint.className = 'param-hint';
865
+ hint.textContent = p.placeholder ?? '';
866
+
867
+ const input = this.#makeParamInput(p, step.params?.[p.name] ?? p.default ?? '');
868
+ input.addEventListener('change', () => {
869
+ const storedVal = input.value;
870
+ this.#node.updateStep(this.#event, index, {
871
+ params: { ...(step.params ?? {}), [p.name]: storedVal }
872
+ });
873
+ });
874
+ row.appendChild(lbl);
875
+ row.appendChild(input);
876
+ container.appendChild(row);
877
+ }
878
+
879
+ // Show any code params as a note prompting Configure
880
+ const codeParams = (def.params ?? []).filter(p => p.type === 'code' || p.type === 'textarea');
881
+ if (codeParams.length > 0) {
882
+ const note = document.createElement('div');
883
+ note.className = 'step-basic-code-note';
884
+ note.innerHTML = `${renderAfIcon('gear')} <span>${codeParams.map(p => p.label).join(', ')} — switch to <strong>Configure</strong> to edit</span>`;
885
+ container.appendChild(note);
886
+ }
887
+ }
888
+
889
+ // ── Configure mode — all params ────────────────────────────────────────────
890
+ #renderConfigureMode(container, step, index, def) {
891
+ const params = def.params ?? [];
892
+ if (params.length === 0) {
893
+ const note = document.createElement('div');
894
+ note.className = 'step-basic-desc';
895
+ note.textContent = def.desc ?? 'No parameters.';
896
+ container.appendChild(note);
897
+ return;
898
+ }
899
+
900
+ for (const p of params) {
901
+ const row = document.createElement('div');
902
+ row.className = 'param-row';
903
+ const lbl = document.createElement('label');
904
+ lbl.className = 'param-label';
905
+ lbl.innerHTML = `${p.label}${p.type === 'code' ? ' <span class="param-code-badge">JS</span>' : ''}`;
906
+ const input = this.#makeParamInput(p, step.params?.[p.name] ?? p.default ?? '');
907
+ input.addEventListener('change', () => {
908
+ const storedVal = input.value;
909
+ this.#node.updateStep(this.#event, index, {
910
+ params: { ...(step.params ?? {}), [p.name]: storedVal }
911
+ });
912
+ });
913
+ row.appendChild(lbl);
914
+ row.appendChild(input);
915
+ container.appendChild(row);
916
+ }
917
+ }
918
+
919
+ // ── JSON mode — raw step object editor ─────────────────────────────────────
920
+ #renderJsonMode(container, step, index) {
921
+ const { _uiMode, ...cleanStep } = step;
922
+ const ta = document.createElement('textarea');
923
+ ta.className = 'param-input step-json-editor';
924
+ ta.rows = 6;
925
+ ta.value = JSON.stringify(cleanStep, null, 2);
926
+ ta.spellcheck = false;
927
+
928
+ let parseErr = false;
929
+ const errDiv = document.createElement('div');
930
+ errDiv.className = 'step-json-error';
931
+ errDiv.style.display = 'none';
932
+
933
+ ta.addEventListener('input', () => {
934
+ try {
935
+ const parsed = JSON.parse(ta.value);
936
+ parseErr = false;
937
+ errDiv.style.display = 'none';
938
+ ta.style.borderColor = '';
939
+ this.#node.updateStep(this.#event, index, parsed);
940
+ } catch (e) {
941
+ parseErr = true;
942
+ errDiv.textContent = e.message;
943
+ errDiv.style.display = 'block';
944
+ ta.style.borderColor = 'var(--danger)';
945
+ }
946
+ });
947
+
948
+ container.appendChild(ta);
949
+ container.appendChild(errDiv);
950
+ }
951
+
952
+ #makeParamInput(paramDef, value) {
953
+ if (paramDef.type === 'select') {
954
+ const sel = document.createElement('select');
955
+ sel.className = 'param-input';
956
+ for (const opt of (paramDef.options ?? [])) {
957
+ const o = document.createElement('option');
958
+ o.value = opt; o.textContent = opt;
959
+ if (opt === value) o.selected = true;
960
+ sel.appendChild(o);
961
+ }
962
+ return sel;
963
+ }
964
+
965
+ if (paramDef.type === 'boolean') {
966
+ const sel = document.createElement('select');
967
+ sel.className = 'param-input';
968
+ for (const opt of ['true','false']) {
969
+ const o = document.createElement('option');
970
+ o.value = opt; o.textContent = opt;
971
+ if (String(value) === opt) o.selected = true;
972
+ sel.appendChild(o);
973
+ }
974
+ return sel;
975
+ }
976
+
977
+ if (paramDef.type === 'textarea') {
978
+ const ta = document.createElement('textarea');
979
+ ta.className = 'param-input';
980
+ ta.rows = 2;
981
+ ta.value = value ?? '';
982
+ ta.placeholder = paramDef.placeholder ?? '';
983
+ return ta;
984
+ }
985
+
986
+ if (paramDef.type === 'room') {
987
+ // Dropdown populated by app via needNodeOptions event
988
+ const sel = document.createElement('select');
989
+ sel.className = 'param-input';
990
+ const placeholder = document.createElement('option');
991
+ placeholder.value = ''; placeholder.textContent = '— select room —';
992
+ sel.appendChild(placeholder);
993
+ sel.value = value ?? '';
994
+ // Defer population until app wires needNodeOptions
995
+ requestAnimationFrame(() => {
996
+ this.emit('needNodeOptions', { select: sel, currentValue: value ?? '' });
997
+ });
998
+ return sel;
999
+ }
1000
+
1001
+ if (paramDef.type === 'inventory-key') {
1002
+ // inventory-key params are plain strings in v2 — no { $$inv } wrapper
1003
+ const keyName = typeof value === 'string' ? value : '';
1004
+ const inp = document.createElement('input');
1005
+ inp.type = 'text';
1006
+ inp.className = 'param-input param-input-inv-key';
1007
+ inp.value = keyName;
1008
+ inp.placeholder = paramDef.placeholder ?? 'inventoryKey';
1009
+ return inp;
1010
+ }
1011
+
1012
+ const inp = document.createElement('input');
1013
+ inp.className = 'param-input';
1014
+ if (paramDef.type === 'number') inp.type = 'number';
1015
+ else if (paramDef.type === 'url') inp.type = 'url';
1016
+ else inp.type = 'text';
1017
+ inp.value = value ?? '';
1018
+ inp.placeholder = paramDef.placeholder ?? '';
1019
+ if (paramDef.type === 'code') inp.classList.add('param-input-code');
1020
+ return inp;
1021
+ }
1022
+
1023
+ // ── Add step from action card ──────────────────────────────────────────────
1024
+ #addStep(actionId, def) {
1025
+ if (!this.#node) return;
1026
+ const params = {};
1027
+ for (const p of (def.params ?? [])) {
1028
+ params[p.name] = p.default ?? '';
1029
+ }
1030
+ this.#node.addStep(this.#event, { action: actionId, params });
1031
+ // Expand the last card after render
1032
+ requestAnimationFrame(() => {
1033
+ const cards = this.#wfSteps.querySelectorAll('.step-card');
1034
+ const last = cards[cards.length - 1];
1035
+ last?.querySelector('.step-params')?.classList.add('open');
1036
+ last?.scrollIntoView({ behavior: 'smooth' });
1037
+ });
1038
+ }
1039
+
1040
+ #addStepAt(actionId, def, insertIndex) {
1041
+ if (!this.#node) return;
1042
+ const params = {};
1043
+ for (const p of (def.params ?? [])) {
1044
+ params[p.name] = p.default ?? '';
1045
+ }
1046
+ this.#node.insertStep(this.#event, insertIndex, { action: actionId, params });
1047
+ }
1048
+
1049
+ // ── Diamond routes editor ─────────────────────────────────────────────────
1050
+ #renderRoutesEditor() {
1051
+ this.#wfSteps.innerHTML = '';
1052
+
1053
+ const header = document.createElement('div');
1054
+ header.style.cssText = 'font-size:11px;color:var(--text-muted);margin-bottom:8px';
1055
+ header.textContent = 'Routes are evaluated top-to-bottom. First match wins. Use inventory.key syntax.';
1056
+ this.#wfSteps.appendChild(header);
1057
+
1058
+ const routes = this.#node.routes.peek() ?? [];
1059
+ routes.forEach((route, i) => {
1060
+ this.#wfSteps.appendChild(this.#makeRouteRow(route, i));
1061
+ });
1062
+
1063
+ const addBtn = document.createElement('button');
1064
+ addBtn.className = 'add-route-btn';
1065
+ addBtn.textContent = '+ Add Route';
1066
+ addBtn.addEventListener('click', () => {
1067
+ const rs = [...(this.#node.routes.peek() ?? [])];
1068
+ rs.push({ condition: 'true', target: '', label: 'Default' });
1069
+ this.#node.routes.value = rs;
1070
+ this.#renderWorkflow();
1071
+ });
1072
+ this.#wfSteps.appendChild(addBtn);
1073
+ }
1074
+
1075
+ /** Parse a condition like `inventory.get('key') === 'val'` or `inventory.key === 'val'` into {key, op, value} */
1076
+ #parseCondition(cond) {
1077
+ // Match: inventory.get('key') op value OR inventory.key op value
1078
+ const m = cond.trim().match(/^inventory\.(?:get\(['"](.+?)['"]\)|(\w+))\s*(===|!==|==|!=|>=|<=|>|<|includes)\s*(.+)$/);
1079
+ if (!m) return null;
1080
+ const key = m[1] ?? m[2];
1081
+ const op = m[3];
1082
+ let val = m[4].trim();
1083
+ // Strip quotes from string values
1084
+ if ((val.startsWith("'") && val.endsWith("'")) || (val.startsWith('"') && val.endsWith('"'))) {
1085
+ val = val.slice(1, -1);
1086
+ }
1087
+ return { key, op, val };
1088
+ }
1089
+
1090
+ /** Build a condition expression from {key, op, val} */
1091
+ #buildCondition(key, op, val) {
1092
+ if (!key) return 'true';
1093
+ const valExpr = isNaN(val) && val !== 'true' && val !== 'false'
1094
+ ? `'${val.replace(/'/g, "\\'")}'`
1095
+ : val;
1096
+ return `inventory.get('${key}') ${op} ${valExpr}`;
1097
+ }
1098
+
1099
+ #makeRouteRow(route, index) {
1100
+ const row = document.createElement('div');
1101
+ row.className = 'route-row';
1102
+
1103
+ const parsed = this.#parseCondition(route.condition ?? '');
1104
+
1105
+ row.innerHTML = `
1106
+ <div class="route-builder">
1107
+ <div class="route-visual${parsed ? '' : ' hidden'}">
1108
+ <select class="route-key"><option value="">— inventory key —</option></select>
1109
+ <select class="route-op">
1110
+ <option value="===">=== (equals)</option>
1111
+ <option value="!==">!== (not equals)</option>
1112
+ <option value=">=">>= (≥)</option>
1113
+ <option value="<=">&lt;= (≤)</option>
1114
+ <option value=">">> (greater)</option>
1115
+ <option value="<">&lt; (less)</option>
1116
+ <option value="includes">includes</option>
1117
+ </select>
1118
+ <input class="route-val" placeholder="value">
1119
+ </div>
1120
+ <div class="route-advanced${parsed ? ' hidden' : ''}">
1121
+ <input class="route-condition" placeholder="inventory.get('key') === 'value'" value="${escAttr(route.condition ?? '')}">
1122
+ </div>
1123
+ <button class="route-toggle-mode" title="${parsed ? 'Switch to expression mode' : 'Switch to visual mode'}">${parsed ? '{ }' : '◈'}</button>
1124
+ </div>
1125
+ <div class="route-footer">
1126
+ <select class="route-target">
1127
+ <option value="">— target room —</option>
1128
+ </select>
1129
+ <button class="route-del" title="Delete route">${renderAfIcon('x-lg')}</button>
1130
+ </div>`;
1131
+
1132
+ const visualDiv = row.querySelector('.route-visual');
1133
+ const advDiv = row.querySelector('.route-advanced');
1134
+ const keySel = row.querySelector('.route-key');
1135
+ const opSel = row.querySelector('.route-op');
1136
+ const valInput = row.querySelector('.route-val');
1137
+ const condInput = row.querySelector('.route-condition');
1138
+ const toggleBtn = row.querySelector('.route-toggle-mode');
1139
+ const tgtSel = row.querySelector('.route-target');
1140
+ const delBtn = row.querySelector('.route-del');
1141
+
1142
+ // Populate inventory keys
1143
+ this.emit('needInventoryKeys', { select: keySel, currentValue: parsed?.key ?? '' });
1144
+
1145
+ // Populate target options from graph
1146
+ this.emit('needNodeOptions', { select: tgtSel, currentValue: route.target });
1147
+
1148
+ // Set visual values if parsed
1149
+ if (parsed) {
1150
+ keySel.value = parsed.key;
1151
+ opSel.value = parsed.op;
1152
+ valInput.value = parsed.val;
1153
+ }
1154
+
1155
+ // Toggle between visual and advanced mode
1156
+ toggleBtn.addEventListener('click', () => {
1157
+ const isVisual = !visualDiv.classList.contains('hidden');
1158
+ if (isVisual) {
1159
+ // Switch to expression
1160
+ visualDiv.classList.add('hidden');
1161
+ advDiv.classList.remove('hidden');
1162
+ toggleBtn.textContent = '◈';
1163
+ toggleBtn.title = 'Switch to visual mode';
1164
+ } else {
1165
+ // Switch to visual — try to parse current expression
1166
+ const p2 = this.#parseCondition(condInput.value);
1167
+ if (p2) {
1168
+ this.emit('needInventoryKeys', { select: keySel, currentValue: p2.key });
1169
+ keySel.value = p2.key;
1170
+ opSel.value = p2.op;
1171
+ valInput.value = p2.val;
1172
+ visualDiv.classList.remove('hidden');
1173
+ advDiv.classList.add('hidden');
1174
+ toggleBtn.textContent = '{ }';
1175
+ toggleBtn.title = 'Switch to expression mode';
1176
+ } else {
1177
+ this.emit('toast', { msg: 'Cannot parse expression — edit manually', type: 'info' });
1178
+ }
1179
+ }
1180
+ });
1181
+
1182
+ const syncFromVisual = () => {
1183
+ const cond = this.#buildCondition(keySel.value, opSel.value, valInput.value || 'true');
1184
+ condInput.value = cond;
1185
+ this.#updateRoute(index, { condition: cond });
1186
+ };
1187
+
1188
+ keySel.addEventListener('change', syncFromVisual);
1189
+ opSel.addEventListener('change', syncFromVisual);
1190
+ valInput.addEventListener('input', syncFromVisual);
1191
+ condInput.addEventListener('change', () => this.#updateRoute(index, { condition: condInput.value }));
1192
+
1193
+ tgtSel.addEventListener('change', () => this.#updateRoute(index, { target: tgtSel.value }));
1194
+ delBtn.addEventListener('click', () => {
1195
+ const rs = (this.#node.routes.peek() ?? []).filter((_, i) => i !== index);
1196
+ this.#node.routes.value = rs;
1197
+ this.#renderWorkflow();
1198
+ });
1199
+
1200
+ return row;
1201
+ }
1202
+
1203
+ #updateRoute(index, changes) {
1204
+ const routes = [...(this.#node.routes.peek() ?? [])];
1205
+ routes[index] = { ...routes[index], ...changes };
1206
+ this.#node.routes.value = routes;
1207
+ }
1208
+
1209
+ // ── AI action generation ──────────────────────────────────────────────────
1210
+ async #generateAIAction() {
1211
+ const prompt = this.#aiInput.value.trim();
1212
+ if (!prompt) return;
1213
+
1214
+ this.#aiBtn.disabled = true;
1215
+ this.#aiBtn.innerHTML = `${renderAfIcon('arrow-repeat')}<span>Generating…</span>`;
1216
+
1217
+ try {
1218
+ const result = await API.generateAction(prompt);
1219
+ if (result?.id && result?.label) {
1220
+ this.addCustomAction(result.id, result);
1221
+ // Navigate to the category where the action landed
1222
+ const prefixCat = result.id.includes('.') ? result.id.split('.')[0] : null;
1223
+ const targetCat = (prefixCat && ACTION_LIBRARY[prefixCat])
1224
+ ? prefixCat
1225
+ : Object.keys(ACTION_LIBRARY)[0];
1226
+ if (targetCat) this.#selectCategory(targetCat);
1227
+ this.#aiInput.value = '';
1228
+ const catLabel = ACTION_LIBRARY[targetCat]?.label ?? targetCat;
1229
+ this.emit('toast', { msg: `Action "${result.label}" added to ${catLabel}`, type: 'success' });
1230
+ } else {
1231
+ this.emit('toast', { msg: 'AI returned an unexpected response.', type: 'error' });
1232
+ }
1233
+ } catch (err) {
1234
+ this.emit('toast', { msg: `AI error: ${err.message}`, type: 'error' });
1235
+ } finally {
1236
+ this.#aiBtn.disabled = false;
1237
+ this.#aiBtn.innerHTML = `${renderAfIcon('magic')}<span>Generate Action</span>`;
1238
+ }
1239
+ }
1240
+
1241
+ // ── Chat context ──────────────────────────────────────────────────────────
1242
+
1243
+ #updateChatContext() {
1244
+ if (!this.#chat) return;
1245
+ if (this.#node?._isThing && this.#thingCtx) {
1246
+ const { thingDef, parentNode } = this.#thingCtx;
1247
+ this.#chat.setContext({
1248
+ projectId: this._projectId ?? '',
1249
+ nodeId: parentNode.id,
1250
+ nodeLabel: parentNode.label?.value ?? '',
1251
+ thingId: thingDef.id,
1252
+ thingLabel: THING_LIBRARY[thingDef.type]?.label ?? thingDef.type,
1253
+ eventKey: this.#event,
1254
+ nodePayload: thingDef.events,
1255
+ });
1256
+ } else if (this.#node) {
1257
+ this.#chat.setContext({
1258
+ projectId: this._projectId ?? '',
1259
+ nodeId: this.#node.id,
1260
+ nodeLabel: this.#node.label?.value ?? '',
1261
+ thingId: '',
1262
+ thingLabel: '',
1263
+ eventKey: this.#event,
1264
+ nodePayload: this.#node.payload?.peek() ?? null,
1265
+ });
1266
+ } else {
1267
+ this.#chat.setContext({});
1268
+ }
1269
+ }
1270
+
1271
+ /** Called by App when a project is opened so chat keys are scoped to the project. */
1272
+ setProjectId(id) {
1273
+ this._projectId = id;
1274
+ this.#updateChatContext();
1275
+ }
1276
+
1277
+ /** Wire the executor callback that runs undercity-commands from the AI chat. */
1278
+ setChatExecutor(fn) {
1279
+ if (this.#chat) this.#chat.onExecuteCommands = fn;
1280
+ }
1281
+
1282
+ dispose() { this.#scope.dispose(); this.#nodeScope.dispose(); }
1283
+ }
1284
+
1285
+ function escAttr(str) {
1286
+ return String(str ?? '').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
1287
+ }