@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
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Workleaf path helpers.
3
+ *
4
+ * A workleaf is an ephemeral sub-worktree owned by a parent worktree (or
5
+ * main). It lives at:
6
+ *
7
+ * <repo-root>/workleafs/<parent>/workleaf--<slug>/
8
+ *
9
+ * The parent name is encoded in the path — no metadata file is needed.
10
+ * Workleaves cannot be nested: spawning a workleaf from inside another
11
+ * workleaf is refused at the CLI layer using `detectWorkleafContext`.
12
+ *
13
+ * Branch naming convention is `<parent>--workleaf--<slug>` so the branch
14
+ * is self-describing in `git branch` output even without spatial context.
15
+ *
16
+ * This module is intentionally pure and side-effect-free so it can be
17
+ * unit-tested without touching the filesystem (apart from the listing
18
+ * helper, which obviously must read disk).
19
+ */
20
+
21
+ import { existsSync, readdirSync, realpathSync } from 'fs'
22
+ import { join } from 'path'
23
+ import { repoRoot, slugify } from '../worktree/port.js'
24
+
25
+ /** Literal prefix every workleaf directory and branch slug carries. */
26
+ export const WORKLEAF_PREFIX = 'workleaf--'
27
+
28
+ /** Branch convention: `<parent>--workleaf--<slug>`. */
29
+ export const WORKLEAF_BRANCH_SEPARATOR = '--workleaf--'
30
+
31
+ /**
32
+ * Build the absolute on-disk path for a workleaf.
33
+ *
34
+ * @param {string} parent — parent worktree name (e.g. 'main' or '4.3.0')
35
+ * @param {string} name — user-supplied workleaf name (will be slugified)
36
+ * @param {string} [cwd] — for tests; falls back to process.cwd()
37
+ * @returns {string} absolute path
38
+ */
39
+ export function workleafDir(parent, name, cwd) {
40
+ const root = repoRoot(cwd)
41
+ const slug = slugify(name)
42
+ return join(root, 'workleafs', parent, WORKLEAF_PREFIX + slug)
43
+ }
44
+
45
+ /**
46
+ * Compose the git branch name for a workleaf.
47
+ *
48
+ * @param {string} parent
49
+ * @param {string} name
50
+ * @returns {string} e.g. '4.3.0--workleaf--quick-fix'
51
+ */
52
+ export function workleafBranchName(parent, name) {
53
+ const slug = slugify(name)
54
+ return `${parent}${WORKLEAF_BRANCH_SEPARATOR}${slug}`
55
+ }
56
+
57
+ /**
58
+ * Inverse of {@link workleafBranchName}. Splits `parent--workleaf--slug`
59
+ * into its parts. Uses the LAST occurrence of `--workleaf--` so the
60
+ * function survives parent names that contain `--workleaf--` literally
61
+ * (unlikely, but cheap to handle).
62
+ *
63
+ * Returns null if the input is not a workleaf branch.
64
+ */
65
+ export function parentOfWorkleaf(branchName) {
66
+ if (typeof branchName !== 'string') return null
67
+ const idx = branchName.lastIndexOf(WORKLEAF_BRANCH_SEPARATOR)
68
+ if (idx <= 0) return null
69
+ const parent = branchName.slice(0, idx)
70
+ const slug = branchName.slice(idx + WORKLEAF_BRANCH_SEPARATOR.length)
71
+ if (!parent || !slug) return null
72
+ return { parent, slug }
73
+ }
74
+
75
+ /**
76
+ * Detect whether the given cwd is inside a workleaf, and if so, which
77
+ * parent and slug it belongs to.
78
+ *
79
+ * Returns:
80
+ * { inWorkleaf: true, parent, slug, branch, dir, root } when inside
81
+ * { inWorkleaf: false } when outside
82
+ *
83
+ * Uses realpathSync to resolve symlinks before pattern-matching — same
84
+ * convention as `worktree/port.js`. Falls back to the raw cwd if the
85
+ * path does not exist on disk yet (e.g. during dry-run tests).
86
+ */
87
+ export function detectWorkleafContext(cwd = process.cwd()) {
88
+ let realCwd
89
+ try {
90
+ realCwd = realpathSync(cwd)
91
+ } catch {
92
+ realCwd = cwd
93
+ }
94
+
95
+ // Match <root>/workleafs/<parent>/workleaf--<slug> (anywhere within it).
96
+ // Capture groups: 1=root, 2=parent, 3=slug.
97
+ const re = new RegExp(`^(.+)[/\\\\]workleafs[/\\\\]([^/\\\\]+)[/\\\\]${WORKLEAF_PREFIX}([^/\\\\]+)`)
98
+ const m = realCwd.match(re)
99
+ if (!m) return { inWorkleaf: false }
100
+
101
+ const [, root, parent, slug] = m
102
+ return {
103
+ inWorkleaf: true,
104
+ parent,
105
+ slug,
106
+ branch: workleafBranchName(parent, slug),
107
+ dir: join(root, 'workleafs', parent, WORKLEAF_PREFIX + slug),
108
+ root,
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Enumerate every workleaf on disk under <repoRoot>/workleafs/, grouped
114
+ * by parent. Skips entries that don't look like real git worktrees
115
+ * (must contain a `.git` file at the workleaf root).
116
+ *
117
+ * @param {string} [cwd]
118
+ * @returns {Array<{ parent: string, slug: string, branch: string, dir: string }>}
119
+ */
120
+ export function listWorkleafs(cwd) {
121
+ const root = repoRoot(cwd)
122
+ const base = join(root, 'workleafs')
123
+ if (!existsSync(base)) return []
124
+
125
+ const out = []
126
+ for (const parentEntry of readdirSync(base, { withFileTypes: true })) {
127
+ if (!parentEntry.isDirectory()) continue
128
+ const parent = parentEntry.name
129
+ const parentDir = join(base, parent)
130
+ for (const child of readdirSync(parentDir, { withFileTypes: true })) {
131
+ if (!child.isDirectory()) continue
132
+ if (!child.name.startsWith(WORKLEAF_PREFIX)) continue
133
+ const dir = join(parentDir, child.name)
134
+ // A worktree-managed directory has a `.git` file (not dir) inside.
135
+ if (!existsSync(join(dir, '.git'))) continue
136
+ const slug = child.name.slice(WORKLEAF_PREFIX.length)
137
+ out.push({
138
+ parent,
139
+ slug,
140
+ branch: workleafBranchName(parent, slug),
141
+ dir,
142
+ })
143
+ }
144
+ }
145
+ return out
146
+ }
@@ -0,0 +1,230 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, realpathSync } from 'fs'
3
+ import { join } from 'path'
4
+ import { tmpdir } from 'os'
5
+
6
+ import {
7
+ WORKLEAF_PREFIX,
8
+ WORKLEAF_BRANCH_SEPARATOR,
9
+ workleafDir,
10
+ workleafBranchName,
11
+ parentOfWorkleaf,
12
+ detectWorkleafContext,
13
+ listWorkleafs,
14
+ } from './paths.js'
15
+
16
+ describe('workleafBranchName', () => {
17
+ it('builds <parent>--workleaf--<slug>', () => {
18
+ expect(workleafBranchName('main', 'quick-fix')).toBe('main--workleaf--quick-fix')
19
+ expect(workleafBranchName('4.3.0', 'Quick Fix')).toBe('4.3.0--workleaf--quick-fix')
20
+ })
21
+
22
+ it('slugifies the name', () => {
23
+ expect(workleafBranchName('main', 'My Cool.Feature')).toBe('main--workleaf--my-cool-feature')
24
+ })
25
+ })
26
+
27
+ describe('parentOfWorkleaf', () => {
28
+ it('splits a normal workleaf branch', () => {
29
+ expect(parentOfWorkleaf('main--workleaf--quick-fix')).toEqual({ parent: 'main', slug: 'quick-fix' })
30
+ expect(parentOfWorkleaf('4.3.0--workleaf--bench')).toEqual({ parent: '4.3.0', slug: 'bench' })
31
+ })
32
+
33
+ it('returns null for non-workleaf branches', () => {
34
+ expect(parentOfWorkleaf('main')).toBeNull()
35
+ expect(parentOfWorkleaf('4.3.0--feature')).toBeNull()
36
+ expect(parentOfWorkleaf('')).toBeNull()
37
+ })
38
+
39
+ it('returns null for malformed inputs', () => {
40
+ expect(parentOfWorkleaf(null)).toBeNull()
41
+ expect(parentOfWorkleaf(undefined)).toBeNull()
42
+ expect(parentOfWorkleaf(123)).toBeNull()
43
+ })
44
+
45
+ it('uses the LAST --workleaf-- separator (parent names containing it)', () => {
46
+ // A pathological but possible parent name: someone named their parent
47
+ // worktree `something--workleaf--child`. We should still split it correctly.
48
+ expect(parentOfWorkleaf('something--workleaf--child--workleaf--slug')).toEqual({
49
+ parent: 'something--workleaf--child',
50
+ slug: 'slug',
51
+ })
52
+ })
53
+
54
+ it('returns null when parent or slug ends up empty', () => {
55
+ expect(parentOfWorkleaf('--workleaf--slug')).toBeNull()
56
+ expect(parentOfWorkleaf('parent--workleaf--')).toBeNull()
57
+ })
58
+ })
59
+
60
+ describe('workleafDir', () => {
61
+ let tempRoot
62
+ let originalCwd
63
+
64
+ beforeEach(() => {
65
+ tempRoot = realpathSync(mkdtempSync(join(tmpdir(), 'sb-workleaf-dir-')))
66
+ originalCwd = process.cwd()
67
+ process.chdir(tempRoot)
68
+ })
69
+
70
+ afterEach(() => {
71
+ process.chdir(originalCwd)
72
+ rmSync(tempRoot, { recursive: true, force: true })
73
+ })
74
+
75
+ it('returns workleafs/<parent>/workleaf--<slug> when called from repo root', () => {
76
+ expect(workleafDir('main', 'quick-fix')).toBe(
77
+ join(tempRoot, 'workleafs', 'main', 'workleaf--quick-fix')
78
+ )
79
+ })
80
+
81
+ it('slugifies the workleaf name', () => {
82
+ expect(workleafDir('main', 'My Cool.Feature')).toBe(
83
+ join(tempRoot, 'workleafs', 'main', 'workleaf--my-cool-feature')
84
+ )
85
+ })
86
+
87
+ it('resolves repo root even when called from inside a worktree', () => {
88
+ const wt = join(tempRoot, 'worktrees', '4.3.0')
89
+ mkdirSync(wt, { recursive: true })
90
+ expect(workleafDir('4.3.0', 'fix', wt)).toBe(
91
+ join(tempRoot, 'workleafs', '4.3.0', 'workleaf--fix')
92
+ )
93
+ })
94
+
95
+ it('resolves repo root even when called from inside a workleaf', () => {
96
+ const wl = join(tempRoot, 'workleafs', '4.3.0', 'workleaf--outer')
97
+ mkdirSync(wl, { recursive: true })
98
+ expect(workleafDir('4.3.0', 'inner', wl)).toBe(
99
+ join(tempRoot, 'workleafs', '4.3.0', 'workleaf--inner')
100
+ )
101
+ })
102
+ })
103
+
104
+ describe('detectWorkleafContext', () => {
105
+ let tempRoot
106
+
107
+ beforeEach(() => {
108
+ tempRoot = realpathSync(mkdtempSync(join(tmpdir(), 'sb-workleaf-ctx-')))
109
+ })
110
+
111
+ afterEach(() => {
112
+ rmSync(tempRoot, { recursive: true, force: true })
113
+ })
114
+
115
+ it('returns inWorkleaf=false from repo root', () => {
116
+ expect(detectWorkleafContext(tempRoot).inWorkleaf).toBe(false)
117
+ })
118
+
119
+ it('returns inWorkleaf=false from inside a worktree', () => {
120
+ const wt = join(tempRoot, 'worktrees', '4.3.0')
121
+ mkdirSync(wt, { recursive: true })
122
+ expect(detectWorkleafContext(wt).inWorkleaf).toBe(false)
123
+ })
124
+
125
+ it('detects parent=main when cwd is inside a main-parented workleaf', () => {
126
+ const dir = join(tempRoot, 'workleafs', 'main', 'workleaf--fix')
127
+ mkdirSync(dir, { recursive: true })
128
+ const ctx = detectWorkleafContext(dir)
129
+ expect(ctx.inWorkleaf).toBe(true)
130
+ expect(ctx.parent).toBe('main')
131
+ expect(ctx.slug).toBe('fix')
132
+ expect(ctx.branch).toBe('main--workleaf--fix')
133
+ expect(ctx.dir).toBe(dir)
134
+ expect(ctx.root).toBe(tempRoot)
135
+ })
136
+
137
+ it('detects parent=<worktree> when cwd is inside a worktree-parented workleaf', () => {
138
+ const dir = join(tempRoot, 'workleafs', '4.3.0', 'workleaf--bench')
139
+ mkdirSync(dir, { recursive: true })
140
+ const ctx = detectWorkleafContext(dir)
141
+ expect(ctx.inWorkleaf).toBe(true)
142
+ expect(ctx.parent).toBe('4.3.0')
143
+ expect(ctx.slug).toBe('bench')
144
+ expect(ctx.branch).toBe('4.3.0--workleaf--bench')
145
+ expect(ctx.root).toBe(tempRoot)
146
+ })
147
+
148
+ it('detects context from a nested cwd inside a workleaf', () => {
149
+ const dir = join(tempRoot, 'workleafs', 'main', 'workleaf--fix', 'src', 'nested')
150
+ mkdirSync(dir, { recursive: true })
151
+ const ctx = detectWorkleafContext(dir)
152
+ expect(ctx.inWorkleaf).toBe(true)
153
+ expect(ctx.parent).toBe('main')
154
+ expect(ctx.slug).toBe('fix')
155
+ })
156
+
157
+ it('does not match a directory that lacks the workleaf-- prefix', () => {
158
+ const dir = join(tempRoot, 'workleafs', 'main', 'not-a-workleaf')
159
+ mkdirSync(dir, { recursive: true })
160
+ expect(detectWorkleafContext(dir).inWorkleaf).toBe(false)
161
+ })
162
+ })
163
+
164
+ describe('listWorkleafs', () => {
165
+ let tempRoot
166
+
167
+ beforeEach(() => {
168
+ tempRoot = realpathSync(mkdtempSync(join(tmpdir(), 'sb-workleaf-list-')))
169
+ })
170
+
171
+ afterEach(() => {
172
+ rmSync(tempRoot, { recursive: true, force: true })
173
+ })
174
+
175
+ it('returns [] when workleafs/ does not exist', () => {
176
+ expect(listWorkleafs(tempRoot)).toEqual([])
177
+ })
178
+
179
+ it('returns [] when workleafs/ has no valid entries', () => {
180
+ mkdirSync(join(tempRoot, 'workleafs'), { recursive: true })
181
+ expect(listWorkleafs(tempRoot)).toEqual([])
182
+ })
183
+
184
+ it('lists workleafs that have a .git marker', () => {
185
+ const dir = join(tempRoot, 'workleafs', 'main', 'workleaf--fix')
186
+ mkdirSync(dir, { recursive: true })
187
+ writeFileSync(join(dir, '.git'), 'gitdir: /fake')
188
+
189
+ const result = listWorkleafs(tempRoot)
190
+ expect(result).toEqual([
191
+ { parent: 'main', slug: 'fix', branch: 'main--workleaf--fix', dir },
192
+ ])
193
+ })
194
+
195
+ it('skips entries missing the workleaf-- prefix and entries without .git', () => {
196
+ mkdirSync(join(tempRoot, 'workleafs', 'main', 'workleaf--ok'), { recursive: true })
197
+ writeFileSync(join(tempRoot, 'workleafs', 'main', 'workleaf--ok', '.git'), 'gitdir: /fake')
198
+ // Missing .git marker
199
+ mkdirSync(join(tempRoot, 'workleafs', 'main', 'workleaf--no-git'), { recursive: true })
200
+ // Missing prefix
201
+ mkdirSync(join(tempRoot, 'workleafs', 'main', 'just-a-folder'), { recursive: true })
202
+
203
+ const result = listWorkleafs(tempRoot)
204
+ expect(result).toHaveLength(1)
205
+ expect(result[0].slug).toBe('ok')
206
+ })
207
+
208
+ it('groups workleafs across multiple parents', () => {
209
+ const a = join(tempRoot, 'workleafs', 'main', 'workleaf--alpha')
210
+ const b = join(tempRoot, 'workleafs', '4.3.0', 'workleaf--beta')
211
+ mkdirSync(a, { recursive: true })
212
+ mkdirSync(b, { recursive: true })
213
+ writeFileSync(join(a, '.git'), 'gitdir: /fake')
214
+ writeFileSync(join(b, '.git'), 'gitdir: /fake')
215
+
216
+ const result = listWorkleafs(tempRoot)
217
+ const parents = result.map((r) => r.parent).sort()
218
+ expect(parents).toEqual(['4.3.0', 'main'])
219
+ })
220
+ })
221
+
222
+ describe('constants', () => {
223
+ it('exposes WORKLEAF_PREFIX', () => {
224
+ expect(WORKLEAF_PREFIX).toBe('workleaf--')
225
+ })
226
+
227
+ it('exposes WORKLEAF_BRANCH_SEPARATOR', () => {
228
+ expect(WORKLEAF_BRANCH_SEPARATOR).toBe('--workleaf--')
229
+ })
230
+ })
@@ -8,6 +8,9 @@ import * as Alert from '../../../lib/components/ui/alert/index.js'
8
8
 
9
9
  const CANVAS_SUCCESS_KEY = 'sb-canvas-created'
10
10
 
11
+ const selectClass =
12
+ 'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50'
13
+
11
14
  function getApiUrl() {
12
15
  const basePath = window.__STORYBOARD_BASE_PATH__ || '/'
13
16
  return basePath.replace(/\/$/, '') + '/_storyboard/canvas'
@@ -19,8 +22,15 @@ export default function CreateCanvasForm({ onClose }) {
19
22
  const [titleTouched, setTitleTouched] = useState(false)
20
23
  const [description, setDescription] = useState('')
21
24
  const [folder, setFolder] = useState('')
25
+ // Special folder select value indicating the user wants to create a new
26
+ // folder rather than pick an existing one. Triggers the new-folder input.
27
+ const NEW_FOLDER_VALUE = '__new__'
28
+ const [folderSelectValue, setFolderSelectValue] = useState('')
29
+ const [newFolderName, setNewFolderName] = useState('')
30
+ const [newFolderKind, setNewFolderKind] = useState('workspace')
22
31
  const [grid, setGrid] = useState(true)
23
- const [folders, setFolders] = useState([])
32
+ // folderEntries: [{ name, kind: 'workspace' | 'pages' }, ...]
33
+ const [folderEntries, setFolderEntries] = useState([])
24
34
  const [loading, setLoading] = useState(true)
25
35
  const [submitting, setSubmitting] = useState(false)
26
36
  const [error, setError] = useState(null)
@@ -56,8 +66,13 @@ export default function CreateCanvasForm({ onClose }) {
56
66
  )
57
67
 
58
68
  const routePreview = useMemo(
59
- () => (kebabName ? `/canvas/${kebabName}` : ''),
60
- [kebabName],
69
+ () => {
70
+ if (!kebabName) return ''
71
+ // Both `.folder/` and plain dir produce the same URL — `.folder` is
72
+ // stripped by the data plugin's route resolver.
73
+ return folder ? `/canvas/${folder}/${kebabName}` : `/canvas/${kebabName}`
74
+ },
75
+ [kebabName, folder],
61
76
  )
62
77
 
63
78
  const nameError = useMemo(() => {
@@ -68,10 +83,14 @@ export default function CreateCanvasForm({ onClose }) {
68
83
  return null
69
84
  }, [name, kebabName])
70
85
 
71
- const canSubmit = useMemo(
72
- () => !!kebabName && !nameError && !submitting,
73
- [kebabName, nameError, submitting],
74
- )
86
+ const canSubmit = useMemo(() => {
87
+ if (!kebabName || nameError || submitting) return false
88
+ // When the user picked "+ Create new folder" they must also provide a
89
+ // valid (kebab-case) folder name. Other selections (existing folder or
90
+ // "No folder") always have a usable `folder` value already.
91
+ if (folderSelectValue === NEW_FOLDER_VALUE && !folder) return false
92
+ return true
93
+ }, [kebabName, nameError, submitting, folderSelectValue, folder])
75
94
 
76
95
  useEffect(() => {
77
96
  // Restore success state after Vite's full-reload on file creation
@@ -90,7 +109,13 @@ export default function CreateCanvasForm({ onClose }) {
90
109
  const res = await fetch(getApiUrl() + '/folders')
91
110
  if (res.ok) {
92
111
  const data = await res.json()
93
- setFolders(data.folders || [])
112
+ // Prefer the new tagged `entries` shape; fall back to legacy
113
+ // `folders` string array (treat as 'pages' for back-compat).
114
+ if (Array.isArray(data.entries)) {
115
+ setFolderEntries(data.entries.filter((e) => e && e.name))
116
+ } else if (Array.isArray(data.folders)) {
117
+ setFolderEntries(data.folders.map((name) => ({ name, kind: 'pages' })))
118
+ }
94
119
  }
95
120
  } catch { /* empty */ } finally {
96
121
  setLoading(false)
@@ -99,6 +124,38 @@ export default function CreateCanvasForm({ onClose }) {
99
124
  fetchFolders()
100
125
  }, [])
101
126
 
127
+ function handleFolderSelectChange(value) {
128
+ setFolderSelectValue(value)
129
+ if (value === NEW_FOLDER_VALUE) {
130
+ // Defer folder commit until the user types a name.
131
+ setFolder('')
132
+ } else {
133
+ setFolder(value)
134
+ setNewFolderName('')
135
+ }
136
+ }
137
+
138
+ // Whenever the user types into the new-folder input, mirror it into `folder`
139
+ // so the submit handler picks it up. Kebab-case to match canvas folder
140
+ // naming conventions (data plugin / route resolution assumes lowercase).
141
+ const kebabNewFolder = useMemo(
142
+ () =>
143
+ newFolderName
144
+ .replace(/[^a-zA-Z0-9\s_-]/g, '')
145
+ .trim()
146
+ .replace(/[\s_]+/g, '-')
147
+ .toLowerCase()
148
+ .replace(/-+/g, '-')
149
+ .replace(/^-|-$/g, ''),
150
+ [newFolderName],
151
+ )
152
+
153
+ useEffect(() => {
154
+ if (folderSelectValue === NEW_FOLDER_VALUE) {
155
+ setFolder(kebabNewFolder)
156
+ }
157
+ }, [folderSelectValue, kebabNewFolder])
158
+
102
159
  function handleTitleInput(e) {
103
160
  setTitle(e.target.value)
104
161
  setTitleTouched(true)
@@ -123,6 +180,10 @@ export default function CreateCanvasForm({ onClose }) {
123
180
  title: displayTitle,
124
181
  description: description || undefined,
125
182
  folder: folder || undefined,
183
+ // Only meaningful when the folder doesn't exist yet — the server
184
+ // resolves existing folders by inspecting the filesystem and
185
+ // ignores `folderKind` in that case.
186
+ folderKind: folder && folderSelectValue === NEW_FOLDER_VALUE ? newFolderKind : undefined,
126
187
  grid,
127
188
  }),
128
189
  })
@@ -152,45 +213,46 @@ export default function CreateCanvasForm({ onClose }) {
152
213
  }
153
214
 
154
215
  return (
155
- <div onKeyDown={handleKeydown}>
216
+ <>
156
217
  <Panel.Header>
157
218
  <Panel.Title>Create canvas</Panel.Title>
158
- <Panel.Close onClick={onClose} />
219
+ <Panel.Close />
159
220
  </Panel.Header>
160
221
 
161
- <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', padding: '1rem' }}>
162
- <div>
222
+ <div className="p-4 pt-2 space-y-3" onKeyDown={handleKeydown}>
223
+ <div className="space-y-1">
163
224
  <Label htmlFor="canvas-name">Name</Label>
164
225
  <Input
165
226
  id="canvas-name"
166
227
  value={name}
167
228
  onChange={(e) => setName(e.target.value)}
168
229
  placeholder="my-canvas"
230
+ autoComplete="off"
231
+ spellCheck={false}
169
232
  />
170
- {nameError && (
171
- <p style={{ color: 'var(--color-danger-fg, red)', fontSize: '0.875rem', marginTop: '0.25rem' }}>
172
- {nameError}
173
- </p>
174
- )}
233
+ {nameError && <p className="text-sm text-destructive">{nameError}</p>}
175
234
  {routePreview && (
176
- <p style={{ color: 'var(--color-fg-muted, #666)', fontSize: '0.875rem', marginTop: '0.25rem' }}>
177
- {routePreview}
235
+ <p className="text-xs text-muted-foreground">
236
+ Route:{' '}
237
+ <code className="px-1 py-0.5 bg-muted rounded font-mono text-foreground text-xs">
238
+ {routePreview}
239
+ </code>
178
240
  </p>
179
241
  )}
180
242
  </div>
181
243
 
182
- <div>
244
+ <div className="space-y-1">
183
245
  <Label htmlFor="canvas-title">Title</Label>
184
246
  <Input
185
247
  id="canvas-title"
186
248
  value={displayTitle}
187
249
  onChange={handleTitleInput}
188
250
  onBlur={handleTitleBlur}
189
- placeholder="Auto-generated from name"
251
+ placeholder={autoTitle || 'Auto-derived from name'}
190
252
  />
191
253
  </div>
192
254
 
193
- <div>
255
+ <div className="space-y-1">
194
256
  <Label htmlFor="canvas-description">Description</Label>
195
257
  <Input
196
258
  id="canvas-description"
@@ -200,30 +262,64 @@ export default function CreateCanvasForm({ onClose }) {
200
262
  />
201
263
  </div>
202
264
 
203
- <div>
265
+ <div className="space-y-1">
204
266
  <Label htmlFor="canvas-folder">Folder</Label>
205
267
  <select
206
268
  id="canvas-folder"
207
- value={folder}
208
- onChange={(e) => setFolder(e.target.value)}
269
+ className={selectClass}
270
+ value={folderSelectValue}
271
+ onChange={(e) => handleFolderSelectChange(e.target.value)}
209
272
  disabled={loading}
210
273
  >
211
274
  <option value="">No folder</option>
212
- {folders.map((f) => (
213
- <option key={f} value={f}>
214
- {f}
275
+ {folderEntries.map((f) => (
276
+ <option key={`${f.kind}:${f.name}`} value={f.name}>
277
+ {f.name} {f.kind === 'workspace' ? '— workspace folder' : '— canvas pages'}
215
278
  </option>
216
279
  ))}
280
+ <option value={NEW_FOLDER_VALUE}>+ Create new folder…</option>
217
281
  </select>
218
282
  </div>
219
283
 
220
- <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
284
+ {folderSelectValue === NEW_FOLDER_VALUE && (
285
+ <div className="space-y-2 pl-4 border-l-2 border-muted">
286
+ <div className="space-y-1">
287
+ <Label htmlFor="canvas-new-folder-name">New folder name</Label>
288
+ <Input
289
+ id="canvas-new-folder-name"
290
+ value={newFolderName}
291
+ onChange={(e) => setNewFolderName(e.target.value)}
292
+ placeholder="kebab-case-folder"
293
+ />
294
+ </div>
295
+ <div className="space-y-1">
296
+ <Label htmlFor="canvas-new-folder-kind">Folder type</Label>
297
+ <select
298
+ id="canvas-new-folder-kind"
299
+ className={selectClass}
300
+ value={newFolderKind}
301
+ onChange={(e) => setNewFolderKind(e.target.value)}
302
+ >
303
+ <option value="workspace">
304
+ Workspace folder — sibling canvases grouped in the workspace
305
+ </option>
306
+ <option value="pages">
307
+ Canvas pages — multi-page canvas, canvases become pages
308
+ </option>
309
+ </select>
310
+ </div>
311
+ </div>
312
+ )}
313
+
314
+ <div className="flex items-center gap-2">
221
315
  <Checkbox
222
316
  id="canvas-grid"
223
317
  checked={grid}
224
318
  onCheckedChange={setGrid}
225
319
  />
226
- <Label htmlFor="canvas-grid">Show grid</Label>
320
+ <Label htmlFor="canvas-grid" className="text-sm font-normal cursor-pointer">
321
+ Show grid
322
+ </Label>
227
323
  </div>
228
324
 
229
325
  {error && (
@@ -234,12 +330,12 @@ export default function CreateCanvasForm({ onClose }) {
234
330
 
235
331
  {success && (
236
332
  <Alert.Root>
237
- <Alert.Description>
333
+ <Alert.Description className="text-success">
238
334
  {success}
239
335
  {createdRoute && (
240
336
  <>
241
337
  {' — '}
242
- <a href={createdRoute}>Open canvas</a>
338
+ <a href={createdRoute} className="underline">Open canvas</a>
243
339
  </>
244
340
  )}
245
341
  </Alert.Description>
@@ -252,9 +348,9 @@ export default function CreateCanvasForm({ onClose }) {
252
348
  Cancel
253
349
  </Button>
254
350
  <Button onClick={submit} disabled={!canSubmit}>
255
- {submitting ? 'Creating' : 'Create'}
351
+ {submitting ? 'Creating\u2026' : 'Create'}
256
352
  </Button>
257
353
  </Panel.Footer>
258
- </div>
354
+ </>
259
355
  )
260
356
  }
@@ -13,6 +13,7 @@ import {
13
13
  buildTemplateRecipeIndex,
14
14
  resolveTemplateRecipeEntry,
15
15
  } from '../templateIndex.js'
16
+ import { readWorkshopPartials } from '../partialRender.js'
16
17
 
17
18
  // ---------------------------------------------------------------------------
18
19
  // Helpers
@@ -461,7 +462,10 @@ function generateFlowJson({ title, author, description, globals, sourceData, rou
461
462
  */
462
463
  export function createFlowsHandler(ctx) {
463
464
  const { root, sendJson, workshopConfig = {} } = ctx
464
- const getTemplateRecipes = () => buildTemplateRecipeIndex(root, workshopConfig.partials)
465
+ const getTemplateRecipes = () => {
466
+ const partials = readWorkshopPartials(root)
467
+ return buildTemplateRecipeIndex(root, partials.length ? partials : workshopConfig.partials)
468
+ }
465
469
 
466
470
  return async (req, res, { body, path: routePath, method }) => {
467
471
  const templateRecipes = getTemplateRecipes()