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
package/src/ide/app.js ADDED
@@ -0,0 +1,1375 @@
1
+ /**
2
+ * app.js — Undercity IDE Application.
3
+ *
4
+ * App extends Emitter and acts as the central host for:
5
+ * • Plugin registration (App.use(plugin))
6
+ * • The command palette (Ctrl+K)
7
+ * • Project management (open, save, generate, preview)
8
+ * • Graph + UI wiring (MapBuilder, Savant, PropertiesPanel)
9
+ * • Undo/redo via CommandHistory
10
+ * • Auto-save after each mutation
11
+ * • Copy / Cut / Paste
12
+ *
13
+ * Plugin interface:
14
+ * const MyPlugin = {
15
+ * name: 'my-plugin',
16
+ * install(app) {
17
+ * app.registerCommand({ name: '…', … });
18
+ * app.on('project:opened', proj => { … });
19
+ * }
20
+ * };
21
+ * app.use(MyPlugin);
22
+ */
23
+
24
+ import { ActionsPlugin } from '/actions/index.js';
25
+ import { Emitter } from '/src/lib/signal.js';
26
+ import { Scope } from '/src/lib/scope.js';
27
+ import { normalizeIconName, renderAfIcon } from '/src/lib/icons.js';
28
+ import { Graph, NodeType } from '/src/ide/graph.js';
29
+ import { THING_LIBRARY } from '/src/ide/thing-library.js';
30
+ import '/src/ide/undercity-map.js';
31
+ import { Savant } from '/src/ide/savant.js';
32
+ import { API } from '/src/ide/project-api.js';
33
+ import { CommandPalette } from '/src/ide/command-line/index.js';
34
+ import { ALL_COMMANDS } from '/src/ide/command-line/commands.js';
35
+ import { CommandHistory } from '/src/ide/history.js';
36
+
37
+ // ── App class ─────────────────────────────────────────────────────────────────
38
+
39
+ class App extends Emitter {
40
+ // Private state
41
+ #project = null;
42
+ #graph = new Graph();
43
+ #mapBuilder = null;
44
+ #savant = null;
45
+ #isDirty = false;
46
+ #commands = new Map();
47
+ #plugins = [];
48
+ #scope = new Scope();
49
+ #propsScope = new Scope(); // reset each time the props panel opens
50
+ #palette = null;
51
+ #history = new CommandHistory();
52
+ #clipboard = null; // { node: NodeJSON } | null
53
+ #autoSaveTimer = null;
54
+ #historyRestoring = false; // true during undo/redo (suppresses auto-save)
55
+ #selectedEdge = null; // currently selected GraphEdge | null
56
+
57
+ // ── Accessors ──────────────────────────────────────────────────────
58
+
59
+ get project() { return this.#project; }
60
+ get graph() { return this.#graph; }
61
+ get isDirty() { return this.#isDirty; }
62
+
63
+ // ── Plugin system ──────────────────────────────────────────────────
64
+
65
+ /**
66
+ * Register a plugin. The plugin's `install(app)` method is called
67
+ * immediately, allowing it to register commands, listen to events, etc.
68
+ * Returns `this` for chaining: app.use(A).use(B).
69
+ */
70
+ use(plugin) {
71
+ if (typeof plugin.install !== 'function') {
72
+ throw new TypeError(`Plugin "${plugin.name ?? '?'}" must have an install(app) method`);
73
+ }
74
+ plugin.install(this);
75
+ this.#plugins.push(plugin);
76
+ this.emit('plugin:installed', plugin);
77
+ return this;
78
+ }
79
+
80
+ // ── Command system ─────────────────────────────────────────────────
81
+
82
+ /** Register a command definition. Overwrites any existing command with the same name. */
83
+ registerCommand(def) {
84
+ this.#commands.set(def.name, def);
85
+ this.emit('command:registered', def);
86
+ }
87
+
88
+ /** Execute a command by name with pre-resolved args/flags. */
89
+ async execute(name, args = [], flags = {}) {
90
+ const cmd = this.#commands.get(name);
91
+ if (!cmd) throw new Error(`Unknown command: ${name}`);
92
+ return cmd.execute({ args, flags, app: this });
93
+ }
94
+
95
+ /** Look up a registered command by name. */
96
+ getCommand(name) { return this.#commands.get(name) ?? null; }
97
+
98
+ /** Register (or replace) an action category in the Savant. Used by category plugins. */
99
+ registerActions(catId, def) {
100
+ this.#savant?.registerCategory(catId, def);
101
+ }
102
+
103
+ /** List all registered commands. */
104
+ listCommands() { return [...this.#commands.values()]; }
105
+
106
+ // ── Project management ─────────────────────────────────────────────
107
+
108
+ async loadProjectList() {
109
+ try {
110
+ const projects = await API.listProjects();
111
+ const sel = document.getElementById('project-select');
112
+ sel.innerHTML = '<option value="">— select project —</option>';
113
+ for (const p of projects) {
114
+ const opt = document.createElement('option');
115
+ opt.value = p.id;
116
+ opt.textContent = p.name;
117
+ if (this.#project?.id === p.id) opt.selected = true;
118
+ sel.appendChild(opt);
119
+ }
120
+ return projects;
121
+ } catch (err) {
122
+ this.toast(`Failed to load projects: ${err.message}`, 'error');
123
+ return [];
124
+ }
125
+ }
126
+
127
+ async openProject(id) {
128
+ try {
129
+ const proj = await API.getProject(id);
130
+ this.#project = proj;
131
+ this.#graph = new Graph();
132
+ if (proj.graph) this.#graph.fromJSON(proj.graph);
133
+
134
+ // Reset undo/redo when opening a new project
135
+ this.#history.clear();
136
+
137
+ // Re-wire map
138
+ this.#mapBuilder.setGraph(this.#graph);
139
+ this.#bindMapEvents();
140
+
141
+ // Re-wire savant
142
+ this.#savant.setNode(null);
143
+ this.#savant.setProjectId(proj.id);
144
+ for (const [id, def] of Object.entries(proj.customActions ?? {})) {
145
+ this.#savant.addCustomAction(id, def);
146
+ }
147
+
148
+ this.markClean();
149
+ this.#mapBuilder.fitView();
150
+ this.#addRecent(proj);
151
+ localStorage.setItem('pw-last-project', proj.id);
152
+ window.currentProject = this.#project;
153
+ this.toast(`Opened: ${proj.name}`, 'info');
154
+ this.emit('project:opened', proj);
155
+ this.#hideWelcome();
156
+ } catch (err) {
157
+ this.toast(`Error loading project: ${err.message}`, 'error');
158
+ }
159
+ }
160
+
161
+ async saveProject() {
162
+ if (!this.#project) { this.toast('No project open', 'error'); return; }
163
+ try {
164
+ const data = {
165
+ ...this.#project,
166
+ graph: this.#graph.toJSON(),
167
+ customActions: this.#savant.getCustomActions(),
168
+ };
169
+ await API.saveProject(this.#project.id, data);
170
+ this.#project = data;
171
+ this.markClean();
172
+ this.toast('Project saved', 'success');
173
+ this.emit('project:saved', data);
174
+ } catch (err) {
175
+ this.toast(`Save failed: ${err.message}`, 'error');
176
+ }
177
+ }
178
+
179
+ /** Silent background save — no toast, no dirty update. */
180
+ async #silentSave() {
181
+ if (!this.#project || !this.#isDirty) return;
182
+ try {
183
+ const data = {
184
+ ...this.#project,
185
+ graph: this.#graph.toJSON(),
186
+ customActions: this.#savant.getCustomActions(),
187
+ };
188
+ await API.saveProject(this.#project.id, data);
189
+ this.#project = data;
190
+ this.markClean();
191
+ } catch { /* silent — user can save manually */ }
192
+ }
193
+
194
+ /** Schedule a silent auto-save after a short debounce. */
195
+ #scheduleAutoSave() {
196
+ if (this.#historyRestoring) return; // don't auto-save during undo/redo
197
+ clearTimeout(this.#autoSaveTimer);
198
+ this.#autoSaveTimer = setTimeout(() => this.#silentSave(), 800);
199
+ }
200
+
201
+ async createProject(data) {
202
+ try {
203
+ const proj = await API.createProject({
204
+ ...data,
205
+ graph: data.graph ?? { nodes: [], edges: [] },
206
+ inventory: data.inventory ?? { schema: {} },
207
+ customActions: data.customActions ?? {},
208
+ });
209
+ await this.loadProjectList();
210
+ const sel = document.getElementById('project-select');
211
+ sel.value = proj.id;
212
+ await this.openProject(proj.id);
213
+ return proj;
214
+ } catch (err) {
215
+ this.toast(`Create failed: ${err.message}`, 'error');
216
+ throw err;
217
+ }
218
+ }
219
+
220
+ async generateProject() {
221
+ if (!this.#project) { this.toast('No project open', 'error'); return null; }
222
+ const btn = document.getElementById('btn-generate');
223
+ btn.innerHTML = buttonLabel('arrow-repeat', 'Generating…');
224
+ btn.disabled = true;
225
+ try {
226
+ await API.saveProject(this.#project.id, { ...this.#project, graph: this.#graph.toJSON() });
227
+ const result = await API.generateProject(this.#project.id);
228
+ this.toast(`Generated ${result.files.length} files → ${result.path}`, 'success');
229
+ this.emit('project:generated', result);
230
+ return result;
231
+ } catch (err) {
232
+ this.toast(`Generate failed: ${err.message}`, 'error');
233
+ return null;
234
+ } finally {
235
+ btn.innerHTML = buttonLabel('lightning-charge', 'Generate');
236
+ btn.disabled = false;
237
+ }
238
+ }
239
+
240
+ // ── Undo / Redo ───────────────────────────────────────────────────
241
+
242
+ undo() {
243
+ const prev = this.#history.undo(this.#graph.toJSON());
244
+ if (!prev) { this.toast('Nothing to undo', 'info'); return; }
245
+ this.#restoreGraph(prev);
246
+ this.toast('Undo', 'info');
247
+ }
248
+
249
+ redo() {
250
+ const next = this.#history.redo(this.#graph.toJSON());
251
+ if (!next) { this.toast('Nothing to redo', 'info'); return; }
252
+ this.#restoreGraph(next);
253
+ this.toast('Redo', 'info');
254
+ }
255
+
256
+ #restoreGraph(graphJSON) {
257
+ this.#historyRestoring = true;
258
+ this.#graph.fromJSON(graphJSON);
259
+ this.#mapBuilder.setGraph(this.#graph);
260
+ this.#bindMapEvents();
261
+ this.#savant.setNode(null);
262
+ this.#closePropsPanel();
263
+ this.markDirty();
264
+ this.#historyRestoring = false;
265
+ }
266
+
267
+ // ── Copy / Cut / Paste ────────────────────────────────────────────
268
+
269
+ copySelected() {
270
+ const node = this.#mapBuilder?.selectedNode;
271
+ if (!node) { this.toast('Select a node first', 'info'); return; }
272
+ this.#clipboard = { node: node.toJSON() };
273
+ this.toast(`Copied: ${node.label.value}`, 'info');
274
+ }
275
+
276
+ cutSelected() {
277
+ const node = this.#mapBuilder?.selectedNode;
278
+ if (!node) { this.toast('Select a node first', 'info'); return; }
279
+ this.#clipboard = { node: node.toJSON() };
280
+ this.#history.record(this.#graph.toJSON());
281
+ this.#graph.removeNode(node.id);
282
+ this.#closePropsPanel();
283
+ this.#savant.setNode(null);
284
+ this.markDirty();
285
+ this.toast(`Cut: ${this.#clipboard.node.label}`, 'info');
286
+ }
287
+
288
+ paste() {
289
+ if (!this.#clipboard?.node) { this.toast('Nothing to paste', 'info'); return; }
290
+ this.#history.record(this.#graph.toJSON());
291
+ const src = this.#clipboard.node;
292
+ // Paste with 80px offset; clear isEntry so we don't accidentally clone the lobby
293
+ this.#graph.addNode({
294
+ ...src,
295
+ id: undefined,
296
+ x: (src.x ?? 200) + 80,
297
+ y: (src.y ?? 200) + 80,
298
+ label: src.label + ' (copy)',
299
+ meta: { ...src.meta, isEntry: false },
300
+ });
301
+ this.markDirty();
302
+ }
303
+
304
+ // ── Dirty tracking ─────────────────────────────────────────────────
305
+
306
+ markDirty() {
307
+ this.#isDirty = true;
308
+ const btn = document.getElementById('btn-save');
309
+ btn.innerHTML = buttonLabel('record-circle', 'Save');
310
+ btn.classList.add('primary');
311
+ this.emit('dirty:changed', true);
312
+ this.#scheduleAutoSave();
313
+ }
314
+
315
+ markClean() {
316
+ this.#isDirty = false;
317
+ const btn = document.getElementById('btn-save');
318
+ btn.innerHTML = buttonLabel('floppy', 'Save');
319
+ btn.classList.remove('primary');
320
+ this.emit('dirty:changed', false);
321
+ }
322
+
323
+ // ── View ──────────────────────────────────────────────────────────
324
+
325
+ fitView() { this.#mapBuilder?.fitView(); }
326
+
327
+ /** Export current map as MCP JSON (addNode + addEdge commands) and copy to clipboard. */
328
+ #exportMapMcp() {
329
+ const nodes = [...this.#graph.nodes.values()];
330
+ const edges = [...this.#graph.edges.values()];
331
+ const cmds = [];
332
+
333
+ // addNode commands
334
+ for (const n of nodes) {
335
+ const cmd = {
336
+ cmd: 'addNode',
337
+ type: n.type ?? 'room',
338
+ label: n.label?.value ?? n.label ?? n.id,
339
+ };
340
+ if (n.meta?.isEntry) cmd.entry = true;
341
+ if (n.template) cmd.template = n.template;
342
+ cmds.push(cmd);
343
+ }
344
+
345
+ // addEdge commands
346
+ for (const e of edges) {
347
+ const from = nodes.find(n => n.id === e.fromId);
348
+ const to = nodes.find(n => n.id === e.toId);
349
+ if (!from || !to) continue;
350
+ const cmd = {
351
+ cmd: 'addEdge',
352
+ from: from.label?.value ?? from.label ?? from.id,
353
+ to: to.label?.value ?? to.label ?? to.id,
354
+ };
355
+ if (e.label?.value) cmd.label = e.label.value;
356
+ if (e.condition?.value) cmd.condition = e.condition.value;
357
+ cmds.push(cmd);
358
+ }
359
+
360
+ const json = JSON.stringify(cmds, null, 2);
361
+ navigator.clipboard.writeText(json).then(() => {
362
+ this.toast(`Copied ${cmds.length} MCP commands to clipboard`, 'info');
363
+ }).catch(() => {
364
+ const win = window.open('', '_blank', 'width=700,height=520');
365
+ if (win) { win.document.write(`<pre style="font:13px monospace;padding:16px">${json.replace(/</g,'&lt;')}</pre>`); }
366
+ });
367
+ }
368
+
369
+ toast(msg, type = 'info') {
370
+ const area = document.getElementById('toast-area');
371
+ const el = document.createElement('div');
372
+ el.className = `ide-toast ${type}`;
373
+ el.textContent = msg;
374
+ area.appendChild(el);
375
+ setTimeout(() => el.remove(), 3400);
376
+ }
377
+
378
+ // ── Map event wiring ──────────────────────────────────────────────
379
+
380
+ #bindMapEvents() {
381
+ this.#scope.scope('map').dispose();
382
+ const ms = this.#scope.scope('map');
383
+
384
+ // Record snapshot before map-initiated mutations
385
+ ms.add(this.#mapBuilder.on('beforeChange', () => {
386
+ this.#history.record(this.#graph.toJSON());
387
+ }));
388
+
389
+ // Track edge selection
390
+ ms.add(this.#mapBuilder.on('edgeSelected', edge => {
391
+ this.#selectedEdge = edge;
392
+ this.#openEdgePropsPanel(edge);
393
+ }));
394
+ ms.add(this.#mapBuilder.on('edgeDeselected', () => {
395
+ this.#selectedEdge = null;
396
+ this.#closePropsPanel();
397
+ }));
398
+
399
+ ms.add(this.#mapBuilder.on('nodeSelected', node => {
400
+ this.#savant.setNode(node);
401
+ this.#openPropsPanel(node);
402
+ this.markDirty();
403
+ }));
404
+
405
+ ms.add(this.#mapBuilder.on('nodeDeselected', () => {
406
+ this.#closePropsPanel();
407
+ this.#savant.setNode(null);
408
+ }));
409
+
410
+ // Auto-select a node immediately after it is added to the graph
411
+ ms.add(this.#graph.on('nodeAdded', node => {
412
+ this.#mapBuilder.selectNode(node.id);
413
+ }));
414
+
415
+ ms.add(this.#mapBuilder.on('nodeDoubleClicked', node => {
416
+ const label = prompt('Rename room:', node.label.value);
417
+ if (label !== null) {
418
+ const trimmed = label.trim();
419
+ if (trimmed) {
420
+ const duplicate = [...this.#graph.nodes.values()].find(
421
+ n => n.id !== node.id && n.label.value.trim().toLowerCase() === trimmed.toLowerCase()
422
+ );
423
+ if (duplicate) {
424
+ this.toast(`"${trimmed}" is already used by another room`, 'warning');
425
+ return;
426
+ }
427
+ }
428
+ this.#history.record(this.#graph.toJSON());
429
+ node.label.value = trimmed || node.label.value;
430
+ this.markDirty();
431
+ }
432
+ }));
433
+
434
+ ms.add(this.#mapBuilder.on('edgeCreated', () => this.markDirty()));
435
+
436
+ ms.add(this.#mapBuilder.on('contextMenu', ({ node, x, y }) => {
437
+ this.#showContextMenu(node, x, y);
438
+ }));
439
+ }
440
+
441
+ // ── Properties panel ─────────────────────────────────────────────
442
+
443
+ #openPropsPanel(node) {
444
+ this.#propsScope.dispose();
445
+ const panel = document.getElementById('props-panel');
446
+ panel.classList.add('open');
447
+ document.getElementById('props-title').textContent = node.label.value;
448
+ this.#renderPropsBody(node);
449
+
450
+ // Push: re-render only the things list when node.things changes
451
+ if (node.things) {
452
+ this.#propsScope.add(
453
+ node.things.subscribe(() => this.#renderThingsList(node), false)
454
+ );
455
+ }
456
+
457
+ this.emit('props:opened', node);
458
+ }
459
+
460
+ #closePropsPanel() {
461
+ this.#propsScope.dispose();
462
+ document.getElementById('props-panel').classList.remove('open');
463
+ this.emit('props:closed');
464
+ }
465
+
466
+ #openEdgePropsPanel(edge) {
467
+ this.#propsScope.dispose();
468
+ const fromNode = this.#graph.nodes.get(edge.fromId);
469
+ const toNode = this.#graph.nodes.get(edge.toId);
470
+ const isDiamond = fromNode?.type === 'diamond';
471
+ const panel = document.getElementById('props-panel');
472
+ panel.classList.add('open');
473
+ document.getElementById('props-title').textContent = 'Connector';
474
+ const body = document.getElementById('props-body');
475
+ body.innerHTML = `
476
+ <div class="props-section">
477
+ <div class="props-section-title">Connection</div>
478
+ <div class="props-field">
479
+ <label class="props-label">From</label>
480
+ <div class="props-value">${escAttr(fromNode?.label.value ?? edge.fromId)}</div>
481
+ </div>
482
+ <div class="props-field">
483
+ <label class="props-label">To</label>
484
+ <div class="props-value">${escAttr(toNode?.label.value ?? edge.toId)}</div>
485
+ </div>
486
+ <div class="props-field">
487
+ <label class="props-label">Label</label>
488
+ <input class="props-input" id="edge-label" value="${escAttr(edge.label.value)}">
489
+ </div>
490
+ ${isDiamond ? `
491
+ <div class="props-field">
492
+ <label class="props-label">Condition <span class="props-hint">(JS expression)</span></label>
493
+ <input class="props-input" id="edge-condition" value="${escAttr(edge.condition.value)}" placeholder="inventory.get('key') === 'value'">
494
+ </div>` : ''}
495
+ </div>`;
496
+
497
+ document.getElementById('edge-label').addEventListener('input', e => {
498
+ edge.label.value = e.target.value;
499
+ this.markDirty();
500
+ });
501
+ if (isDiamond) {
502
+ document.getElementById('edge-condition').addEventListener('input', e => {
503
+ edge.condition.value = e.target.value;
504
+ this.markDirty();
505
+ });
506
+ }
507
+ }
508
+
509
+ #renderPropsBody(node) {
510
+ const body = document.getElementById('props-body');
511
+ body.innerHTML = `
512
+ <div class="props-section">
513
+ <div class="props-section-title">Identity</div>
514
+ <div class="props-field">
515
+ <label class="props-label">Label</label>
516
+ <input class="props-input" id="prop-label" value="${escAttr(node.label.value)}">
517
+ </div>
518
+ <div class="props-field">
519
+ <label class="props-label">Type</label>
520
+ <select class="props-select" id="prop-type">
521
+ <option value="room" ${node.type === 'room' ? 'selected' : ''}> Room</option>
522
+ <option value="diamond" ${node.type === 'diamond' ? 'selected' : ''}>Diamond (Logic Joint)</option>
523
+ <option value="terminal" ${node.type === 'terminal' ? 'selected' : ''}>Terminal (End State)</option>
524
+ </select>
525
+ </div>
526
+ <div class="props-field">
527
+ <label class="props-label">
528
+ <input type="checkbox" id="prop-entry" ${node.meta?.isEntry ? 'checked' : ''}> Entry point (Lobby)
529
+ </label>
530
+ </div>
531
+ </div>
532
+ ${node.type === 'room' && node.meta?.isEntry ? `
533
+ <div class="props-section">
534
+ <div class="props-section-title">Lobby Meta</div>
535
+ <div class="props-field">
536
+ <label class="props-label">App Name</label>
537
+ <input class="props-input" id="prop-meta-appName" value="${escAttr(node.meta?.appName ?? '')}">
538
+ </div>
539
+ <div class="props-field">
540
+ <label class="props-label">Tagline</label>
541
+ <input class="props-input" id="prop-meta-tagline" value="${escAttr(node.meta?.tagline ?? '')}">
542
+ </div>
543
+ <div class="props-field">
544
+ <label class="props-label">Icon (Bootstrap icon name)</label>
545
+ <input class="props-input" id="prop-meta-icon" value="${escAttr(node.meta?.icon ?? '')}" placeholder="shield-lock">
546
+ </div>
547
+ </div>` : ''}
548
+ ${node.type === 'terminal' ? this.#terminalMetaHTML(node) : ''}
549
+ ${node.type === 'room' ? this.#thingsHTML() : ''}`;
550
+
551
+ // Live-update handlers
552
+ document.getElementById('prop-label').addEventListener('input', e => {
553
+ const newLabel = e.target.value.trim();
554
+ // Duplicate room name guard — warn but don't block typing
555
+ if (newLabel) {
556
+ const duplicate = [...this.#graph.nodes.values()].find(
557
+ n => n.id !== node.id && n.label.value.trim().toLowerCase() === newLabel.toLowerCase()
558
+ );
559
+ if (duplicate) {
560
+ e.target.classList.add('is-invalid');
561
+ let errEl = e.target.nextElementSibling;
562
+ if (!errEl?.classList.contains('invalid-feedback')) {
563
+ errEl = document.createElement('div');
564
+ errEl.className = 'invalid-feedback';
565
+ e.target.insertAdjacentElement('afterend', errEl);
566
+ }
567
+ errEl.textContent = `Another room is already named "${duplicate.label.value}".`;
568
+ } else {
569
+ e.target.classList.remove('is-invalid');
570
+ const errEl = e.target.nextElementSibling;
571
+ if (errEl?.classList.contains('invalid-feedback')) errEl.textContent = '';
572
+ }
573
+ }
574
+ node.label.value = e.target.value;
575
+ document.getElementById('props-title').textContent = e.target.value;
576
+ this.markDirty();
577
+ });
578
+
579
+ document.getElementById('prop-type').addEventListener('change', () => {
580
+ this.toast('Type changes require removing and re-adding the node', 'info');
581
+ });
582
+
583
+ document.getElementById('prop-entry').addEventListener('change', e => {
584
+ if (e.target.checked) this.#graph.setEntry(node.id);
585
+ else node.meta.isEntry = false;
586
+ this.markDirty();
587
+ // Re-render so Lobby Meta section appears/disappears
588
+ this.#renderPropsBody(node);
589
+ });
590
+
591
+ if (node.type === 'terminal') {
592
+ ['message', 'nextLabel', 'nextHref'].forEach(k => {
593
+ const el = document.getElementById(`prop-meta-${k}`);
594
+ if (el) el.addEventListener('input', ev => { node.meta[k] = ev.target.value; this.markDirty(); });
595
+ });
596
+ }
597
+
598
+ if (node.type === 'room') {
599
+ ['appName', 'tagline', 'icon'].forEach(k => {
600
+ const el = document.getElementById(`prop-meta-${k}`);
601
+ if (el) el.addEventListener('input', ev => {
602
+ if (!node.meta) node.meta = {};
603
+ node.meta[k] = ev.target.value;
604
+ this.markDirty();
605
+ });
606
+ });
607
+ }
608
+
609
+ if (node.type === 'room') {
610
+ this.#renderThingsList(node); // initial population
611
+ this.#wireThingsPanel(node); // wire controls once
612
+ }
613
+ }
614
+
615
+ #terminalMetaHTML(node) {
616
+ const m = node.meta ?? {};
617
+ return `
618
+ <div class="props-section">
619
+ <div class="props-section-title">Terminal Metadata</div>
620
+ <div class="props-field">
621
+ <label class="props-label">Message</label>
622
+ <input class="props-input" id="prop-meta-message" value="${escAttr(m.message ?? '')}">
623
+ </div>
624
+ <div class="props-field">
625
+ <label class="props-label">Button Label</label>
626
+ <input class="props-input" id="prop-meta-nextLabel" value="${escAttr(m.nextLabel ?? '')}">
627
+ </div>
628
+ <div class="props-field">
629
+ <label class="props-label">Button URL</label>
630
+ <input class="props-input" id="prop-meta-nextHref" value="${escAttr(m.nextHref ?? '')}">
631
+ </div>
632
+ </div>`;
633
+ }
634
+
635
+ // ── Things panel ──────────────────────────────────────────────
636
+
637
+ /** Static scaffold: just the section shell with the add controls. */
638
+ #thingsHTML() {
639
+ const typeOptions = Object.entries(THING_LIBRARY)
640
+ .map(([k, v]) => `<option value="${escAttr(k)}">${escHtml(v.label)}</option>`)
641
+ .join('');
642
+ return `
643
+ <div class="props-section" id="things-section">
644
+ <div class="props-section-title">Things
645
+ <select id="thing-type-select" class="things-add-select">${typeOptions}</select>
646
+ <button id="thing-add-btn" class="things-add-btn">+ Add</button>
647
+ </div>
648
+ <div id="things-list"></div>
649
+ </div>`;
650
+ }
651
+
652
+ /** Render the thing cards into #things-list. Called by the push subscription. */
653
+ #renderThingsList(node) {
654
+ const list = document.getElementById('things-list');
655
+ if (!list) return;
656
+ const things = node.things.peek();
657
+ if (!things.length) {
658
+ list.innerHTML = '<div class="things-empty">No things yet</div>';
659
+ return;
660
+ }
661
+ list.innerHTML = things.map(t => {
662
+ const lib = THING_LIBRARY[t.type] ?? {};
663
+ const icon = lib.icon ?? 'box';
664
+ const col = lib.color ?? 'var(--text-muted)';
665
+ const lbl = lib.label ?? t.type;
666
+ const cfg = Object.entries(t.config ?? {})
667
+ .map(([k, v]) => `<span class="thing-cfg-item"><b>${escHtml(k)}</b>: ${escHtml(String(v))}</span>`)
668
+ .join('');
669
+ return `
670
+ <div class="thing-card" data-thing-id="${escAttr(t.id)}">
671
+ <div class="thing-card-head" style="--thing-color:${col}">
672
+ <i class="bi bi-${icon}"></i>
673
+ <span class="thing-card-label">${escHtml(lbl)}</span>
674
+ <div class="thing-card-actions">
675
+ <button class="thing-btn thing-edit" title="Edit workflows">⚙</button>
676
+ <button class="thing-btn thing-remove" title="Remove">×</button>
677
+ </div>
678
+ </div>
679
+ ${cfg ? `<div class="thing-card-cfg">${cfg}</div>` : ''}
680
+ </div>`;
681
+ }).join('');
682
+ }
683
+
684
+ /** Wire the static add-button + delegated list clicks (called once per panel open). */
685
+ #wireThingsPanel(node) {
686
+ const section = document.getElementById('things-section');
687
+ if (!section) return;
688
+
689
+ section.querySelector('#thing-add-btn').addEventListener('click', () => {
690
+ const sel = section.querySelector('#thing-type-select').value;
691
+ const t = node.addThing({ type: sel });
692
+ this.markDirty();
693
+ this.#openThingConfig(node, t);
694
+ // #renderThingsList will fire via the node.things subscription
695
+ });
696
+
697
+ section.querySelector('#things-list').addEventListener('click', e => {
698
+ const card = e.target.closest('.thing-card');
699
+ if (!card) return;
700
+ const id = card.dataset.thingId;
701
+ if (e.target.classList.contains('thing-remove')) {
702
+ node.removeThing(id);
703
+ this.markDirty();
704
+ // #renderThingsList fires via subscription
705
+ } else if (e.target.classList.contains('thing-edit')) {
706
+ const t = node.things.peek().find(x => x.id === id);
707
+ if (t) this.#openThingConfig(node, t);
708
+ }
709
+ });
710
+ }
711
+
712
+ #openThingConfig(node, thingDef) {
713
+ if (this.#savant) {
714
+ this.#savant.setThing(thingDef, node);
715
+ }
716
+ }
717
+
718
+ // ── Context menu ──────────────────────────────────────────────────
719
+
720
+ #ctxNode = null;
721
+
722
+ #showContextMenu(node, x, y) {
723
+ this.#ctxNode = node;
724
+ const menu = document.getElementById('ctx-menu');
725
+ menu.style.left = `${x}px`;
726
+ menu.style.top = `${y}px`;
727
+ menu.classList.add('open');
728
+ }
729
+
730
+ // ── Recent projects (localStorage) ────────────────────────────────
731
+
732
+ #addRecent(proj) {
733
+ try {
734
+ const list = JSON.parse(localStorage.getItem('pw-recent') ?? '[]');
735
+ const next = [
736
+ { id: proj.id, name: proj.name, description: proj.description ?? '' },
737
+ ...list.filter(p => p.id !== proj.id),
738
+ ].slice(0, 8);
739
+ localStorage.setItem('pw-recent', JSON.stringify(next));
740
+ } catch { /* ignore */ }
741
+ }
742
+
743
+ #getRecent() {
744
+ try { return JSON.parse(localStorage.getItem('pw-recent') ?? '[]'); } catch { return []; }
745
+ }
746
+
747
+ // ── Welcome screen ────────────────────────────────────────────────
748
+
749
+ async #showWelcome() {
750
+ const modal = document.getElementById('welcome-modal');
751
+ if (!modal) return;
752
+
753
+ // Populate recent projects
754
+ const recent = this.#getRecent();
755
+ const recentEl = document.getElementById('welcome-recent-list');
756
+ const recentSection = document.getElementById('welcome-recent-section');
757
+ if (recentEl) {
758
+ if (recent.length) {
759
+ recentSection?.classList.remove('d-none-ish');
760
+ recentEl.innerHTML = recent.map(p => `
761
+ <div class="welcome-recent-item" data-pid="${escAttr(p.id)}">
762
+ <af-icon name="folder2-open"></af-icon>
763
+ <div class="wr-info">
764
+ <div class="wr-name">${escHtml(p.name)}</div>
765
+ ${p.description ? `<div class="wr-desc">${escHtml(p.description)}</div>` : ''}
766
+ </div>
767
+ <small>${escHtml(p.id)}</small>
768
+ </div>`).join('');
769
+ recentEl.querySelectorAll('.welcome-recent-item').forEach(item => {
770
+ item.addEventListener('click', () => {
771
+ this.openProject(item.dataset.pid);
772
+ });
773
+ });
774
+ } else {
775
+ recentSection?.classList.add('d-none-ish');
776
+ }
777
+ }
778
+
779
+ // Populate templates
780
+ const tmplEl = document.getElementById('welcome-template-list');
781
+ if (tmplEl) {
782
+ try {
783
+ const templates = await API.listTemplates();
784
+ tmplEl.innerHTML = templates.slice(0, 6).map(t => `
785
+ <div class="welcome-tmpl-card" data-tid="${escAttr(t.id)}">
786
+ <div class="welcome-tmpl-icon">${renderAfIcon(normalizeIconName(t.icon, 'stars'))}</div>
787
+ <div class="welcome-tmpl-name">${escHtml(t.name)}</div>
788
+ </div>`).join('');
789
+ tmplEl.querySelectorAll('.welcome-tmpl-card').forEach(card => {
790
+ const tid = card.dataset.tid;
791
+ const tmpl = templates.find(t => t.id === tid);
792
+ card.addEventListener('click', () => {
793
+ this.#hideWelcome();
794
+ if (tmpl && this._selectTemplate) {
795
+ this._selectTemplate(tmpl);
796
+ } else {
797
+ document.getElementById('btn-new')?.click();
798
+ }
799
+ });
800
+ });
801
+ } catch { tmplEl.innerHTML = ''; }
802
+ }
803
+
804
+ modal.classList.add('open');
805
+ }
806
+
807
+ #hideWelcome() {
808
+ document.getElementById('welcome-modal')?.classList.remove('open');
809
+ }
810
+
811
+ // ── Toolbar button wiring ─────────────────────────────────────────
812
+
813
+ #bindToolbar() {
814
+ const $ = id => document.getElementById(id);
815
+
816
+ $('btn-save') .addEventListener('click', () => this.saveProject());
817
+ $('btn-generate').addEventListener('click', () => this.generateProject());
818
+ $('btn-fit-view').addEventListener('click', () => this.fitView());
819
+
820
+ // Export map as MCP JSON (addNode + addEdge commands)
821
+ $('btn-export-map')?.addEventListener('click', () => this.#exportMapMcp());
822
+ $('btn-cmd')?.addEventListener('click', () => this.#palette?.open());
823
+
824
+ // Undo / Redo buttons
825
+ $('btn-undo')?.addEventListener('click', () => this.undo());
826
+ $('btn-redo')?.addEventListener('click', () => this.redo());
827
+
828
+ // Preview: generate + open in new tab
829
+ $('btn-preview')?.addEventListener('click', async () => {
830
+ const result = await this.generateProject();
831
+ if (result?.path) window.open(result.path, '_blank');
832
+ });
833
+
834
+ // Reset: wipe all projects + generated files, clear browser state
835
+ $('btn-reset')?.addEventListener('click', async () => {
836
+ const confirmed = window.confirm(
837
+ 'Reset everything?\n\nThis will delete ALL projects and generated files, and clear all browser storage.\n\nThis cannot be undone.'
838
+ );
839
+ if (!confirmed) return;
840
+ try {
841
+ const res = await fetch('/api/reset', { method: 'POST' });
842
+ const data = await res.json();
843
+ if (data.ok) {
844
+ // Clear all browser storage for this origin
845
+ localStorage.clear();
846
+ sessionStorage.clear();
847
+ location.reload();
848
+ } else {
849
+ this.toast('Reset failed: ' + (data.error ?? 'unknown error'), 'error');
850
+ }
851
+ } catch (e) {
852
+ this.toast('Reset failed: ' + e.message, 'error');
853
+ }
854
+ });
855
+
856
+ // ── Save as Template ─────────────────────────────────────────────
857
+ $('btn-save-template')?.addEventListener('click', () => {
858
+ if (!this.#project) { this.toast('No project open', 'error'); return; }
859
+ const p = this.#project;
860
+ $('tmpl-save-name').value = p.name ?? '';
861
+ $('tmpl-save-id').value = p.id ?? '';
862
+ $('tmpl-save-desc').value = p.description ?? '';
863
+ $('tmpl-save-icon').value = p.meta?.icon ?? '';
864
+ $('tmpl-save-category').value = 'custom';
865
+ $('save-template-modal').classList.add('open');
866
+ });
867
+ $('btn-tmpl-save-cancel')?.addEventListener('click', () => $('save-template-modal').classList.remove('open'));
868
+ $('save-template-modal')?.addEventListener('click', e => {
869
+ if (e.target === $('save-template-modal')) $('save-template-modal').classList.remove('open');
870
+ });
871
+
872
+ // Auto-generate id from name
873
+ $('tmpl-save-name')?.addEventListener('input', () => {
874
+ if (!$('tmpl-save-id').dataset.userEdited) {
875
+ $('tmpl-save-id').value = $('tmpl-save-name').value.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
876
+ }
877
+ });
878
+ $('tmpl-save-id')?.addEventListener('input', () => {
879
+ $('tmpl-save-id').dataset.userEdited = $('tmpl-save-id').value ? '1' : '';
880
+ });
881
+
882
+ $('btn-tmpl-save-confirm')?.addEventListener('click', async () => {
883
+ const name = $('tmpl-save-name').value.trim();
884
+ const id = $('tmpl-save-id').value.trim();
885
+ const desc = $('tmpl-save-desc').value.trim();
886
+ const icon = $('tmpl-save-icon').value.trim() || 'stars';
887
+ const category = $('tmpl-save-category').value.trim() || 'custom';
888
+ if (!name || !id) { this.toast('Name and ID required', 'error'); return; }
889
+ try {
890
+ await API.saveTemplate({
891
+ id, name,
892
+ description: desc,
893
+ icon,
894
+ category,
895
+ graph: this.#graph.toJSON(),
896
+ inventory: this.#project?.inventory ?? { schema: {} },
897
+ customActions: this.#project?.customActions ?? {},
898
+ });
899
+ this.toast(`Template "${name}" saved`, 'success');
900
+ $('save-template-modal').classList.remove('open');
901
+ } catch (e) {
902
+ this.toast('Save failed: ' + e.message, 'error');
903
+ }
904
+ });
905
+
906
+ // ── Template gallery / New project modal ─────────────────────────
907
+ let _selectedTemplate = null;
908
+
909
+ const openGallery = async () => {
910
+ $('new-project-modal').classList.add('open');
911
+ $('new-proj-step-gallery').style.display = '';
912
+ $('new-proj-step-name').style.display = 'none';
913
+ $('new-proj-id').value = '';
914
+ $('new-proj-name').value = '';
915
+ _selectedTemplate = null;
916
+ // Load and render templates
917
+ const gallery = $('template-gallery');
918
+ gallery.innerHTML = '<div class="tmpl-loading">Loading…</div>';
919
+ try {
920
+ const templates = await API.listTemplates();
921
+ gallery.innerHTML = '';
922
+ for (const tmpl of templates) {
923
+ const card = document.createElement('div');
924
+ card.className = 'tmpl-card';
925
+ card.dataset.tmplId = tmpl.id;
926
+ const iconName = normalizeIconName(tmpl.icon, 'stars');
927
+ card.innerHTML = `
928
+ <div class="tmpl-card-icon">${renderAfIcon(iconName)}</div>
929
+ <div class="tmpl-card-name">${escHtml(tmpl.name)}</div>
930
+ <div class="tmpl-card-desc">${escHtml(tmpl.description)}</div>`;
931
+ card.addEventListener('click', () => selectTemplate(tmpl));
932
+ gallery.appendChild(card);
933
+ }
934
+ } catch {
935
+ gallery.innerHTML = '<div class="tmpl-loading">Could not load templates.</div>';
936
+ }
937
+ };
938
+
939
+ const selectTemplate = (tmpl) => {
940
+ _selectedTemplate = tmpl;
941
+ $('new-project-modal').classList.add('open');
942
+ $('new-proj-step-gallery').style.display = 'none';
943
+ $('new-proj-step-name').style.display = '';
944
+ $('tmpl-selected-badge').innerHTML =
945
+ `<span class="tmpl-badge">${renderAfIcon(normalizeIconName(tmpl.icon, 'stars'))}<span>${escHtml(tmpl.name)}</span></span>`;
946
+ $('new-proj-name').value = '';
947
+ $('new-proj-id').value = '';
948
+ if ($('new-proj-desc')) $('new-proj-desc').value = '';
949
+ delete $('new-proj-id').dataset.userEdited;
950
+ $('new-proj-name').focus();
951
+ };
952
+ // Expose so #showWelcome can pre-select a template and go straight to step 2
953
+ this._selectTemplate = selectTemplate;
954
+
955
+ $('btn-new').addEventListener('click', openGallery);
956
+
957
+ $('btn-new-cancel').addEventListener('click', () => $('new-project-modal').classList.remove('open'));
958
+ $('btn-new-back')?.addEventListener('click', () => {
959
+ $('new-proj-step-gallery').style.display = '';
960
+ $('new-proj-step-name').style.display = 'none';
961
+ });
962
+ $('new-project-modal').addEventListener('click', e => {
963
+ if (e.target === $('new-project-modal')) $('new-project-modal').classList.remove('open');
964
+ });
965
+
966
+ // Auto-generate ID from name
967
+ $('new-proj-name').addEventListener('input', () => {
968
+ const raw = $('new-proj-name').value.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
969
+ if (!$('new-proj-id').dataset.userEdited) $('new-proj-id').value = raw;
970
+ });
971
+ $('new-proj-id').addEventListener('input', () => {
972
+ $('new-proj-id').dataset.userEdited = $('new-proj-id').value ? '1' : '';
973
+ });
974
+
975
+ $('btn-new-cancel-2')?.addEventListener('click', () => $('new-project-modal').classList.remove('open'));
976
+
977
+ $('btn-new-confirm').addEventListener('click', async () => {
978
+ const rawId = $('new-proj-id').value.trim().replace(/\s+/g,'-').toLowerCase();
979
+ const name = $('new-proj-name').value.trim();
980
+ const desc = $('new-proj-desc')?.value.trim() ?? '';
981
+ if (!rawId || !name) { this.toast('Name and ID are required', 'error'); return; }
982
+
983
+ let projectData = { id: rawId, name, description: desc };
984
+ if (_selectedTemplate && _selectedTemplate.id !== 'blank') {
985
+ try {
986
+ const full = await API.getTemplate(_selectedTemplate.id);
987
+ projectData = { ...full, id: rawId, name,
988
+ description: desc || full.description || '',
989
+ created: undefined, modified: undefined };
990
+ } catch { /* fall through to blank */ }
991
+ }
992
+
993
+ await this.createProject(projectData);
994
+ $('new-project-modal').classList.remove('open');
995
+ });
996
+
997
+ // Project selector
998
+ $('project-select').addEventListener('change', e => {
999
+ if (e.target.value) this.openProject(e.target.value);
1000
+ });
1001
+
1002
+ // Properties panel close
1003
+ $('props-close').addEventListener('click', () => {
1004
+ this.#closePropsPanel();
1005
+ this.#mapBuilder?.deselect();
1006
+ });
1007
+
1008
+ // Context menu actions
1009
+ document.addEventListener('click', () => document.getElementById('ctx-menu').classList.remove('open'));
1010
+ document.getElementById('ctx-menu').querySelectorAll('[data-ctx]').forEach(item => {
1011
+ item.addEventListener('click', () => {
1012
+ if (!this.#ctxNode) return;
1013
+ const action = item.dataset.ctx;
1014
+ if (action === 'set-entry') {
1015
+ this.#history.record(this.#graph.toJSON());
1016
+ this.#graph.setEntry(this.#ctxNode.id);
1017
+ this.markDirty();
1018
+ this.toast('Entry set', 'success');
1019
+ }
1020
+ if (action === 'rename') {
1021
+ const l = prompt('Rename:', this.#ctxNode.label.value);
1022
+ if (l !== null) {
1023
+ this.#history.record(this.#graph.toJSON());
1024
+ this.#ctxNode.label.value = l.trim() || this.#ctxNode.label.value;
1025
+ this.markDirty();
1026
+ }
1027
+ }
1028
+ if (action === 'duplicate') {
1029
+ this.#history.record(this.#graph.toJSON());
1030
+ this.#graph.addNode({
1031
+ ...this.#ctxNode.toJSON(),
1032
+ id: undefined,
1033
+ x: this.#ctxNode.x.value + 80,
1034
+ y: this.#ctxNode.y.value + 80,
1035
+ meta: { ...this.#ctxNode.meta, isEntry: false },
1036
+ });
1037
+ this.markDirty();
1038
+ }
1039
+ if (action === 'copy') { this.#clipboard = { node: this.#ctxNode.toJSON() }; this.toast(`Copied: ${this.#ctxNode.label.value}`, 'info'); }
1040
+ if (action === 'cut') { this.#clipboard = { node: this.#ctxNode.toJSON() }; this.#history.record(this.#graph.toJSON()); this.#graph.removeNode(this.#ctxNode.id); this.#closePropsPanel(); this.#savant.setNode(null); this.markDirty(); this.toast('Cut', 'info'); }
1041
+ if (action === 'delete') {
1042
+ this.#history.record(this.#graph.toJSON());
1043
+ this.#graph.removeNode(this.#ctxNode.id);
1044
+ this.#closePropsPanel();
1045
+ this.#savant.setNode(null);
1046
+ this.markDirty();
1047
+ }
1048
+ this.#ctxNode = null;
1049
+ });
1050
+ });
1051
+
1052
+ // Keyboard shortcuts
1053
+ document.addEventListener('keydown', e => {
1054
+ const el = e.target;
1055
+ const isEditing = el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable;
1056
+
1057
+ // Ctrl+S — save
1058
+ if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); this.saveProject(); return; }
1059
+
1060
+ // Undo/Redo — only when not editing text
1061
+ if (!isEditing) {
1062
+ if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { e.preventDefault(); this.undo(); return; }
1063
+ if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) { e.preventDefault(); this.redo(); return; }
1064
+
1065
+ // Copy / Cut / Paste
1066
+ if ((e.ctrlKey || e.metaKey) && e.key === 'c') { e.preventDefault(); this.copySelected(); return; }
1067
+ if ((e.ctrlKey || e.metaKey) && e.key === 'x') { e.preventDefault(); this.cutSelected(); return; }
1068
+ if ((e.ctrlKey || e.metaKey) && e.key === 'v') { e.preventDefault(); this.paste(); return; }
1069
+
1070
+ // Delete key — remove selected node or edge
1071
+ if (e.key === 'Delete' || e.key === 'Backspace') {
1072
+ const node = this.#mapBuilder?.selectedNode;
1073
+ if (node) {
1074
+ this.#history.record(this.#graph.toJSON());
1075
+ this.#graph.removeNode(node.id);
1076
+ this.#closePropsPanel();
1077
+ this.#savant.setNode(null);
1078
+ this.markDirty();
1079
+ return;
1080
+ }
1081
+ if (this.#selectedEdge) {
1082
+ this.#history.record(this.#graph.toJSON());
1083
+ this.#graph.removeEdge(this.#selectedEdge.id);
1084
+ this.#selectedEdge = null;
1085
+ this.markDirty();
1086
+ return;
1087
+ }
1088
+ }
1089
+ }
1090
+
1091
+ // Tool keyboard shortcuts (always active unless editing)
1092
+ if (!isEditing) {
1093
+ const toolKeys = { v: 'select', s: 'room', d: 'diamond', t: 'terminal', c: 'connect' };
1094
+ const tool = toolKeys[e.key];
1095
+ if (tool) document.querySelector(`.map-tool[data-tool="${tool}"]`)?.click();
1096
+ }
1097
+ });
1098
+
1099
+ // Map tool buttons
1100
+ document.querySelectorAll('.map-tool[data-tool]').forEach(btn => {
1101
+ btn.addEventListener('click', () => {
1102
+ document.querySelectorAll('.map-tool').forEach(b => b.classList.remove('active'));
1103
+ btn.classList.add('active');
1104
+ this.#mapBuilder?.setTool(btn.dataset.tool);
1105
+ });
1106
+ });
1107
+
1108
+ // Welcome modal
1109
+ $('welcome-close')?.addEventListener('click', () => this.#hideWelcome());
1110
+ $('welcome-modal')?.addEventListener('click', e => {
1111
+ if (e.target === $('welcome-modal')) this.#hideWelcome();
1112
+ });
1113
+ $('welcome-new-btn')?.addEventListener('click', () => {
1114
+ this.#hideWelcome();
1115
+ openGallery();
1116
+ });
1117
+ }
1118
+
1119
+ // ── Split pane resize ─────────────────────────────────────────────
1120
+
1121
+ #initSplitResize() {
1122
+ const handle = document.getElementById('split-handle');
1123
+ const body = document.getElementById('ide-body');
1124
+ let frac = 0.6; // top-pane fraction of available height
1125
+ let dragging = false, startY = 0, startFrac = 0;
1126
+
1127
+ const applyFrac = () => {
1128
+ const total = body.clientHeight - 8;
1129
+ if (total <= 0) return;
1130
+ const topPx = Math.round(total * frac);
1131
+ const botPx = total - topPx;
1132
+ if (topPx < 150 || botPx < 120) return;
1133
+ body.style.gridTemplateRows = `${topPx}px 8px ${botPx}px`;
1134
+ };
1135
+
1136
+ handle.addEventListener('mousedown', e => {
1137
+ dragging = true;
1138
+ startY = e.clientY;
1139
+ startFrac = frac;
1140
+ document.body.style.cursor = 'ns-resize';
1141
+ document.body.style.userSelect = 'none';
1142
+ e.preventDefault();
1143
+ });
1144
+ document.addEventListener('mousemove', e => {
1145
+ if (!dragging) return;
1146
+ const total = body.clientHeight - 8;
1147
+ if (total <= 0) return;
1148
+ frac = Math.min(
1149
+ Math.max(startFrac + (e.clientY - startY) / total, 150 / total),
1150
+ 1 - 120 / total,
1151
+ );
1152
+ applyFrac();
1153
+ });
1154
+ document.addEventListener('mouseup', () => {
1155
+ if (dragging) { dragging = false; document.body.style.cursor = ''; document.body.style.userSelect = ''; }
1156
+ });
1157
+ window.addEventListener('resize', applyFrac);
1158
+ applyFrac(); // Sync grid with frac on init so there's no mismatch on first drag
1159
+ }
1160
+
1161
+ // ── Savant column resize ─────────────────────────────────────────
1162
+
1163
+ #initSavantResize() {
1164
+ const body = document.getElementById('savant-body');
1165
+ if (!body) return;
1166
+ let col1 = 160, col2 = 220, col3 = 260;
1167
+
1168
+ const updateCols = () => {
1169
+ body.style.gridTemplateColumns = `${col1}px 4px ${col2}px 4px 1fr 4px ${col3}px`;
1170
+ };
1171
+
1172
+ // direction: 1 = dragging right increases column (default), -1 = reversed (chat pane)
1173
+ const setupHandle = (id, getV, setV, direction = 1) => {
1174
+ const handle = document.getElementById(id);
1175
+ if (!handle) return;
1176
+ let dragging = false, startX = 0, startV = 0;
1177
+ handle.addEventListener('mousedown', e => {
1178
+ dragging = true; startX = e.clientX; startV = getV();
1179
+ document.body.style.cssText += ';cursor:ew-resize;user-select:none';
1180
+ e.preventDefault();
1181
+ });
1182
+ document.addEventListener('mousemove', e => {
1183
+ if (!dragging) return;
1184
+ setV(Math.max(80, startV + direction * (e.clientX - startX)));
1185
+ updateCols();
1186
+ });
1187
+ document.addEventListener('mouseup', () => {
1188
+ if (dragging) { dragging = false; document.body.style.cursor = ''; document.body.style.userSelect = ''; }
1189
+ });
1190
+ };
1191
+
1192
+ setupHandle('aw-h1', () => col1, v => { col1 = v; });
1193
+ setupHandle('aw-h2', () => col2, v => { col2 = v; });
1194
+ // aw-h3 is left of chat pane: drag right → shrink chat, drag left → grow chat
1195
+ setupHandle('aw-h3', () => col3, v => { col3 = v; }, -1);
1196
+ }
1197
+
1198
+ // ── Savant init ──────────────────────────────────────────────────
1199
+
1200
+ #initSavant() {
1201
+ const container = document.getElementById('savant-pane');
1202
+ this.#savant = new Savant(container);
1203
+
1204
+ this.#savant.on('toast', ({ msg, type }) => this.toast(msg, type));
1205
+ this.#savant.on('customActionsChanged', () => this.markDirty());
1206
+ this.#savant.on('payload:changed', () => this.markDirty());
1207
+
1208
+ this.#savant.on('needNodeOptions', ({ select, currentValue }) => {
1209
+ select.innerHTML = '<option value="">— target room —</option>';
1210
+ for (const node of this.#graph.nodes.values()) {
1211
+ if (node.type === NodeType.DIAMOND) continue;
1212
+ const opt = document.createElement('option');
1213
+ opt.value = node.id;
1214
+ opt.textContent = `${node.label.value} (${node.id})`;
1215
+ if (node.id === currentValue) opt.selected = true;
1216
+ select.appendChild(opt);
1217
+ }
1218
+ });
1219
+
1220
+ this.#savant.on('needInventoryKeys', ({ select, currentValue }) => {
1221
+ // Preserve the placeholder option
1222
+ const existing = [...select.options].map(o => o.value);
1223
+ const schema = this.#project?.inventory?.schema ?? {};
1224
+ for (const key of Object.keys(schema)) {
1225
+ if (!existing.includes(key)) {
1226
+ const opt = document.createElement('option');
1227
+ opt.value = key;
1228
+ opt.textContent = key;
1229
+ select.appendChild(opt);
1230
+ }
1231
+ }
1232
+ // Also allow custom key not in schema
1233
+ if (currentValue && !Object.keys(schema).includes(currentValue)) {
1234
+ const opt = document.createElement('option');
1235
+ opt.value = currentValue;
1236
+ opt.textContent = currentValue;
1237
+ select.appendChild(opt);
1238
+ }
1239
+ if (currentValue) select.value = currentValue;
1240
+ });
1241
+
1242
+ // ── AI Chat command executor ──────────────────────────────────────
1243
+ this.#savant.setChatExecutor(commands => {
1244
+ try {
1245
+ this.#history.record(this.#graph.toJSON());
1246
+ // Build a label→node map for resolution
1247
+ const resolveNode = (ref) => {
1248
+ if (!ref) return null;
1249
+ // Try exact ID first
1250
+ const byId = this.#graph.nodes.get(ref);
1251
+ if (byId) return byId;
1252
+ // Try case-insensitive label
1253
+ const lower = String(ref).toLowerCase();
1254
+ for (const n of this.#graph.nodes.values()) {
1255
+ if (n.label.value.toLowerCase() === lower) return n;
1256
+ }
1257
+ return null;
1258
+ };
1259
+ // First pass: collect addNode temp-id map (labels assigned in this batch)
1260
+ const created = new Map(); // label → GraphNode
1261
+ for (const cmd of commands) {
1262
+ if (cmd.cmd === 'addNode') {
1263
+ const x = cmd.x ?? (200 + Math.random() * 400);
1264
+ const y = cmd.y ?? (200 + Math.random() * 300);
1265
+ const node = this.#graph.addNode({ type: cmd.type ?? 'room', label: cmd.label ?? 'Room', x, y });
1266
+ if (cmd.entry) this.#graph.setEntry(node.id);
1267
+ created.set((cmd.label ?? '').toLowerCase(), node);
1268
+ }
1269
+ }
1270
+ // Second pass: edges and steps
1271
+ for (const cmd of commands) {
1272
+ if (cmd.cmd === 'addNode') continue; // already done
1273
+ if (cmd.cmd === 'addEdge') {
1274
+ const fromNode = created.get((cmd.from ?? '').toLowerCase()) ?? resolveNode(cmd.from);
1275
+ const toNode = created.get((cmd.to ?? '').toLowerCase()) ?? resolveNode(cmd.to);
1276
+ if (!fromNode || !toNode) continue;
1277
+ const edge = this.#graph.addEdge(fromNode.id, toNode.id);
1278
+ if (cmd.label) edge.label.value = cmd.label;
1279
+ if (cmd.condition) edge.condition.value = cmd.condition;
1280
+ } else if (cmd.cmd === 'addStep') {
1281
+ const node = created.get((cmd.node ?? '').toLowerCase()) ?? resolveNode(cmd.node);
1282
+ if (!node) throw new Error(`addStep: node "${cmd.node}" not found`);
1283
+ if (!cmd.step) continue;
1284
+ const event = cmd.event ?? 'onEnter';
1285
+ node.addStep(event, cmd.step);
1286
+ } else if (cmd.cmd === 'setLabel') {
1287
+ const node = created.get((cmd.node ?? '').toLowerCase()) ?? resolveNode(cmd.node);
1288
+ if (node && cmd.label) node.label.value = cmd.label;
1289
+ } else if (cmd.cmd === 'setEntry') {
1290
+ const node = created.get((cmd.node ?? '').toLowerCase()) ?? resolveNode(cmd.node);
1291
+ if (node) this.#graph.setEntry(node.id);
1292
+ }
1293
+ }
1294
+ this.markDirty();
1295
+ return { ok: true };
1296
+ } catch (e) {
1297
+ return { error: e.message };
1298
+ }
1299
+ });
1300
+ }
1301
+
1302
+ // ── Command palette init ──────────────────────────────────────────
1303
+
1304
+ #initPalette() {
1305
+ this.#palette = new CommandPalette(this);
1306
+ for (const cmd of ALL_COMMANDS) this.registerCommand(cmd);
1307
+ this.#palette.registerAll(this.listCommands());
1308
+ this.emit('palette:ready', this.#palette);
1309
+ }
1310
+
1311
+ // ── Boot ──────────────────────────────────────────────────────────
1312
+
1313
+ async boot() {
1314
+ this.#initSavant();
1315
+ this.use(ActionsPlugin);
1316
+ this.#initSplitResize();
1317
+ this.#initSavantResize();
1318
+ this.#initPalette();
1319
+ this.#bindToolbar();
1320
+
1321
+ // Initial map canvas — the <undercity-map> element is already in the DOM
1322
+ this.#mapBuilder = document.getElementById('undercity-map');
1323
+ this.#mapBuilder.setGraph(this.#graph);
1324
+ this.#bindMapEvents();
1325
+
1326
+ // Load projects
1327
+ await this.loadProjectList();
1328
+
1329
+ // Re-open last project, or show welcome screen
1330
+ const lastId = localStorage.getItem('pw-last-project');
1331
+ const sel = document.getElementById('project-select');
1332
+ const hasLast = lastId && sel.querySelector(`option[value="${lastId}"]`);
1333
+
1334
+ if (hasLast) {
1335
+ sel.value = lastId;
1336
+ await this.openProject(lastId);
1337
+ } else {
1338
+ this.#showWelcome();
1339
+ }
1340
+
1341
+ // Expose for AI console backwards-compat
1342
+ window.currentProject = this.#project;
1343
+ window.ideSavant = this.#savant;
1344
+ window.dispatchEvent(new CustomEvent('savant-ready', { detail: this.#savant }));
1345
+
1346
+ this.emit('ready', this);
1347
+ return this;
1348
+ }
1349
+ }
1350
+
1351
+ // ── Helpers ───────────────────────────────────────────────────────────────────
1352
+
1353
+ function escAttr(str) {
1354
+ return String(str ?? '').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1355
+ }
1356
+
1357
+ function escHtml(str) {
1358
+ return String(str ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1359
+ }
1360
+
1361
+ function buttonLabel(iconName, label) {
1362
+ return `${renderAfIcon(iconName)}<span>${escHtml(label)}</span>`;
1363
+ }
1364
+
1365
+ // ── Boot ─────────────────────────────────────────────────────────────────────
1366
+
1367
+ export const app = new App();
1368
+
1369
+ app.boot().catch(err => {
1370
+ console.error('[Undercity] Boot error:', err);
1371
+ document.getElementById('toast-area')?.insertAdjacentHTML('beforeend',
1372
+ `<div class="ide-toast error">Boot error: ${err.message}</div>`);
1373
+ });
1374
+
1375
+ export default app;