@dfosco/storyboard 0.7.1 → 0.9.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 (323) hide show
  1. package/dist/storyboard-ui.css +1 -1
  2. package/dist/storyboard-ui.js +14128 -10953
  3. package/dist/storyboard-ui.js.map +1 -1
  4. package/dist/tailwind.css +1 -1
  5. package/dist/tiny-canvas.css +1 -1
  6. package/package.json +138 -128
  7. package/scaffold/AGENTS.md +9 -7
  8. package/scaffold/README.md +18 -0
  9. package/scaffold/deploy.yml +21 -0
  10. package/scaffold/gitignore +8 -14
  11. package/scaffold/preview.yml +40 -0
  12. package/scaffold/{manifest.json → scaffold.config.json} +73 -3
  13. package/scaffold/scripts/aggregate-artifacts.mjs +257 -0
  14. package/scaffold/skills/canvas/SKILL.md +1 -1
  15. package/scaffold/skills/migrate-0.5.0/SKILL.md +1 -1
  16. package/scaffold/skills/ship/SKILL.md +2 -2
  17. package/scaffold/skills/worktree/SKILL.md +1 -1
  18. package/scaffold/src/_prototype.jsx +17 -0
  19. package/scaffold/src/_story.jsx +13 -0
  20. package/scaffold/src/library/README.md +34 -0
  21. package/scaffold/src/library/_app.jsx +41 -0
  22. package/scaffold/src/library/_app.module.css +2 -0
  23. package/scaffold/src/library/create.jsx +10 -0
  24. package/scaffold/src/library/index.jsx +14 -0
  25. package/scaffold/src/library/mount.jsx +51 -0
  26. package/scaffold/src/library/prototypes-entry.jsx +57 -0
  27. package/scaffold/src/library/routes.jsx +205 -0
  28. package/scaffold/src/library/viewfinder.jsx +13 -0
  29. package/scaffold/src/library/workspace.jsx +17 -0
  30. package/scripts/aggregate-artifacts.mjs +257 -0
  31. package/src/canvas/style.css +16 -16
  32. package/src/core/artifact/operations.js +54 -15
  33. package/src/core/artifact/schemas/canvas.schema.json +7 -1
  34. package/src/core/autosync/server.test.js +1 -1
  35. package/src/core/canvas/hot-pool.js +21 -33
  36. package/src/core/canvas/materializer.js +39 -13
  37. package/src/core/canvas/selectedWidgets.js +1 -1
  38. package/src/core/canvas/server.js +376 -100
  39. package/src/core/canvas/server.test.js +472 -2
  40. package/src/core/canvas/terminal-server.js +144 -57
  41. package/src/core/canvas/undoRedo.js +174 -0
  42. package/src/core/canvas/undoRedo.test.js +250 -0
  43. package/src/core/cli/artifact.js +2 -0
  44. package/src/core/cli/buildManifest.js +34 -0
  45. package/src/core/cli/canvasRedo.js +44 -0
  46. package/src/core/cli/canvasUndo.js +40 -0
  47. package/src/core/cli/create.js +151 -23
  48. package/src/core/cli/dev.js +23 -35
  49. package/src/core/cli/index.js +21 -0
  50. package/src/core/cli/schemas.js +8 -4
  51. package/src/core/cli/serverUrl.js +24 -0
  52. package/src/core/cli/setup.js +14 -25
  53. package/src/core/cli/terminal-welcome.js +12 -6
  54. package/src/core/cli/updateVersion.js +42 -2
  55. package/src/core/cli/workleaf.js +140 -0
  56. package/src/core/comments/api.js +1 -1
  57. package/src/core/comments/queries.js +3 -0
  58. package/src/core/comments/ui/CommentWindow.jsx +3 -2
  59. package/src/core/comments/ui/comment-layout.css +14 -14
  60. package/src/core/data/artifactIndex.js +235 -0
  61. package/src/core/data/artifactIndex.test.js +203 -0
  62. package/src/core/data/artifactManifest.js +290 -0
  63. package/src/core/data/artifactManifest.test.js +207 -0
  64. package/src/core/data/viewfinder.js +47 -7
  65. package/src/core/index.js +24 -1
  66. package/src/core/inspector/highlighter.js +1 -1
  67. package/src/core/inspector/mouseMode.js +2 -2
  68. package/src/core/lib/components/ui/avatar/avatar-fallback.jsx +2 -2
  69. package/src/core/lib/components/ui/avatar/avatar-image.jsx +2 -2
  70. package/src/core/lib/components/ui/avatar/avatar.jsx +2 -2
  71. package/src/core/lib/components/ui/checkbox/checkbox.jsx +12 -9
  72. package/src/core/lib/components/ui/dialog/dialog-content.jsx +45 -31
  73. package/src/core/lib/components/ui/dialog/dialog-trigger.jsx +9 -5
  74. package/src/core/lib/components/ui/dialog/dialog.jsx +12 -3
  75. package/src/core/lib/components/ui/dialog/dialog.module.css +118 -0
  76. package/src/core/lib/components/ui/dialog/index.js +4 -34
  77. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.jsx +3 -3
  78. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.jsx +19 -9
  79. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-content.jsx +8 -8
  80. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.jsx +2 -2
  81. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-group.jsx +2 -2
  82. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-item.jsx +15 -4
  83. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-portal.jsx +2 -2
  84. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.jsx +2 -2
  85. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.jsx +14 -7
  86. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-separator.jsx +2 -2
  87. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.jsx +8 -4
  88. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.jsx +4 -4
  89. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-sub.jsx +2 -2
  90. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-trigger.jsx +8 -2
  91. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu.jsx +2 -2
  92. package/src/core/lib/components/ui/label/label.jsx +1 -2
  93. package/src/core/lib/components/ui/panel/panel-close.jsx +7 -4
  94. package/src/core/lib/components/ui/panel/panel-content.jsx +7 -9
  95. package/src/core/lib/components/ui/panel/panel-title.jsx +3 -3
  96. package/src/core/lib/components/ui/panel/panel.jsx +11 -2
  97. package/src/core/lib/components/ui/separator/separator.jsx +6 -4
  98. package/src/core/lib/components/ui/tooltip/tooltip-content.jsx +7 -6
  99. package/src/core/lib/components/ui/tooltip/tooltip-trigger.jsx +13 -6
  100. package/src/core/lib/components/ui/tooltip/tooltip.jsx +5 -5
  101. package/src/core/modes/modes.css +4 -4
  102. package/src/core/mountStoryboardCore.js +287 -83
  103. package/src/core/mountStoryboardCore.test.js +198 -0
  104. package/src/core/rename-watcher/config.json +1 -1
  105. package/src/core/rename-watcher/watcher.js +330 -37
  106. package/src/core/rename-watcher/watcher.test.js +253 -0
  107. package/src/core/scaffold/fragments.js +287 -0
  108. package/src/core/scaffold/fragments.test.js +401 -0
  109. package/src/core/scaffold/index.js +291 -0
  110. package/src/core/scaffold/migrateLegacyGitignore.js +157 -0
  111. package/src/core/scaffold/scaffold-integration.test.js +247 -0
  112. package/src/core/scaffold.js +4 -97
  113. package/src/core/stores/canvasInteractionStore.js +120 -0
  114. package/src/core/stores/canvasInteractionStore.test.js +126 -0
  115. package/src/core/stores/configSchema.js +64 -8
  116. package/src/core/stores/configSchema.test.js +3 -0
  117. package/src/core/stores/customerModeConfig.js +24 -3
  118. package/src/core/stores/customerModeConfig.test.js +46 -0
  119. package/src/core/stores/presentationStore.js +118 -0
  120. package/src/core/stores/presentationStore.test.js +114 -0
  121. package/src/core/stores/recentArtifacts.js +49 -2
  122. package/src/core/stores/recentArtifacts.test.js +59 -6
  123. package/src/core/stores/themeBootstrap.js +264 -0
  124. package/src/core/stores/themeBootstrap.test.js +211 -0
  125. package/src/core/stores/themeStore.test.js +154 -0
  126. package/src/core/stores/themeStore.ts +235 -220
  127. package/src/core/stores/uiConfig.js +10 -2
  128. package/src/core/styles/generateThemesCss.js +63 -0
  129. package/src/core/styles/tailwind.css +150 -94
  130. package/src/core/styles/workspace-chrome.css +13 -0
  131. package/src/core/tools/handlers/paletteTheme.js +11 -8
  132. package/src/core/ui/ActionMenuButton.jsx +2 -1
  133. package/src/core/ui/AutosyncMenuButton.css +7 -7
  134. package/src/core/ui/BranchSelect.module.css +5 -5
  135. package/src/core/ui/CanvasCreateMenu.jsx +1 -19
  136. package/src/core/ui/ChromeSlot.jsx +92 -0
  137. package/src/core/ui/ChromeSlot.test.jsx +140 -0
  138. package/src/core/ui/CoreUIBar.css +5 -5
  139. package/src/core/ui/CoreUIBar.jsx +170 -142
  140. package/src/core/ui/InspectorPanel.css +5 -5
  141. package/src/core/ui/InspectorPanel.jsx +9 -9
  142. package/src/core/ui/PwaInstallBanner.css +5 -5
  143. package/src/core/ui/PwaInstallBanner.jsx +5 -5
  144. package/src/core/ui/ThemeMenuButton.jsx +45 -30
  145. package/src/core/ui/core-ui-colors.css +28 -66
  146. package/src/core/ui/sidepanel.css +12 -12
  147. package/src/core/vite/server-plugin.js +13 -0
  148. package/src/core/workleaf/cleanup.js +102 -0
  149. package/src/core/workleaf/create.js +199 -0
  150. package/src/core/workleaf/delete.js +101 -0
  151. package/src/core/workleaf/list.js +57 -0
  152. package/src/core/workleaf/merge.js +238 -0
  153. package/src/core/workleaf/paths.js +146 -0
  154. package/src/core/workleaf/paths.test.js +230 -0
  155. package/src/core/workshop/features/createCanvas/CreateCanvasForm.jsx +130 -34
  156. package/src/core/workshop/features/createFlow/server.js +5 -1
  157. package/src/core/workshop/features/createPrototype/server.js +5 -1
  158. package/src/core/workshop/features/templateIndex.js +28 -1
  159. package/src/core/worktree/port.js +48 -5
  160. package/src/core/worktree/port.test.js +42 -0
  161. package/src/internals/ArtifactForm/ArtifactForm.jsx +173 -112
  162. package/src/internals/ArtifactForm/ArtifactForm.module.css +51 -69
  163. package/src/internals/ArtifactForm/artifactSchemas.js +40 -5
  164. package/src/internals/AuthModal/AuthModal.module.css +34 -34
  165. package/src/internals/BaseUiForm/BaseUiForm.jsx +293 -0
  166. package/src/internals/BaseUiForm/BaseUiForm.module.css +542 -0
  167. package/src/internals/BranchBar/BranchBar.module.css +4 -4
  168. package/src/internals/BranchesDropdown/BranchesDropdown.jsx +69 -0
  169. package/src/internals/BranchesDropdown/BranchesDropdown.module.css +84 -0
  170. package/src/internals/CommandPalette/CommandPalette.jsx +41 -29
  171. package/src/internals/CommandPalette/CreateArtifactForm.jsx +150 -0
  172. package/src/internals/CommandPalette/CreateDialog.jsx +20 -110
  173. package/src/internals/CommandPalette/WidgetArtifactDialog.css +16 -16
  174. package/src/internals/CommandPalette/WidgetArtifactDialog.jsx +1 -1
  175. package/src/internals/CommandPalette/command-palette.css +14 -14
  176. package/src/internals/CommandPalette/createHelpers.js +30 -0
  177. package/src/internals/CreatePage/CreatePage.jsx +57 -0
  178. package/src/internals/CreatePage/CreatePage.module.css +34 -0
  179. package/src/internals/Dialog.jsx +51 -0
  180. package/src/internals/FlowError.module.css +5 -5
  181. package/src/internals/PrototypeErrorBoundary.module.css +15 -15
  182. package/src/internals/SimpleWorkspace/SimpleWorkspace.jsx +534 -0
  183. package/src/internals/SimpleWorkspace/SimpleWorkspace.module.css +323 -0
  184. package/src/internals/Tooltip.jsx +39 -0
  185. package/src/internals/Viewfinder.jsx +229 -64
  186. package/src/internals/Viewfinder.module.css +253 -182
  187. package/src/internals/canvas/CanvasControls.module.css +11 -11
  188. package/src/internals/canvas/CanvasPage.bridge.test.jsx +11 -37
  189. package/src/internals/canvas/CanvasPage.jsx +358 -217
  190. package/src/internals/canvas/CanvasPage.module.css +20 -19
  191. package/src/internals/canvas/CanvasPage.multiselect.test.jsx +39 -26
  192. package/src/internals/canvas/CanvasToolbar.module.css +11 -11
  193. package/src/internals/canvas/ConnectorLayer.jsx +39 -2
  194. package/src/internals/canvas/ConnectorLayer.module.css +10 -10
  195. package/src/internals/canvas/PageSelector.jsx +15 -1
  196. package/src/internals/canvas/PageSelector.module.css +41 -27
  197. package/src/internals/canvas/canvasApi.js +40 -5
  198. package/src/internals/canvas/canvasTheme.js +22 -111
  199. package/src/internals/canvas/isolation/createIsolationMiddleware.js +68 -0
  200. package/src/internals/canvas/isolation/mountIsolationApp.jsx +88 -0
  201. package/src/internals/canvas/isolation/prototypeRoutes.jsx +140 -0
  202. package/src/internals/canvas/isolation/prototypesEntry.jsx +28 -0
  203. package/src/internals/canvas/isolation/storiesEntry.jsx +20 -0
  204. package/src/internals/canvas/isolation/storyRoutes.jsx +55 -0
  205. package/src/internals/canvas/useUndoRedo.js +75 -60
  206. package/src/internals/canvas/useUndoRedo.test.js +46 -178
  207. package/src/internals/canvas/widgets/CodePenEmbed.jsx +16 -36
  208. package/src/internals/canvas/widgets/CodePenEmbed.module.css +12 -12
  209. package/src/internals/canvas/widgets/CropOverlay.module.css +23 -23
  210. package/src/internals/canvas/widgets/ExpandedPane.module.css +8 -8
  211. package/src/internals/canvas/widgets/ExpandedPaneTopBar.jsx +1 -1
  212. package/src/internals/canvas/widgets/FigmaEmbed.jsx +15 -36
  213. package/src/internals/canvas/widgets/FigmaEmbed.module.css +15 -15
  214. package/src/internals/canvas/widgets/FrozenTerminalOverlay.jsx +30 -21
  215. package/src/internals/canvas/widgets/FrozenTerminalOverlay.module.css +5 -5
  216. package/src/internals/canvas/widgets/ImageWidget.jsx +7 -2
  217. package/src/internals/canvas/widgets/ImageWidget.module.css +3 -3
  218. package/src/internals/canvas/widgets/LinkPreview.module.css +79 -80
  219. package/src/internals/canvas/widgets/MarkdownBlock.module.css +31 -31
  220. package/src/internals/canvas/widgets/PromptWidget.jsx +2 -16
  221. package/src/internals/canvas/widgets/PromptWidget.module.css +33 -33
  222. package/src/internals/canvas/widgets/PrototypeEmbed.jsx +92 -17
  223. package/src/internals/canvas/widgets/PrototypeEmbed.module.css +58 -56
  224. package/src/internals/canvas/widgets/PrototypeEmbed.test.jsx +118 -4
  225. package/src/internals/canvas/widgets/ResizeHandle.module.css +4 -4
  226. package/src/internals/canvas/widgets/StorySetWidget.jsx +53 -25
  227. package/src/internals/canvas/widgets/StoryWidget.jsx +71 -56
  228. package/src/internals/canvas/widgets/StoryWidget.module.css +22 -20
  229. package/src/internals/canvas/widgets/TerminalReadWidget.jsx +13 -15
  230. package/src/internals/canvas/widgets/TerminalReadWidget.module.css +4 -4
  231. package/src/internals/canvas/widgets/TerminalWidget.jsx +56 -45
  232. package/src/internals/canvas/widgets/TerminalWidget.module.css +10 -10
  233. package/src/internals/canvas/widgets/TilesWidget.module.css +17 -17
  234. package/src/internals/canvas/widgets/WidgetChrome.jsx +1 -1
  235. package/src/internals/canvas/widgets/WidgetChrome.module.css +76 -61
  236. package/src/internals/canvas/widgets/WidgetWrapper.module.css +2 -2
  237. package/src/internals/canvas/widgets/embedOverlay.module.css +14 -4
  238. package/src/internals/canvas/widgets/embedTheme.js +12 -104
  239. package/src/internals/canvas/widgets/expandUtils.js +19 -19
  240. package/src/internals/canvas/widgets/normalizeLegacyEmbedSrc.js +74 -0
  241. package/src/internals/canvas/widgets/widgetConfig.js +5 -5
  242. package/src/internals/context.jsx +22 -1
  243. package/src/internals/context.test.jsx +47 -0
  244. package/src/internals/hooks/useArtifactIndex.js +31 -0
  245. package/src/internals/hooks/useArtifactIndex.test.js +45 -0
  246. package/src/internals/hooks/usePrototypeReloadGuard.js +14 -8
  247. package/src/internals/index.js +6 -0
  248. package/src/internals/loadConsumerWrapper.js +60 -0
  249. package/src/internals/story/ComponentSetPage.jsx +75 -34
  250. package/src/internals/story/ComponentSetPage.module.css +14 -14
  251. package/src/internals/story/StoryPage.jsx +16 -25
  252. package/src/internals/story/StoryPage.module.css +2 -2
  253. package/src/internals/vite/data-plugin.js +317 -149
  254. package/src/internals/vite/data-plugin.test.js +11 -11
  255. package/{scaffold/storyboard.config.json → storyboard.config.json} +25 -0
  256. package/terminal.config.json +1 -1
  257. package/src/core/lib/components/ui/card/card-action.jsx +0 -11
  258. package/src/core/lib/components/ui/card/card-content.jsx +0 -11
  259. package/src/core/lib/components/ui/card/card-description.jsx +0 -11
  260. package/src/core/lib/components/ui/card/card-footer.jsx +0 -11
  261. package/src/core/lib/components/ui/card/card-header.jsx +0 -19
  262. package/src/core/lib/components/ui/card/card-title.jsx +0 -11
  263. package/src/core/lib/components/ui/card/card.jsx +0 -17
  264. package/src/core/lib/components/ui/card/index.js +0 -25
  265. package/src/core/lib/components/ui/collapsible/collapsible-content.jsx +0 -7
  266. package/src/core/lib/components/ui/collapsible/collapsible-trigger.jsx +0 -7
  267. package/src/core/lib/components/ui/collapsible/collapsible.jsx +0 -7
  268. package/src/core/lib/components/ui/collapsible/index.js +0 -13
  269. package/src/core/lib/components/ui/dialog/dialog-close.jsx +0 -7
  270. package/src/core/lib/components/ui/dialog/dialog-description.jsx +0 -15
  271. package/src/core/lib/components/ui/dialog/dialog-footer.jsx +0 -23
  272. package/src/core/lib/components/ui/dialog/dialog-header.jsx +0 -11
  273. package/src/core/lib/components/ui/dialog/dialog-overlay.jsx +0 -15
  274. package/src/core/lib/components/ui/dialog/dialog-portal.jsx +0 -4
  275. package/src/core/lib/components/ui/dialog/dialog-title.jsx +0 -15
  276. package/src/core/lib/components/ui/popover/index.js +0 -28
  277. package/src/core/lib/components/ui/popover/popover-close.jsx +0 -7
  278. package/src/core/lib/components/ui/popover/popover-content.jsx +0 -22
  279. package/src/core/lib/components/ui/popover/popover-description.jsx +0 -11
  280. package/src/core/lib/components/ui/popover/popover-header.jsx +0 -11
  281. package/src/core/lib/components/ui/popover/popover-portal.jsx +0 -4
  282. package/src/core/lib/components/ui/popover/popover-title.jsx +0 -11
  283. package/src/core/lib/components/ui/popover/popover-trigger.jsx +0 -8
  284. package/src/core/lib/components/ui/popover/popover.jsx +0 -4
  285. package/src/core/lib/components/ui/select/index.js +0 -37
  286. package/src/core/lib/components/ui/select/select-content.jsx +0 -30
  287. package/src/core/lib/components/ui/select/select-group-heading.jsx +0 -17
  288. package/src/core/lib/components/ui/select/select-group.jsx +0 -15
  289. package/src/core/lib/components/ui/select/select-item.jsx +0 -26
  290. package/src/core/lib/components/ui/select/select-label.jsx +0 -11
  291. package/src/core/lib/components/ui/select/select-portal.jsx +0 -4
  292. package/src/core/lib/components/ui/select/select-scroll-down-button.jsx +0 -18
  293. package/src/core/lib/components/ui/select/select-scroll-up-button.jsx +0 -18
  294. package/src/core/lib/components/ui/select/select-separator.jsx +0 -15
  295. package/src/core/lib/components/ui/select/select-trigger.jsx +0 -25
  296. package/src/core/lib/components/ui/select/select.jsx +0 -4
  297. package/src/core/lib/components/ui/sheet/index.js +0 -34
  298. package/src/core/lib/components/ui/sheet/sheet-close.jsx +0 -7
  299. package/src/core/lib/components/ui/sheet/sheet-content.jsx +0 -35
  300. package/src/core/lib/components/ui/sheet/sheet-description.jsx +0 -15
  301. package/src/core/lib/components/ui/sheet/sheet-footer.jsx +0 -11
  302. package/src/core/lib/components/ui/sheet/sheet-header.jsx +0 -11
  303. package/src/core/lib/components/ui/sheet/sheet-overlay.jsx +0 -15
  304. package/src/core/lib/components/ui/sheet/sheet-portal.jsx +0 -4
  305. package/src/core/lib/components/ui/sheet/sheet-title.jsx +0 -15
  306. package/src/core/lib/components/ui/sheet/sheet-trigger.jsx +0 -7
  307. package/src/core/lib/components/ui/sheet/sheet.jsx +0 -4
  308. package/src/core/lib/components/ui/toggle/index.js +0 -8
  309. package/src/core/lib/components/ui/toggle/toggle.jsx +0 -36
  310. package/src/core/lib/components/ui/toggle-group/index.js +0 -10
  311. package/src/core/lib/components/ui/toggle-group/toggle-group-item.jsx +0 -29
  312. package/src/core/lib/components/ui/toggle-group/toggle-group.jsx +0 -43
  313. package/src/internals/canvas/StoryErrorBoundary.jsx +0 -47
  314. package/src/internals/canvas/WebGLContextPool.jsx +0 -291
  315. package/src/internals/canvas/WebGLContextPool.test.jsx +0 -165
  316. package/src/internals/canvas/componentIsolate.jsx +0 -144
  317. package/src/internals/canvas/componentSetIsolate.jsx +0 -300
  318. package/src/internals/canvas/widgets/ComponentSetWidget.module.css +0 -89
  319. package/src/internals/canvas/widgets/InlineStoryRenderer.jsx +0 -129
  320. package/src/internals/canvas/widgets/InlineStoryRenderer.test.jsx +0 -68
  321. package/src/internals/canvas/widgets/StorySetWidget.module.css +0 -89
  322. package/src/primer/ThemeSync.jsx +0 -73
  323. package/src/primer/index.js +0 -1
@@ -12,6 +12,8 @@
12
12
  * GET /list — list all canvases
13
13
  * GET /folders — list canvas folders
14
14
  * PUT /update — append update events (widgets, sources, settings)
15
+ * POST /undo — append the inverse of a previously-applied event
16
+ * POST /redo — re-apply an undone event (mirror of /undo)
15
17
  * PUT /rename-page — rename a canvas page file
16
18
  * PUT /reorder-pages — save page order for a canvas folder
17
19
  * GET /page-order — read page order for a folder
@@ -38,7 +40,9 @@ import fs from 'node:fs'
38
40
  import path from 'node:path'
39
41
  import { Buffer } from 'node:buffer'
40
42
  import { createHash } from 'node:crypto'
41
- import { materializeFromText, serializeEvent } from './materializer.js'
43
+ import { materializeFromText, parseCanvasJsonl, serializeEvent } from './materializer.js'
44
+ import { buildInverseEvent } from './undoRedo.js'
45
+ import { compactCanvas } from './compact.js'
42
46
  import { toCanvasId, parseCanvasId } from './identity.js'
43
47
  import {
44
48
  GH_INSTALL_URL,
@@ -137,7 +141,7 @@ function writeFolderMeta(folderDir, meta) {
137
141
  */
138
142
  function findCanvasFiles(root) {
139
143
  const results = []
140
- const ignore = new Set(['node_modules', 'dist', '.git', '.worktrees'])
144
+ const ignore = new Set(['node_modules', 'dist', '.git', '.worktrees', 'worktrees', 'workleafs'])
141
145
 
142
146
  function walk(dir, rel) {
143
147
  let entries
@@ -164,7 +168,7 @@ function findCanvasFiles(root) {
164
168
  */
165
169
  function findStoryFiles(root) {
166
170
  const results = []
167
- const ignore = new Set(['node_modules', 'dist', '.git', '.worktrees'])
171
+ const ignore = new Set(['node_modules', 'dist', '.git', '.worktrees', 'worktrees', 'workleafs'])
168
172
  const ROUTABLE_DIRS = ['src/canvas', 'src/components']
169
173
 
170
174
  function walk(dir, rel) {
@@ -240,6 +244,53 @@ function appendEventRaw(filePath, event) {
240
244
  fs.appendFileSync(filePath, serializeEvent(event) + '\n', 'utf-8')
241
245
  }
242
246
 
247
+ /**
248
+ * Generate a short random event id. Stamped on every canvas event so
249
+ * undo/redo can reference the inverse target by id alone.
250
+ */
251
+ function generateEventId() {
252
+ return `evt_${Math.random().toString(36).slice(2, 10)}`
253
+ }
254
+
255
+ /**
256
+ * Hard ceiling for runaway sessions: when a JSONL file grows past this size,
257
+ * compact it once (collapses history into a single `canvas_created` baseline).
258
+ * Set above the normal startup-compaction threshold (500 KB) so most files
259
+ * never hit it — only marathon sessions that don't restart the dev server.
260
+ */
261
+ const SOFT_COMPACT_THRESHOLD_BYTES = 2 * 1024 * 1024
262
+
263
+ /** Per-file lock to avoid compacting the same file repeatedly on bursts. */
264
+ const inFlightCompactions = new Set()
265
+
266
+ /**
267
+ * Check the file size after a write; if past the runaway threshold, kick off
268
+ * a one-shot compaction. Best-effort — failures are swallowed and logged.
269
+ */
270
+ function maybeCompactRunawayFile(filePath) {
271
+ if (inFlightCompactions.has(filePath)) return
272
+ let size
273
+ try { size = fs.statSync(filePath).size } catch { return }
274
+ if (size < SOFT_COMPACT_THRESHOLD_BYTES) return
275
+ inFlightCompactions.add(filePath)
276
+ // Run on the next tick so this append's HMR push fires first and the
277
+ // compaction's HMR push lands after — the client treats both consistently
278
+ // (compaction's push is a single canvas_created event that reconciles).
279
+ setImmediate(() => {
280
+ try {
281
+ const result = compactCanvas(filePath, { force: true })
282
+ devLog().logEvent('info', `[canvas] runaway compaction ${path.basename(filePath)}`, {
283
+ before: result.before,
284
+ after: result.after,
285
+ })
286
+ } catch (err) {
287
+ devLog().logEvent('warn', `[canvas] runaway compaction failed for ${filePath}`, { error: err.message })
288
+ } finally {
289
+ inFlightCompactions.delete(filePath)
290
+ }
291
+ })
292
+ }
293
+
243
294
  /**
244
295
  * Generate a unique widget ID.
245
296
  */
@@ -702,13 +753,25 @@ export function createCanvasHandler(ctx) {
702
753
  // Append an event to an existing canvas file.
703
754
  // Marks the file in the write guard so the data plugin's watcher handler
704
755
  // skips sending a duplicate HMR event (the server pushes its own via
705
- // pushCanvasUpdate after the write).
756
+ // pushCanvasUpdate after the write). Stamps a stable random `id` so
757
+ // undo/redo markers can target events by id.
758
+ //
759
+ // Also runs the runaway-session hard ceiling: if the file grows past
760
+ // SOFT_COMPACT_THRESHOLD after writing, kick off a one-shot compaction
761
+ // to keep the JSONL → HMR → React pipeline responsive. The user's per-tab
762
+ // undo stack will lose pre-compaction targets (post-compaction undo calls
763
+ // return 404 which the client interprets as "clear the stack").
706
764
  function appendEvent(filePath, event) {
765
+ if (event && typeof event === 'object' && !event.id) {
766
+ event.id = generateEventId()
767
+ }
707
768
  markCanvasWrite(filePath)
708
769
  appendEventRaw(filePath, event)
770
+ maybeCompactRunawayFile(filePath)
709
771
  // Unmark after enough time for the watcher to fire and be suppressed.
710
772
  // macOS FSEvents latency is typically 100-500ms; 1s covers edge cases.
711
773
  setTimeout(() => unmarkCanvasWrite(filePath), 1000)
774
+ return event.id
712
775
  }
713
776
 
714
777
  /**
@@ -783,18 +846,18 @@ export function createCanvasHandler(ctx) {
783
846
  }
784
847
 
785
848
  /**
786
- * Non-mutating probe of a hot pool — returns webgl-readiness without
787
- * claiming a slot. Use this when the canvas API just needs to tell the
788
- * client whether to render in webgl-ready mode; the actual session claim
789
- * happens later in terminal-server when the WebSocket connects.
849
+ * Non-mutating probe of a hot pool — returns readiness without claiming
850
+ * a slot. Use this when the canvas API just needs to tell the client
851
+ * whether a hot session is available; the actual session claim happens
852
+ * later in terminal-server when the WebSocket connects.
790
853
  * @param {Object|null} hotPool — HotPoolManager instance
791
854
  * @param {string} poolId — pool to peek at
792
855
  * @param {string} [mode] — 'auto' (default), 'hot', or 'cold'
793
- * @returns {{ ready: boolean, webglReady: boolean }}
856
+ * @returns {{ ready: boolean }}
794
857
  */
795
858
  function peekPool(hotPool, poolId, mode) {
796
- if (!hotPool || mode === 'cold') return { ready: false, webglReady: false }
797
- if (!hotPool.has(poolId)) return { ready: false, webglReady: false }
859
+ if (!hotPool || mode === 'cold') return { ready: false }
860
+ if (!hotPool.has(poolId)) return { ready: false }
798
861
  return hotPool.peek(poolId)
799
862
  }
800
863
 
@@ -879,15 +942,20 @@ export function createCanvasHandler(ctx) {
879
942
  if (routePath === '/folders' && method === 'GET') {
880
943
  const canvasDir = path.join(root, 'src', 'canvas')
881
944
  let folders = []
945
+ let entries = []
882
946
  try {
883
947
  if (fs.existsSync(canvasDir)) {
884
- const entries = fs.readdirSync(canvasDir, { withFileTypes: true })
885
- // .folder directories (existing behavior)
886
- const folderDirs = entries
948
+ const dirEntries = fs.readdirSync(canvasDir, { withFileTypes: true })
949
+ // .folder directories — workspace grouping (sibling canvases shown
950
+ // under a folder header in the workspace, each addressable on its
951
+ // own /canvas/<folder>/<name> route).
952
+ const folderDirs = dirEntries
887
953
  .filter((d) => d.isDirectory() && d.name.endsWith('.folder'))
888
954
  .map((d) => d.name.replace('.folder', ''))
889
- // Plain directories containing .canvas.jsonl files
890
- const plainDirs = entries
955
+ // Plain directories containing .canvas.jsonl files — multi-page
956
+ // canvas grouping (each .canvas.jsonl is a "page" of the parent
957
+ // group, surfaced via PageSelector inside the canvas).
958
+ const plainDirs = dirEntries
891
959
  .filter((d) => {
892
960
  if (!d.isDirectory() || d.name.endsWith('.folder') || d.name.startsWith('_')) return false
893
961
  const files = fs.readdirSync(path.join(canvasDir, d.name))
@@ -895,9 +963,16 @@ export function createCanvasHandler(ctx) {
895
963
  })
896
964
  .map((d) => d.name)
897
965
  folders = [...folderDirs, ...plainDirs]
966
+ entries = [
967
+ ...folderDirs.map((name) => ({ name, kind: 'workspace' })),
968
+ ...plainDirs.map((name) => ({ name, kind: 'pages' })),
969
+ ]
898
970
  }
899
971
  } catch { /* empty */ }
900
- sendJson(res, 200, { folders })
972
+ // `folders` is the legacy flat name list; `entries` is the new tagged
973
+ // list. Clients can prefer `entries` when they need to know whether a
974
+ // folder is a `.folder/` (workspace) or plain (multi-page) dir.
975
+ sendJson(res, 200, { folders, entries })
901
976
  return
902
977
  }
903
978
 
@@ -1007,6 +1082,9 @@ export function createCanvasHandler(ctx) {
1007
1082
 
1008
1083
  try {
1009
1084
  const ts = new Date().toISOString()
1085
+ // Snapshot of pre-update state so we can stamp prev* payloads on
1086
+ // source_updated and settings_updated events without re-reading.
1087
+ const current = readCanvas(filePath)
1010
1088
 
1011
1089
  if (widgets) {
1012
1090
  // Guard against accidental canvas wipes. Any widget IDs present in
@@ -1015,7 +1093,6 @@ export function createCanvasHandler(ctx) {
1015
1093
  // This protects against stale-state writers (e.g. a debounced client
1016
1094
  // save fired before an HMR push containing newly-added widgets was
1017
1095
  // reconciled) silently wiping freshly-created widgets.
1018
- const current = readCanvas(filePath)
1019
1096
  const currentWidgets = current.widgets || []
1020
1097
  if (body.replaceAll !== true) {
1021
1098
  const incomingIds = new Set(widgets.map((w) => w && w.id).filter(Boolean))
@@ -1032,14 +1109,23 @@ export function createCanvasHandler(ctx) {
1032
1109
  }
1033
1110
  }
1034
1111
  const stamped = stampBoundsAll(widgets)
1112
+ // widgets_replaced is not undoable — emitted only by compaction or
1113
+ // explicit replaceAll. The client should use PATCH/POST/DELETE for
1114
+ // granular, undoable changes.
1035
1115
  appendEvent(filePath, { event: 'widgets_replaced', timestamp: ts, widgets: stamped })
1036
1116
  }
1037
1117
 
1038
1118
  if (sources) {
1039
- appendEvent(filePath, { event: 'source_updated', timestamp: ts, sources })
1119
+ appendEvent(filePath, {
1120
+ event: 'source_updated',
1121
+ timestamp: ts,
1122
+ sources,
1123
+ prevSources: Array.isArray(current.sources) ? current.sources : [],
1124
+ })
1040
1125
  }
1041
1126
 
1042
1127
  if (connectors) {
1128
+ // connectors_replaced is not undoable — emitted only by compaction.
1043
1129
  appendEvent(filePath, { event: 'connectors_replaced', timestamp: ts, connectors })
1044
1130
  }
1045
1131
 
@@ -1051,7 +1137,17 @@ export function createCanvasHandler(ctx) {
1051
1137
  }
1052
1138
  }
1053
1139
  if (Object.keys(filtered).length > 0) {
1054
- appendEvent(filePath, { event: 'settings_updated', timestamp: ts, settings: filtered })
1140
+ // Capture prior values for the keys being changed.
1141
+ const prevSettings = {}
1142
+ for (const key of Object.keys(filtered)) {
1143
+ prevSettings[key] = current[key]
1144
+ }
1145
+ appendEvent(filePath, {
1146
+ event: 'settings_updated',
1147
+ timestamp: ts,
1148
+ settings: filtered,
1149
+ prevSettings,
1150
+ })
1055
1151
  }
1056
1152
  }
1057
1153
 
@@ -1063,6 +1159,61 @@ export function createCanvasHandler(ctx) {
1063
1159
  return
1064
1160
  }
1065
1161
 
1162
+ // POST /undo — append the inverse of a previously-applied event, tagged
1163
+ // with meta.kind = 'undo' / meta.of = <targetEventId>. Body:
1164
+ // { name, eventId }
1165
+ // Returns { success, eventId, inverseEvent: {...} }.
1166
+ //
1167
+ // POST /redo behaves identically but stamps meta.kind = 'redo'. Per
1168
+ // convention the client passes the id of the *undo* event when redoing
1169
+ // (so the redo cancels the undo and brings back the original change).
1170
+ if ((routePath === '/undo' || routePath === '/redo') && method === 'POST') {
1171
+ const { name, eventId } = body
1172
+ const kind = routePath === '/undo' ? 'undo' : 'redo'
1173
+
1174
+ if (!name || !eventId) {
1175
+ sendJson(res, 400, { error: 'Canvas name and eventId are required' })
1176
+ return
1177
+ }
1178
+
1179
+ const filePath = findCanvasPath(root, name)
1180
+ if (!filePath) {
1181
+ sendJson(res, 404, { error: `Canvas "${name}" not found` })
1182
+ return
1183
+ }
1184
+
1185
+ try {
1186
+ const text = fs.readFileSync(filePath, 'utf-8')
1187
+ const events = parseCanvasJsonl(text)
1188
+ const target = events.find((evt) => evt && evt.id === eventId)
1189
+ if (!target) {
1190
+ sendJson(res, 404, { error: `Event "${eventId}" not found in canvas "${name}"` })
1191
+ return
1192
+ }
1193
+
1194
+ const inverse = buildInverseEvent(target, kind)
1195
+ if (!inverse) {
1196
+ sendJson(res, 400, {
1197
+ error: `Event "${eventId}" (type "${target.event}") cannot be ${kind === 'undo' ? 'undone' : 'redone'}: missing inverse payload or unsupported event type`,
1198
+ })
1199
+ return
1200
+ }
1201
+
1202
+ inverse.timestamp = new Date().toISOString()
1203
+ const inverseId = appendEvent(filePath, inverse)
1204
+
1205
+ sendJson(res, 200, {
1206
+ success: true,
1207
+ eventId: inverseId,
1208
+ inverseEvent: { ...inverse, id: inverseId },
1209
+ })
1210
+ pushCanvasUpdate(name, filePath, __viteWs)
1211
+ } catch (err) {
1212
+ sendJson(res, 500, { error: `Failed to ${kind} event: ${err.message}` })
1213
+ }
1214
+ return
1215
+ }
1216
+
1066
1217
  // POST /widget — append a widget_added event
1067
1218
  if (routePath === '/widget' && method === 'POST') {
1068
1219
  const { name, type, props = {}, pool, near, direction, resolve, source, gap } = body
@@ -1134,11 +1285,11 @@ export function createCanvasHandler(ctx) {
1134
1285
 
1135
1286
  // Hot pool readiness probe for terminal/agent widgets — non-mutating.
1136
1287
  // The actual session claim happens later in terminal-server when the
1137
- // WS connects. Probing here only tells the client whether to render
1138
- // in webgl-ready mode immediately. (Previously this called
1139
- // acquireFromPool, which leaked a #acquired slot per widget creation
1140
- // because no consume/release ever fired against this acquisition.)
1141
- let hotProbe = { ready: false, webglReady: false }
1288
+ // WS connects. Probing here only tells the client whether a hot
1289
+ // session is available. (Previously this called acquireFromPool,
1290
+ // which leaked a #acquired slot per widget creation because no
1291
+ // consume/release ever fired against this acquisition.)
1292
+ let hotProbe = { ready: false }
1142
1293
  if ((type === 'terminal' || type === 'agent') && pool !== 'cold') {
1143
1294
  const poolId = resolvePoolId(type, props)
1144
1295
  hotProbe = peekPool(hotPool, poolId, pool)
@@ -1150,14 +1301,14 @@ export function createCanvasHandler(ctx) {
1150
1301
 
1151
1302
  const widget = stampBounds({ id: widgetId, type, position, props })
1152
1303
 
1153
- appendEvent(filePath, {
1304
+ const eventId = appendEvent(filePath, {
1154
1305
  event: 'widget_added',
1155
1306
  timestamp: new Date().toISOString(),
1156
1307
  widget,
1157
1308
  })
1158
1309
 
1159
- const response = { success: true, widget }
1160
- if (hotProbe.ready) response.hotSession = { id: null, tmuxName: null, webglReady: hotProbe.webglReady }
1310
+ const response = { success: true, widget, eventId }
1311
+ if (hotProbe.ready) response.hotSession = { id: null, tmuxName: null }
1161
1312
  sendJson(res, 201, response)
1162
1313
  pushCanvasUpdate(name, filePath, __viteWs)
1163
1314
  } catch (err) {
@@ -1190,10 +1341,13 @@ export function createCanvasHandler(ctx) {
1190
1341
  return
1191
1342
  }
1192
1343
 
1193
- appendEvent(filePath, {
1344
+ const eventId = appendEvent(filePath, {
1194
1345
  event: 'widget_removed',
1195
1346
  timestamp: new Date().toISOString(),
1196
1347
  widgetId,
1348
+ // Carry the full removed widget so undo can re-add it without
1349
+ // walking history. Symmetric to widget_added.widget.
1350
+ widget,
1197
1351
  })
1198
1352
 
1199
1353
  // Orphan terminal session when a terminal widget is deleted (not killed)
@@ -1206,7 +1360,7 @@ export function createCanvasHandler(ctx) {
1206
1360
  }
1207
1361
  }
1208
1362
 
1209
- sendJson(res, 200, { success: true, removed: 1 })
1363
+ sendJson(res, 200, { success: true, removed: 1, eventId })
1210
1364
  pushCanvasUpdate(name, filePath, __viteWs)
1211
1365
  } catch (err) {
1212
1366
  sendJson(res, 500, { error: `Failed to remove widget: ${err.message}` })
@@ -1242,25 +1396,34 @@ export function createCanvasHandler(ctx) {
1242
1396
  }
1243
1397
 
1244
1398
  const ts = new Date().toISOString()
1399
+ const eventIds = []
1245
1400
 
1246
1401
  if (props) {
1247
- appendEvent(filePath, {
1402
+ // Capture only the keys being changed so undo restores them
1403
+ // precisely. Missing keys mean "this prop didn't exist before".
1404
+ const prevProps = {}
1405
+ for (const key of Object.keys(props)) {
1406
+ prevProps[key] = widget.props ? widget.props[key] : undefined
1407
+ }
1408
+ eventIds.push(appendEvent(filePath, {
1248
1409
  event: 'widget_updated',
1249
1410
  timestamp: ts,
1250
1411
  widgetId,
1251
1412
  props,
1252
- })
1413
+ prevProps,
1414
+ }))
1253
1415
  }
1254
1416
 
1255
1417
  if (position) {
1256
1418
  // Merge with existing position so partial updates (only --x or --y) are safe
1257
1419
  const mergedPosition = { ...widget.position, ...position }
1258
- appendEvent(filePath, {
1420
+ eventIds.push(appendEvent(filePath, {
1259
1421
  event: 'widget_moved',
1260
1422
  timestamp: ts,
1261
1423
  widgetId,
1262
1424
  position: mergedPosition,
1263
- })
1425
+ prevPosition: widget.position || { x: 0, y: 0 },
1426
+ }))
1264
1427
  }
1265
1428
 
1266
1429
  // Return the merged widget for convenience
@@ -1269,7 +1432,7 @@ export function createCanvasHandler(ctx) {
1269
1432
  props: { ...widget.props, ...(props || {}) },
1270
1433
  position: position ? { ...widget.position, ...position } : widget.position,
1271
1434
  }
1272
- sendJson(res, 200, { success: true, widget: merged })
1435
+ sendJson(res, 200, { success: true, widget: merged, eventIds })
1273
1436
  pushCanvasUpdate(name, filePath, __viteWs)
1274
1437
  } catch (err) {
1275
1438
  sendJson(res, 500, { error: `Failed to update widget: ${err.message}` })
@@ -1417,13 +1580,13 @@ export function createCanvasHandler(ctx) {
1417
1580
  meta: meta && typeof meta === 'object' ? { ...meta } : {},
1418
1581
  }
1419
1582
 
1420
- appendEvent(filePath, {
1583
+ const eventId = appendEvent(filePath, {
1421
1584
  event: 'connector_added',
1422
1585
  timestamp: new Date().toISOString(),
1423
1586
  connector,
1424
1587
  })
1425
1588
 
1426
- sendJson(res, 201, { success: true, connector })
1589
+ sendJson(res, 201, { success: true, connector, eventId })
1427
1590
  pushCanvasUpdate(name, filePath, __viteWs)
1428
1591
  } catch (err) {
1429
1592
  sendJson(res, 500, { error: `Failed to add connector: ${err.message}` })
@@ -1469,14 +1632,28 @@ export function createCanvasHandler(ctx) {
1469
1632
  if (startAnchor) updates.startAnchor = startAnchor
1470
1633
  if (endAnchor) updates.endAnchor = endAnchor
1471
1634
 
1472
- appendEvent(filePath, {
1635
+ // Capture the previous values for the keys we're changing so undo
1636
+ // can restore them precisely.
1637
+ const prevUpdates = {}
1638
+ if (meta) {
1639
+ const prevMeta = {}
1640
+ for (const key of Object.keys(meta)) {
1641
+ prevMeta[key] = connector.meta ? connector.meta[key] : undefined
1642
+ }
1643
+ prevUpdates.meta = prevMeta
1644
+ }
1645
+ if (startAnchor) prevUpdates.startAnchor = connector.start?.anchor
1646
+ if (endAnchor) prevUpdates.endAnchor = connector.end?.anchor
1647
+
1648
+ const eventId = appendEvent(filePath, {
1473
1649
  event: 'connector_updated',
1474
1650
  timestamp: new Date().toISOString(),
1475
1651
  connectorId,
1476
1652
  updates,
1653
+ prevUpdates,
1477
1654
  })
1478
1655
 
1479
- sendJson(res, 200, { success: true })
1656
+ sendJson(res, 200, { success: true, eventId })
1480
1657
  pushCanvasUpdate(name, filePath, __viteWs)
1481
1658
 
1482
1659
  // Inject messaging skill into both terminals when mode changes
@@ -1562,8 +1739,8 @@ export function createCanvasHandler(ctx) {
1562
1739
 
1563
1740
  try {
1564
1741
  const data = readCanvas(filePath)
1565
- const exists = (data.connectors || []).some((c) => c.id === connectorId)
1566
- if (!exists) {
1742
+ const existing = (data.connectors || []).find((c) => c.id === connectorId)
1743
+ if (!existing) {
1567
1744
  sendJson(res, 404, { error: `Connector "${connectorId}" not found in canvas "${name}"` })
1568
1745
  return
1569
1746
  }
@@ -1576,14 +1753,17 @@ export function createCanvasHandler(ctx) {
1576
1753
  return out
1577
1754
  })
1578
1755
 
1579
- appendEvent(filePath, {
1756
+ const eventId = appendEvent(filePath, {
1580
1757
  event: 'connector_waypoints_set',
1581
1758
  timestamp: new Date().toISOString(),
1582
1759
  connectorId,
1583
1760
  waypoints: normalized,
1761
+ // null prevWaypoints means "this connector had no manual routing
1762
+ // before"; undo emits connector_waypoints_cleared in that case.
1763
+ prevWaypoints: Array.isArray(existing.waypoints) ? existing.waypoints : null,
1584
1764
  })
1585
1765
 
1586
- sendJson(res, 200, { success: true, waypoints: normalized })
1766
+ sendJson(res, 200, { success: true, waypoints: normalized, eventId })
1587
1767
  pushCanvasUpdate(name, filePath, __viteWs)
1588
1768
  } catch (err) {
1589
1769
  sendJson(res, 500, { error: `Failed to set waypoints: ${err.message}` })
@@ -1608,19 +1788,20 @@ export function createCanvasHandler(ctx) {
1608
1788
 
1609
1789
  try {
1610
1790
  const data = readCanvas(filePath)
1611
- const exists = (data.connectors || []).some((c) => c.id === connectorId)
1612
- if (!exists) {
1791
+ const existing = (data.connectors || []).find((c) => c.id === connectorId)
1792
+ if (!existing) {
1613
1793
  sendJson(res, 404, { error: `Connector "${connectorId}" not found in canvas "${name}"` })
1614
1794
  return
1615
1795
  }
1616
1796
 
1617
- appendEvent(filePath, {
1797
+ const eventId = appendEvent(filePath, {
1618
1798
  event: 'connector_waypoints_cleared',
1619
1799
  timestamp: new Date().toISOString(),
1620
1800
  connectorId,
1801
+ prevWaypoints: Array.isArray(existing.waypoints) ? existing.waypoints : null,
1621
1802
  })
1622
1803
 
1623
- sendJson(res, 200, { success: true })
1804
+ sendJson(res, 200, { success: true, eventId })
1624
1805
  pushCanvasUpdate(name, filePath, __viteWs)
1625
1806
  } catch (err) {
1626
1807
  sendJson(res, 500, { error: `Failed to clear waypoints: ${err.message}` })
@@ -1645,19 +1826,22 @@ export function createCanvasHandler(ctx) {
1645
1826
 
1646
1827
  try {
1647
1828
  const data = readCanvas(filePath)
1648
- const exists = (data.connectors || []).some((c) => c.id === connectorId)
1649
- if (!exists) {
1829
+ const connector = (data.connectors || []).find((c) => c.id === connectorId)
1830
+ if (!connector) {
1650
1831
  sendJson(res, 404, { error: `Connector "${connectorId}" not found in canvas "${name}"` })
1651
1832
  return
1652
1833
  }
1653
1834
 
1654
- appendEvent(filePath, {
1835
+ const eventId = appendEvent(filePath, {
1655
1836
  event: 'connector_removed',
1656
1837
  timestamp: new Date().toISOString(),
1657
1838
  connectorId,
1839
+ // Carry the full removed connector so undo can re-add it without
1840
+ // walking history. Symmetric to connector_added.connector.
1841
+ connector,
1658
1842
  })
1659
1843
 
1660
- sendJson(res, 200, { success: true, removed: 1 })
1844
+ sendJson(res, 200, { success: true, removed: 1, eventId })
1661
1845
  pushCanvasUpdate(name, filePath, __viteWs)
1662
1846
  } catch (err) {
1663
1847
  sendJson(res, 500, { error: `Failed to remove connector: ${err.message}` })
@@ -1892,7 +2076,7 @@ export function createCanvasHandler(ctx) {
1892
2076
  const widgetId = generateWidgetId(type)
1893
2077
  await prepareTerminalWidget({ type, props, widgetId, canvasName: name, req })
1894
2078
 
1895
- let hotProbe = { ready: false, webglReady: false }
2079
+ let hotProbe = { ready: false }
1896
2080
  if ((type === 'terminal' || type === 'agent') && pool !== 'cold') {
1897
2081
  const poolId = resolvePoolId(type, props)
1898
2082
  hotProbe = peekPool(hotPool, poolId, pool)
@@ -1901,15 +2085,15 @@ export function createCanvasHandler(ctx) {
1901
2085
 
1902
2086
  const widget = stampBounds({ id: widgetId, type, position, props })
1903
2087
 
1904
- appendEvent(filePath, { event: 'widget_added', timestamp: ts, widget })
2088
+ const eventId = appendEvent(filePath, { event: 'widget_added', timestamp: ts, widget })
1905
2089
 
1906
2090
  widgetIds.add(widgetId)
1907
2091
  widgetMap.set(widgetId, widget)
1908
2092
  refs[String(i)] = widgetId
1909
2093
  if (ref) refs[ref] = widgetId
1910
2094
 
1911
- const result = { index: i, op: 'create-widget', ref: ref || undefined, widgetId, widget }
1912
- if (hotProbe.ready) result.hotSession = { id: null, tmuxName: null, webglReady: hotProbe.webglReady }
2095
+ const result = { index: i, op: 'create-widget', ref: ref || undefined, widgetId, widget, eventId }
2096
+ if (hotProbe.ready) result.hotSession = { id: null, tmuxName: null }
1913
2097
  results.push(result)
1914
2098
  break
1915
2099
  }
@@ -1921,12 +2105,16 @@ export function createCanvasHandler(ctx) {
1921
2105
  if (!props) throw new Error('props is required')
1922
2106
  if (!widgetIds.has(widgetId)) throw new Error(`Widget "${widgetId}" not found`)
1923
2107
 
1924
- appendEvent(filePath, { event: 'widget_updated', timestamp: ts, widgetId, props })
1925
-
1926
2108
  const existing = widgetMap.get(widgetId)
2109
+ const prevProps = {}
2110
+ for (const key of Object.keys(props)) {
2111
+ prevProps[key] = existing?.props ? existing.props[key] : undefined
2112
+ }
2113
+ const eventId = appendEvent(filePath, { event: 'widget_updated', timestamp: ts, widgetId, props, prevProps })
2114
+
1927
2115
  if (existing) existing.props = { ...existing.props, ...props }
1928
2116
 
1929
- results.push({ index: i, op: 'update-widget', widgetId, success: true })
2117
+ results.push({ index: i, op: 'update-widget', widgetId, success: true, eventId })
1930
2118
  break
1931
2119
  }
1932
2120
 
@@ -1939,12 +2127,13 @@ export function createCanvasHandler(ctx) {
1939
2127
 
1940
2128
  const existing = widgetMap.get(widgetId)
1941
2129
  const mergedPosition = { ...(existing?.position || {}), ...position }
2130
+ const prevPosition = existing?.position || { x: 0, y: 0 }
1942
2131
 
1943
- appendEvent(filePath, { event: 'widget_moved', timestamp: ts, widgetId, position: mergedPosition })
2132
+ const eventId = appendEvent(filePath, { event: 'widget_moved', timestamp: ts, widgetId, position: mergedPosition, prevPosition })
1944
2133
 
1945
2134
  if (existing) existing.position = mergedPosition
1946
2135
 
1947
- results.push({ index: i, op: 'move-widget', widgetId, success: true })
2136
+ results.push({ index: i, op: 'move-widget', widgetId, success: true, eventId })
1948
2137
  break
1949
2138
  }
1950
2139
 
@@ -1953,12 +2142,18 @@ export function createCanvasHandler(ctx) {
1953
2142
  if (!widgetId) throw new Error('widgetId is required')
1954
2143
  if (!widgetIds.has(widgetId)) throw new Error(`Widget "${widgetId}" not found`)
1955
2144
 
1956
- appendEvent(filePath, { event: 'widget_removed', timestamp: ts, widgetId })
2145
+ const existing = widgetMap.get(widgetId)
2146
+ const eventId = appendEvent(filePath, {
2147
+ event: 'widget_removed',
2148
+ timestamp: ts,
2149
+ widgetId,
2150
+ widget: existing,
2151
+ })
1957
2152
 
1958
2153
  widgetIds.delete(widgetId)
1959
2154
  widgetMap.delete(widgetId)
1960
2155
 
1961
- results.push({ index: i, op: 'delete-widget', widgetId, success: true })
2156
+ results.push({ index: i, op: 'delete-widget', widgetId, success: true, eventId })
1962
2157
  break
1963
2158
  }
1964
2159
 
@@ -1993,14 +2188,14 @@ export function createCanvasHandler(ctx) {
1993
2188
  meta: op.meta && typeof op.meta === 'object' ? { ...op.meta } : {},
1994
2189
  }
1995
2190
 
1996
- appendEvent(filePath, { event: 'connector_added', timestamp: ts, connector })
2191
+ const eventId = appendEvent(filePath, { event: 'connector_added', timestamp: ts, connector })
1997
2192
 
1998
2193
  connectorIds.add(connectorId)
1999
2194
  connectorMap.set(connectorId, connector)
2000
2195
  refs[String(i)] = connectorId
2001
2196
  if (ref) refs[ref] = connectorId
2002
2197
 
2003
- results.push({ index: i, op: 'create-connector', ref: ref || undefined, connectorId, success: true })
2198
+ results.push({ index: i, op: 'create-connector', ref: ref || undefined, connectorId, success: true, eventId })
2004
2199
  break
2005
2200
  }
2006
2201
 
@@ -2009,11 +2204,17 @@ export function createCanvasHandler(ctx) {
2009
2204
  if (!connectorId) throw new Error('connectorId is required')
2010
2205
  if (!connectorIds.has(connectorId)) throw new Error(`Connector "${connectorId}" not found`)
2011
2206
 
2012
- appendEvent(filePath, { event: 'connector_removed', timestamp: ts, connectorId })
2207
+ const existing = connectorMap.get(connectorId)
2208
+ const eventId = appendEvent(filePath, {
2209
+ event: 'connector_removed',
2210
+ timestamp: ts,
2211
+ connectorId,
2212
+ connector: existing,
2213
+ })
2013
2214
  connectorIds.delete(connectorId)
2014
2215
  connectorMap.delete(connectorId)
2015
2216
 
2016
- results.push({ index: i, op: 'delete-connector', connectorId, success: true })
2217
+ results.push({ index: i, op: 'delete-connector', connectorId, success: true, eventId })
2017
2218
  break
2018
2219
  }
2019
2220
 
@@ -2024,12 +2225,22 @@ export function createCanvasHandler(ctx) {
2024
2225
  if (!meta) throw new Error('meta is required')
2025
2226
  if (!connectorIds.has(connectorId)) throw new Error(`Connector "${connectorId}" not found`)
2026
2227
 
2027
- appendEvent(filePath, { event: 'connector_updated', timestamp: ts, connectorId, updates: { meta } })
2028
-
2029
2228
  const existing = connectorMap.get(connectorId)
2229
+ const prevMeta = {}
2230
+ for (const key of Object.keys(meta)) {
2231
+ prevMeta[key] = existing?.meta ? existing.meta[key] : undefined
2232
+ }
2233
+ const eventId = appendEvent(filePath, {
2234
+ event: 'connector_updated',
2235
+ timestamp: ts,
2236
+ connectorId,
2237
+ updates: { meta },
2238
+ prevUpdates: { meta: prevMeta },
2239
+ })
2240
+
2030
2241
  if (existing) existing.meta = { ...(existing.meta || {}), ...meta }
2031
2242
 
2032
- results.push({ index: i, op: 'update-connector', connectorId, success: true })
2243
+ results.push({ index: i, op: 'update-connector', connectorId, success: true, eventId })
2033
2244
  break
2034
2245
  }
2035
2246
 
@@ -2397,6 +2608,7 @@ export function createCanvasHandler(ctx) {
2397
2608
  name,
2398
2609
  title,
2399
2610
  folder,
2611
+ folderKind,
2400
2612
  convertFrom,
2401
2613
  author,
2402
2614
  description,
@@ -2531,7 +2743,12 @@ export function createCanvasHandler(ctx) {
2531
2743
  }
2532
2744
 
2533
2745
  // ── Standard canvas creation ─────────────────────────────────────
2534
- // Determine target directory
2746
+ // Determine target directory. When `folder` is set:
2747
+ // - existing `<folder>.folder/` wins → workspace grouping
2748
+ // - existing `<folder>/` wins → multi-page canvas grouping
2749
+ // - otherwise: create a new dir; `folderKind` controls which kind.
2750
+ // Default kind is "pages" for backward compatibility (the legacy
2751
+ // behavior). Pass `"workspace"` to create `<folder>.folder/`.
2535
2752
  const canvasDir = path.join(root, 'src', 'canvas')
2536
2753
  let targetDir = canvasDir
2537
2754
 
@@ -2540,25 +2757,28 @@ export function createCanvasHandler(ctx) {
2540
2757
  const plainDir = path.join(canvasDir, folder)
2541
2758
 
2542
2759
  if (fs.existsSync(dotFolderDir)) {
2543
- // Existing .folder/ directory
2760
+ // Existing .folder/ directory (workspace grouping)
2544
2761
  targetDir = dotFolderDir
2545
2762
  } else if (fs.existsSync(plainDir) && fs.statSync(plainDir).isDirectory()) {
2546
- // Existing plain directory
2763
+ // Existing plain directory (multi-page grouping)
2547
2764
  targetDir = plainDir
2548
2765
  } else {
2549
- // Create new plain directory
2766
+ // Create a new directory according to the requested kind.
2767
+ const useWorkspace = folderKind === 'workspace'
2768
+ const newDir = useWorkspace ? dotFolderDir : plainDir
2550
2769
  try {
2551
- fs.mkdirSync(plainDir, { recursive: true })
2552
- // Write .meta.json if meta was provided
2770
+ fs.mkdirSync(newDir, { recursive: true })
2771
+ // Write .meta.json if meta was provided (only meaningful for
2772
+ // multi-page groups today; harmless inside a .folder/).
2553
2773
  if (meta && typeof meta === 'object') {
2554
- const metaPath = path.join(plainDir, `${folder}.meta.json`)
2774
+ const metaPath = path.join(newDir, `${folder}.meta.json`)
2555
2775
  fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2) + '\n', 'utf-8')
2556
2776
  }
2557
2777
  } catch (err) {
2558
2778
  sendJson(res, 500, { error: `Failed to create directory: ${err.message}` })
2559
2779
  return
2560
2780
  }
2561
- targetDir = plainDir
2781
+ targetDir = newDir
2562
2782
  }
2563
2783
  }
2564
2784
 
@@ -2763,16 +2983,56 @@ export function Default() {
2763
2983
  const EXT_TO_MIME = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif' }
2764
2984
  const MAX_IMAGE_SIZE = 5 * 1024 * 1024 // 5 MB
2765
2985
 
2766
- // Route snapshot uploads (snapshot-* prefix) to the snapshots directory
2986
+ // Route snapshot uploads (snapshot-* prefix) to the snapshots directory.
2987
+ // Canvases inside a `drafts/` segment go to images/drafts/ (gitignored)
2988
+ // so draft-only images don't leak into git via assets/canvas/images/.
2989
+ function isDraftCanvasId(canvasName) {
2990
+ if (!canvasName || typeof canvasName !== 'string') return false
2991
+ return canvasName.split('/').some((seg) => seg === 'drafts')
2992
+ }
2767
2993
  function resolveWriteDir(canvasName) {
2768
- return canvasName?.startsWith('snapshot-') ? snapshotsDir : imagesDir
2994
+ if (canvasName?.startsWith('snapshot-')) return snapshotsDir
2995
+ if (isDraftCanvasId(canvasName)) return path.join(imagesDir, 'drafts')
2996
+ return imagesDir
2997
+ }
2998
+
2999
+ // Validate a filename coming from the client. Accepts a bare basename
3000
+ // (`foo.png`) or one optional `drafts/` segment (`drafts/foo.png`).
3001
+ // Returns { dirSegment: '' | 'drafts', basename } or null if invalid.
3002
+ function parseValidFilename(filename) {
3003
+ if (!filename || typeof filename !== 'string') return null
3004
+ if (filename.includes('..') || filename.includes('\\')) return null
3005
+ const segments = filename.split('/')
3006
+ if (segments.length === 1) {
3007
+ return segments[0] ? { dirSegment: '', basename: segments[0] } : null
3008
+ }
3009
+ if (segments.length === 2 && segments[0] === 'drafts' && segments[1]) {
3010
+ return { dirSegment: 'drafts', basename: segments[1] }
3011
+ }
3012
+ return null
2769
3013
  }
2770
3014
 
3015
+ // Resolve a stored filename to an absolute path on disk.
3016
+ //
3017
+ // `filename` may be either a bare basename (`foo.png`) or one optional
3018
+ // `drafts/` segment followed by a basename (`drafts/foo.png`). The
3019
+ // `drafts/` form is reserved for images that belong to draft canvases
3020
+ // and resolves only into images/drafts/. Bare basenames intentionally
3021
+ // do NOT fall back to images/drafts/ — that would let a public canvas
3022
+ // serve a draft-only asset just by sharing a basename.
3023
+ //
3024
+ // Returns null when the filename is malformed or the file is not found.
2771
3025
  function resolveImagePath(filename) {
2772
- // Check snapshots dir first, then images
2773
- const snapshotPath = path.join(snapshotsDir, filename)
3026
+ const parsed = parseValidFilename(filename)
3027
+ if (!parsed) return null
3028
+ if (parsed.dirSegment === 'drafts') {
3029
+ const draftPath = path.join(imagesDir, 'drafts', parsed.basename)
3030
+ if (fs.existsSync(draftPath)) return draftPath
3031
+ return null
3032
+ }
3033
+ const snapshotPath = path.join(snapshotsDir, parsed.basename)
2774
3034
  if (fs.existsSync(snapshotPath)) return snapshotPath
2775
- const imagePath = path.join(imagesDir, filename)
3035
+ const imagePath = path.join(imagesDir, parsed.basename)
2776
3036
  if (fs.existsSync(imagePath)) return imagePath
2777
3037
  return null
2778
3038
  }
@@ -2831,26 +3091,33 @@ export function Default() {
2831
3091
  filename = `${prefix}${dateStr}${suffix}.${ext}`
2832
3092
  }
2833
3093
  const targetDir = resolveWriteDir(canvasName || '')
3094
+ // Return the filename with a `drafts/` prefix when the write went to
3095
+ // the drafts subdirectory, so widget `props.src` includes the
3096
+ // directory and the GET route can locate the file unambiguously.
3097
+ const responseFilename =
3098
+ targetDir === path.join(imagesDir, 'drafts') ? `drafts/${filename}` : filename
2834
3099
 
2835
3100
  try {
2836
3101
  fs.mkdirSync(targetDir, { recursive: true })
2837
3102
  fs.writeFileSync(path.join(targetDir, filename), buffer)
2838
- sendJson(res, 201, { success: true, filename })
3103
+ sendJson(res, 201, { success: true, filename: responseFilename })
2839
3104
  } catch (err) {
2840
3105
  sendJson(res, 500, { error: `Failed to save image: ${err.message}` })
2841
3106
  }
2842
3107
  return
2843
3108
  }
2844
3109
 
2845
- // GET /images/<filename> — serve an image file
3110
+ // GET /images/<filename> — serve an image file. Filename may include
3111
+ // an optional `drafts/` directory segment (e.g. `drafts/foo.png`).
2846
3112
  if (routePath.startsWith('/images/') && method === 'GET') {
2847
3113
  // Strip query string (e.g. ?v=123 cache busters) from filename
2848
3114
  let filename = routePath.slice('/images/'.length)
2849
3115
  const qIdx = filename.indexOf('?')
2850
3116
  if (qIdx !== -1) filename = filename.slice(0, qIdx)
2851
3117
 
2852
- // Block path traversal
2853
- if (!filename || filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
3118
+ // Validate (allows optional `drafts/` segment, rejects deeper paths
3119
+ // and path traversal)
3120
+ if (!parseValidFilename(filename)) {
2854
3121
  sendJson(res, 400, { error: 'Invalid filename' })
2855
3122
  return
2856
3123
  }
@@ -2878,7 +3145,8 @@ export function Default() {
2878
3145
  return
2879
3146
  }
2880
3147
 
2881
- // POST /image/duplicate — copy an image file with a new timestamped name
3148
+ // POST /image/duplicate — copy an image file with a new timestamped name.
3149
+ // Accepts a bare basename or one optional `drafts/` segment.
2882
3150
  if (routePath === '/image/duplicate' && method === 'POST') {
2883
3151
  const { filename } = body
2884
3152
 
@@ -2887,7 +3155,8 @@ export function Default() {
2887
3155
  return
2888
3156
  }
2889
3157
 
2890
- if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
3158
+ const parsed = parseValidFilename(filename)
3159
+ if (!parsed) {
2891
3160
  sendJson(res, 400, { error: 'Invalid filename' })
2892
3161
  return
2893
3162
  }
@@ -2899,20 +3168,23 @@ export function Default() {
2899
3168
  }
2900
3169
 
2901
3170
  try {
2902
- const ext = path.extname(filename)
3171
+ const base = parsed.basename
3172
+ const ext = path.extname(base)
2903
3173
  const now = new Date()
2904
3174
  const pad = (n) => String(n).padStart(2, '0')
2905
3175
  const dateStr = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}--${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`
2906
- // Preserve privacy prefix
2907
- const prefix = filename.startsWith('~') ? '~' : ''
2908
- const baseName = filename.replace(/^~/, '').replace(ext, '')
3176
+ // Preserve privacy prefix on the basename
3177
+ const prefix = base.startsWith('~') ? '~' : ''
3178
+ const baseName = base.replace(/^~/, '').replace(ext, '')
2909
3179
  // Extract canvas prefix (everything before the date pattern or the full base)
2910
3180
  const canvasMatch = baseName.match(/^(.+?--)\d{4}-/)
2911
3181
  const canvasPrefix = canvasMatch ? canvasMatch[1] : ''
2912
- const newFilename = `${prefix}${canvasPrefix}${dateStr}${ext}`
3182
+ const newBase = `${prefix}${canvasPrefix}${dateStr}${ext}`
2913
3183
  const targetDir = path.dirname(sourcePath)
2914
- fs.copyFileSync(sourcePath, path.join(targetDir, newFilename))
2915
- sendJson(res, 201, { success: true, filename: newFilename })
3184
+ fs.copyFileSync(sourcePath, path.join(targetDir, newBase))
3185
+ // Return the filename with the same drafts/ prefix as the source
3186
+ const responseFilename = parsed.dirSegment === 'drafts' ? `drafts/${newBase}` : newBase
3187
+ sendJson(res, 201, { success: true, filename: responseFilename })
2916
3188
  } catch (err) {
2917
3189
  sendJson(res, 500, { error: `Failed to duplicate image: ${err.message}` })
2918
3190
  }
@@ -2928,24 +3200,28 @@ export function Default() {
2928
3200
  return
2929
3201
  }
2930
3202
 
2931
- if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
3203
+ const parsed = parseValidFilename(filename)
3204
+ if (!parsed) {
2932
3205
  sendJson(res, 400, { error: 'Invalid filename' })
2933
3206
  return
2934
3207
  }
2935
3208
 
2936
- const isPrivate = filename.startsWith('~')
2937
- const newFilename = isPrivate ? filename.slice(1) : `~${filename}`
3209
+ const base = parsed.basename
3210
+ const isPrivate = base.startsWith('~')
3211
+ const newBase = isPrivate ? base.slice(1) : `~${base}`
2938
3212
  const oldPath = resolveImagePath(filename)
2939
3213
  if (!oldPath) {
2940
3214
  sendJson(res, 404, { error: 'Image not found' })
2941
3215
  return
2942
3216
  }
2943
3217
  const parentDir = path.dirname(oldPath)
2944
- const newPath = path.join(parentDir, newFilename)
3218
+ const newPath = path.join(parentDir, newBase)
3219
+ // Preserve the `drafts/` segment on the returned filename
3220
+ const responseFilename = parsed.dirSegment === 'drafts' ? `drafts/${newBase}` : newBase
2945
3221
 
2946
3222
  try {
2947
3223
  fs.renameSync(oldPath, newPath)
2948
- sendJson(res, 200, { success: true, filename: newFilename, private: !isPrivate })
3224
+ sendJson(res, 200, { success: true, filename: responseFilename, private: !isPrivate })
2949
3225
  } catch (err) {
2950
3226
  sendJson(res, 500, { error: `Failed to toggle private: ${err.message}` })
2951
3227
  }