@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
@@ -14,7 +14,7 @@ function setup() {
14
14
  const canvasDir = path.join(root, 'src', 'canvas')
15
15
  fs.mkdirSync(canvasDir, { recursive: true })
16
16
 
17
- const lastResponse = { status: null, body: null }
17
+ const lastResponse = { status: null, body: null, bytes: null, headers: null }
18
18
 
19
19
  const ctx = {
20
20
  root,
@@ -26,8 +26,24 @@ function setup() {
26
26
 
27
27
  const handler = createCanvasHandler(ctx)
28
28
 
29
+ // Streaming mock for binary GET responses (image fetches). Records the
30
+ // bytes + headers so tests can assert without coupling to real http.
31
+ function makeRes() {
32
+ let buf = Buffer.alloc(0)
33
+ return {
34
+ writeHead(status, headers) {
35
+ lastResponse.status = status
36
+ lastResponse.headers = headers
37
+ },
38
+ end(data) {
39
+ if (data) buf = Buffer.concat([buf, Buffer.isBuffer(data) ? data : Buffer.from(data)])
40
+ lastResponse.bytes = buf
41
+ },
42
+ }
43
+ }
44
+
29
45
  const invoke = (routePath, method, body = {}) =>
30
- handler(null, null, { path: routePath, method, body })
46
+ handler(null, makeRes(), { path: routePath, method, body })
31
47
 
32
48
  return { invoke, root, canvasDir, lastResponse }
33
49
  }
@@ -377,3 +393,457 @@ describe('POST /batch', () => {
377
393
  expect(refs['1']).toMatch(/^connector-/)
378
394
  })
379
395
  })
396
+
397
+ describe('POST /undo and POST /redo (event-id targeting)', () => {
398
+ let root, canvasDir, invoke, lastResponse
399
+
400
+ beforeEach(() => {
401
+ ({ invoke, root, canvasDir, lastResponse } = setup())
402
+ writeCanvas(canvasDir, 'undo-canvas', { title: 'Undo Canvas' })
403
+ })
404
+
405
+ afterEach(() => {
406
+ fs.rmSync(root, { recursive: true, force: true })
407
+ })
408
+
409
+ async function addSticky(text) {
410
+ await invoke('/widget', 'POST', {
411
+ name: 'undo-canvas',
412
+ type: 'sticky-note',
413
+ props: { text },
414
+ position: { x: 0, y: 0 },
415
+ })
416
+ return { widgetId: lastResponse.body.widget.id, eventId: lastResponse.body.eventId }
417
+ }
418
+
419
+ async function readState() {
420
+ await invoke('/read?name=undo-canvas', 'GET', {})
421
+ return lastResponse.body
422
+ }
423
+
424
+ it('appends an event id on every mutation', async () => {
425
+ const { eventId } = await addSticky('a')
426
+ expect(eventId).toMatch(/^evt_/)
427
+ })
428
+
429
+ it('reproduces the cq-enablement scenario: 3 deletes + undo restores only the most recent', async () => {
430
+ const a = await addSticky('a')
431
+ const b = await addSticky('b')
432
+ const c = await addSticky('c')
433
+
434
+ await invoke('/widget', 'DELETE', { name: 'undo-canvas', widgetId: a.widgetId })
435
+ const delAEventId = lastResponse.body.eventId
436
+ await invoke('/widget', 'DELETE', { name: 'undo-canvas', widgetId: b.widgetId })
437
+ const delBEventId = lastResponse.body.eventId
438
+ await invoke('/widget', 'DELETE', { name: 'undo-canvas', widgetId: c.widgetId })
439
+ const delCEventId = lastResponse.body.eventId
440
+
441
+ expect((await readState()).widgets).toEqual([])
442
+
443
+ // Undo only the LAST delete (c).
444
+ await invoke('/undo', 'POST', { name: 'undo-canvas', eventId: delCEventId })
445
+ expect(lastResponse.status).toBe(200)
446
+ expect(lastResponse.body.inverseEvent.event).toBe('widget_added')
447
+ expect(lastResponse.body.inverseEvent.meta).toEqual({ kind: 'undo', of: delCEventId })
448
+
449
+ const after = await readState()
450
+ expect(after.widgets).toHaveLength(1)
451
+ expect(after.widgets[0].id).toBe(c.widgetId)
452
+ // a and b stay deleted — the bug class would have resurrected them too.
453
+ expect(after.widgets.map((w) => w.id)).not.toContain(a.widgetId)
454
+ expect(after.widgets.map((w) => w.id)).not.toContain(b.widgetId)
455
+
456
+ // Tracking that we don't leave delAEventId / delBEventId mentioned.
457
+ void delAEventId
458
+ void delBEventId
459
+ })
460
+
461
+ it('undo of widget_updated reverts the props using prevProps', async () => {
462
+ const { widgetId } = await addSticky('original')
463
+
464
+ await invoke('/widget', 'PATCH', {
465
+ name: 'undo-canvas',
466
+ widgetId,
467
+ props: { text: 'edited' },
468
+ })
469
+ const editEventId = lastResponse.body.eventIds[0]
470
+
471
+ let state = await readState()
472
+ expect(state.widgets[0].props.text).toBe('edited')
473
+
474
+ await invoke('/undo', 'POST', { name: 'undo-canvas', eventId: editEventId })
475
+ state = await readState()
476
+ expect(state.widgets[0].props.text).toBe('original')
477
+ })
478
+
479
+ it('undo of widget_moved reverts the position using prevPosition', async () => {
480
+ const { widgetId } = await addSticky('a')
481
+
482
+ await invoke('/widget', 'PATCH', {
483
+ name: 'undo-canvas',
484
+ widgetId,
485
+ position: { x: 500, y: 600 },
486
+ })
487
+ const moveEventId = lastResponse.body.eventIds[0]
488
+
489
+ let state = await readState()
490
+ expect(state.widgets[0].position).toEqual({ x: 500, y: 600 })
491
+
492
+ await invoke('/undo', 'POST', { name: 'undo-canvas', eventId: moveEventId })
493
+ state = await readState()
494
+ expect(state.widgets[0].position).toEqual({ x: 0, y: 0 })
495
+ })
496
+
497
+ it('redo of an undone delete re-removes the widget', async () => {
498
+ const { widgetId } = await addSticky('a')
499
+ await invoke('/widget', 'DELETE', { name: 'undo-canvas', widgetId })
500
+ const delEventId = lastResponse.body.eventId
501
+
502
+ await invoke('/undo', 'POST', { name: 'undo-canvas', eventId: delEventId })
503
+ const undoEventId = lastResponse.body.eventId
504
+ expect((await readState()).widgets).toHaveLength(1)
505
+
506
+ // Redo with the undo event id — server emits the inverse of the undo,
507
+ // which is a fresh widget_removed.
508
+ await invoke('/redo', 'POST', { name: 'undo-canvas', eventId: undoEventId })
509
+ expect(lastResponse.body.inverseEvent.event).toBe('widget_removed')
510
+ expect(lastResponse.body.inverseEvent.meta).toEqual({ kind: 'redo', of: undoEventId })
511
+
512
+ expect((await readState()).widgets).toEqual([])
513
+ })
514
+
515
+ it('rejects undo on an unknown event id with 404', async () => {
516
+ await invoke('/undo', 'POST', { name: 'undo-canvas', eventId: 'evt_does_not_exist' })
517
+ expect(lastResponse.status).toBe(404)
518
+ })
519
+
520
+ it('rejects undo on a non-undoable event (widgets_replaced) with 400', async () => {
521
+ // Trigger a widgets_replaced via PUT /update with replaceAll
522
+ await invoke('/update', 'PUT', {
523
+ name: 'undo-canvas',
524
+ widgets: [],
525
+ replaceAll: true,
526
+ })
527
+ // Read the file to get the event id of the widgets_replaced event.
528
+ const filePath = path.join(canvasDir, 'undo-canvas.canvas.jsonl')
529
+ const lines = fs.readFileSync(filePath, 'utf-8').trim().split('\n').map((l) => JSON.parse(l))
530
+ const replacedEvt = lines.find((l) => l.event === 'widgets_replaced')
531
+ expect(replacedEvt?.id).toBeTruthy()
532
+
533
+ await invoke('/undo', 'POST', { name: 'undo-canvas', eventId: replacedEvt.id })
534
+ expect(lastResponse.status).toBe(400)
535
+ expect(lastResponse.body.error).toMatch(/cannot be undone/i)
536
+ })
537
+ })
538
+
539
+ describe('runaway-session hard ceiling compaction', () => {
540
+ let root, canvasDir, invoke, lastResponse
541
+
542
+ beforeEach(() => {
543
+ ({ invoke, root, canvasDir, lastResponse } = setup())
544
+ writeCanvas(canvasDir, 'big-canvas', { title: 'Big Canvas' })
545
+ })
546
+
547
+ afterEach(() => {
548
+ fs.rmSync(root, { recursive: true, force: true })
549
+ })
550
+
551
+ // setImmediate-based test helper
552
+ function flushSetImmediate() {
553
+ return new Promise((resolve) => setImmediate(resolve))
554
+ }
555
+
556
+ it('does not compact a small file (under the 2 MB threshold)', async () => {
557
+ const filePath = path.join(canvasDir, 'big-canvas.canvas.jsonl')
558
+ await invoke('/widget', 'POST', {
559
+ name: 'big-canvas',
560
+ type: 'sticky-note',
561
+ props: { text: 'hello' },
562
+ position: { x: 0, y: 0 },
563
+ })
564
+ await flushSetImmediate()
565
+ const sizeAfter = fs.statSync(filePath).size
566
+ expect(sizeAfter).toBeLessThan(2 * 1024 * 1024)
567
+ // File should still contain both canvas_created + widget_added events.
568
+ const events = fs.readFileSync(filePath, 'utf-8').trim().split('\n').map((l) => JSON.parse(l))
569
+ expect(events.length).toBe(2)
570
+ expect(events[0].event).toBe('canvas_created')
571
+ expect(events[1].event).toBe('widget_added')
572
+ })
573
+
574
+ it('compacts the file once it exceeds the runaway threshold', async () => {
575
+ const filePath = path.join(canvasDir, 'big-canvas.canvas.jsonl')
576
+
577
+ // Pre-bloat the file past 2 MB by appending raw garbage events. Each
578
+ // event has a unique id so the materializer treats it as valid.
579
+ const padding = 'x'.repeat(4000)
580
+ const bloatLines = []
581
+ for (let i = 0; i < 600; i++) {
582
+ bloatLines.push(JSON.stringify({
583
+ id: `evt_bloat_${i}`,
584
+ event: 'widget_added',
585
+ timestamp: '2026-01-01',
586
+ widget: { id: `bloat-${i}`, type: 'sticky-note', position: { x: 0, y: 0 }, props: { text: padding } },
587
+ }))
588
+ }
589
+ fs.appendFileSync(filePath, bloatLines.join('\n') + '\n')
590
+ const sizeBefore = fs.statSync(filePath).size
591
+ expect(sizeBefore).toBeGreaterThan(2 * 1024 * 1024)
592
+
593
+ // Trigger appendEvent — this should detect the runaway size and kick
594
+ // off a compaction.
595
+ await invoke('/widget', 'POST', {
596
+ name: 'big-canvas',
597
+ type: 'sticky-note',
598
+ props: { text: 'trigger' },
599
+ position: { x: 0, y: 0 },
600
+ })
601
+ // Compaction runs on setImmediate; flush it.
602
+ await flushSetImmediate()
603
+ await flushSetImmediate()
604
+
605
+ const sizeAfter = fs.statSync(filePath).size
606
+ expect(sizeAfter).toBeLessThan(sizeBefore)
607
+
608
+ // After compaction, file should be a single canvas_created event
609
+ // containing all the materialized widgets.
610
+ const lines = fs.readFileSync(filePath, 'utf-8').trim().split('\n')
611
+ expect(lines.length).toBe(1)
612
+ const baseline = JSON.parse(lines[0])
613
+ expect(baseline.event).toBe('canvas_created')
614
+ // 600 bloat widgets + 1 from the triggering append = 601.
615
+ expect(baseline.widgets.length).toBe(601)
616
+ // Sanity: state still reads cleanly via the normal /read endpoint.
617
+ await invoke('/read?name=big-canvas', 'GET', {})
618
+ expect(lastResponse.status).toBe(200)
619
+ expect(lastResponse.body.widgets.length).toBe(601)
620
+ })
621
+ })
622
+
623
+ describe('image routes — drafts/ subdirectory privacy layer', () => {
624
+ // Minimal 1x1 PNG (base64) for upload tests.
625
+ const PNG_DATA_URL =
626
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgAAIAAAUAAeImBZsAAAAASUVORK5CYII='
627
+
628
+ it('routes a draft-canvas upload into images/drafts/ and returns the drafts/ prefix', async () => {
629
+ const { invoke, root, lastResponse } = setup()
630
+
631
+ await invoke('/image', 'POST', { dataUrl: PNG_DATA_URL, canvasName: 'drafts/storyboarding' })
632
+
633
+ expect(lastResponse.status).toBe(201)
634
+ expect(lastResponse.body.success).toBe(true)
635
+ expect(lastResponse.body.filename).toMatch(/^drafts\//)
636
+ const filename = lastResponse.body.filename
637
+ const basename = filename.slice('drafts/'.length)
638
+ const draftFile = path.join(root, 'assets', 'canvas', 'images', 'drafts', basename)
639
+ const publicFile = path.join(root, 'assets', 'canvas', 'images', basename)
640
+ expect(fs.existsSync(draftFile)).toBe(true)
641
+ expect(fs.existsSync(publicFile)).toBe(false)
642
+ })
643
+
644
+ it('routes a non-draft-canvas upload into images/ as before', async () => {
645
+ const { invoke, root, lastResponse } = setup()
646
+
647
+ await invoke('/image', 'POST', { dataUrl: PNG_DATA_URL, canvasName: 'storyboarding' })
648
+
649
+ expect(lastResponse.status).toBe(201)
650
+ expect(lastResponse.body.filename).not.toMatch(/^drafts\//)
651
+ const filename = lastResponse.body.filename
652
+ const publicFile = path.join(root, 'assets', 'canvas', 'images', filename)
653
+ expect(fs.existsSync(publicFile)).toBe(true)
654
+ })
655
+
656
+ it('treats a canvas with `drafts` anywhere in its segments as draft', async () => {
657
+ const { invoke, root, lastResponse } = setup()
658
+
659
+ await invoke('/image', 'POST', { dataUrl: PNG_DATA_URL, canvasName: 'widget/drafts/v6' })
660
+
661
+ expect(lastResponse.status).toBe(201)
662
+ expect(lastResponse.body.filename).toMatch(/^drafts\//)
663
+ const draftFile = path.join(root, 'assets', 'canvas', 'images', 'drafts',
664
+ lastResponse.body.filename.slice('drafts/'.length))
665
+ expect(fs.existsSync(draftFile)).toBe(true)
666
+ })
667
+
668
+ it('GET /images/drafts/<basename> serves a draft image', async () => {
669
+ const { invoke, root, lastResponse } = setup()
670
+ const draftsDir = path.join(root, 'assets', 'canvas', 'images', 'drafts')
671
+ fs.mkdirSync(draftsDir, { recursive: true })
672
+ const payload = Buffer.from([1, 2, 3])
673
+ fs.writeFileSync(path.join(draftsDir, 'hello.png'), payload)
674
+
675
+ await invoke('/images/drafts/hello.png', 'GET', {})
676
+ expect(lastResponse.status).toBe(200)
677
+ expect(lastResponse.bytes).toEqual(payload)
678
+ })
679
+
680
+ it('GET /images/<basename> does NOT fall back to drafts/', async () => {
681
+ const { invoke, root, lastResponse } = setup()
682
+ const draftsDir = path.join(root, 'assets', 'canvas', 'images', 'drafts')
683
+ fs.mkdirSync(draftsDir, { recursive: true })
684
+ fs.writeFileSync(path.join(draftsDir, 'leak.png'), Buffer.from([1, 2, 3]))
685
+
686
+ await invoke('/images/leak.png', 'GET', {})
687
+ expect(lastResponse.status).toBe(404)
688
+ })
689
+
690
+ it('GET rejects deeper paths than one optional drafts/ segment', async () => {
691
+ const { invoke, lastResponse } = setup()
692
+
693
+ await invoke('/images/drafts/sub/foo.png', 'GET', {})
694
+ expect(lastResponse.status).toBe(400)
695
+ })
696
+
697
+ it('GET rejects path traversal even with drafts prefix', async () => {
698
+ const { invoke, lastResponse } = setup()
699
+
700
+ await invoke('/images/drafts/../etc.png', 'GET', {})
701
+ expect(lastResponse.status).toBe(400)
702
+ })
703
+
704
+ it('toggle-private preserves drafts/ prefix in returned filename', async () => {
705
+ const { invoke, root, lastResponse } = setup()
706
+ const draftsDir = path.join(root, 'assets', 'canvas', 'images', 'drafts')
707
+ fs.mkdirSync(draftsDir, { recursive: true })
708
+ fs.writeFileSync(path.join(draftsDir, 'foo.png'), Buffer.from([1, 2, 3]))
709
+
710
+ await invoke('/image/toggle-private', 'POST', { filename: 'drafts/foo.png' })
711
+
712
+ expect(lastResponse.status).toBe(200)
713
+ expect(lastResponse.body).toEqual({ success: true, filename: 'drafts/~foo.png', private: true })
714
+ expect(fs.existsSync(path.join(draftsDir, '~foo.png'))).toBe(true)
715
+ expect(fs.existsSync(path.join(draftsDir, 'foo.png'))).toBe(false)
716
+ })
717
+
718
+ it('toggle-private still works on bare basenames in images/', async () => {
719
+ const { invoke, root, lastResponse } = setup()
720
+ const imagesDir = path.join(root, 'assets', 'canvas', 'images')
721
+ fs.mkdirSync(imagesDir, { recursive: true })
722
+ fs.writeFileSync(path.join(imagesDir, 'bar.png'), Buffer.from([1, 2, 3]))
723
+
724
+ await invoke('/image/toggle-private', 'POST', { filename: 'bar.png' })
725
+
726
+ expect(lastResponse.status).toBe(200)
727
+ expect(lastResponse.body.filename).toBe('~bar.png')
728
+ expect(fs.existsSync(path.join(imagesDir, '~bar.png'))).toBe(true)
729
+ })
730
+
731
+ it('duplicate preserves drafts/ prefix', async () => {
732
+ const { invoke, root, lastResponse } = setup()
733
+ const draftsDir = path.join(root, 'assets', 'canvas', 'images', 'drafts')
734
+ fs.mkdirSync(draftsDir, { recursive: true })
735
+ fs.writeFileSync(path.join(draftsDir, 'storyboarding--2026-01-01--12-00-00.png'), Buffer.from([1, 2, 3]))
736
+
737
+ await invoke('/image/duplicate', 'POST', { filename: 'drafts/storyboarding--2026-01-01--12-00-00.png' })
738
+
739
+ expect(lastResponse.status).toBe(201)
740
+ expect(lastResponse.body.filename).toMatch(/^drafts\/storyboarding--/)
741
+ })
742
+ })
743
+
744
+ describe('GET /folders and POST /create with folderKind', () => {
745
+ let root, canvasDir, invoke, lastResponse
746
+
747
+ beforeEach(() => {
748
+ ({ invoke, root, canvasDir, lastResponse } = setup())
749
+ })
750
+
751
+ afterEach(() => {
752
+ fs.rmSync(root, { recursive: true, force: true })
753
+ })
754
+
755
+ it('GET /folders returns tagged entries distinguishing workspace from pages', async () => {
756
+ // Workspace folder (`.folder/` suffix) containing one canvas
757
+ fs.mkdirSync(path.join(canvasDir, 'marketing.folder'), { recursive: true })
758
+ writeCanvas(path.join(canvasDir, 'marketing.folder'), 'landing')
759
+ // Plain dir (multi-page canvas group) containing two canvases
760
+ fs.mkdirSync(path.join(canvasDir, 'tour'), { recursive: true })
761
+ writeCanvas(path.join(canvasDir, 'tour'), 'overview')
762
+ writeCanvas(path.join(canvasDir, 'tour'), 'detail')
763
+
764
+ await invoke('/folders', 'GET')
765
+
766
+ expect(lastResponse.status).toBe(200)
767
+ // Legacy flat list still present for back-compat
768
+ expect(lastResponse.body.folders).toContain('marketing')
769
+ expect(lastResponse.body.folders).toContain('tour')
770
+ // New tagged entries carry the kind
771
+ const entries = lastResponse.body.entries
772
+ expect(entries).toEqual(expect.arrayContaining([
773
+ { name: 'marketing', kind: 'workspace' },
774
+ { name: 'tour', kind: 'pages' },
775
+ ]))
776
+ })
777
+
778
+ it('POST /create with folderKind:"workspace" creates a new .folder/ dir', async () => {
779
+ await invoke('/create', 'POST', {
780
+ name: 'landing',
781
+ folder: 'marketing',
782
+ folderKind: 'workspace',
783
+ title: 'Landing',
784
+ })
785
+
786
+ expect(lastResponse.status).toBe(201)
787
+ expect(fs.existsSync(path.join(canvasDir, 'marketing.folder', 'landing.canvas.jsonl'))).toBe(true)
788
+ // Plain dir must NOT have been created
789
+ expect(fs.existsSync(path.join(canvasDir, 'marketing'))).toBe(false)
790
+ })
791
+
792
+ it('POST /create with folderKind:"pages" (or omitted) creates a plain dir', async () => {
793
+ await invoke('/create', 'POST', {
794
+ name: 'overview',
795
+ folder: 'tour',
796
+ folderKind: 'pages',
797
+ title: 'Overview',
798
+ })
799
+
800
+ expect(lastResponse.status).toBe(201)
801
+ expect(fs.existsSync(path.join(canvasDir, 'tour', 'overview.canvas.jsonl'))).toBe(true)
802
+ // .folder/ variant must NOT have been created
803
+ expect(fs.existsSync(path.join(canvasDir, 'tour.folder'))).toBe(false)
804
+ })
805
+
806
+ it('POST /create defaults to plain dir when folderKind is omitted (backward compat)', async () => {
807
+ await invoke('/create', 'POST', {
808
+ name: 'overview',
809
+ folder: 'tour',
810
+ title: 'Overview',
811
+ })
812
+
813
+ expect(lastResponse.status).toBe(201)
814
+ expect(fs.existsSync(path.join(canvasDir, 'tour', 'overview.canvas.jsonl'))).toBe(true)
815
+ expect(fs.existsSync(path.join(canvasDir, 'tour.folder'))).toBe(false)
816
+ })
817
+
818
+ it('POST /create reuses existing .folder/ regardless of folderKind', async () => {
819
+ fs.mkdirSync(path.join(canvasDir, 'marketing.folder'), { recursive: true })
820
+
821
+ // Even with folderKind:"pages", the existing .folder/ wins (filesystem
822
+ // truth beats a hint).
823
+ await invoke('/create', 'POST', {
824
+ name: 'landing',
825
+ folder: 'marketing',
826
+ folderKind: 'pages',
827
+ title: 'Landing',
828
+ })
829
+
830
+ expect(lastResponse.status).toBe(201)
831
+ expect(fs.existsSync(path.join(canvasDir, 'marketing.folder', 'landing.canvas.jsonl'))).toBe(true)
832
+ expect(fs.existsSync(path.join(canvasDir, 'marketing'))).toBe(false)
833
+ })
834
+
835
+ it('POST /create reuses existing plain dir regardless of folderKind', async () => {
836
+ fs.mkdirSync(path.join(canvasDir, 'tour'), { recursive: true })
837
+
838
+ await invoke('/create', 'POST', {
839
+ name: 'overview',
840
+ folder: 'tour',
841
+ folderKind: 'workspace',
842
+ title: 'Overview',
843
+ })
844
+
845
+ expect(lastResponse.status).toBe(201)
846
+ expect(fs.existsSync(path.join(canvasDir, 'tour', 'overview.canvas.jsonl'))).toBe(true)
847
+ expect(fs.existsSync(path.join(canvasDir, 'tour.folder'))).toBe(false)
848
+ })
849
+ })