@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,126 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+
3
+ import {
4
+ initCanvasInteraction,
5
+ setCanvasInteraction,
6
+ getCanvasInteraction,
7
+ subscribeToCanvasInteraction,
8
+ getCanvasInteractionSnapshot,
9
+ _resetCanvasInteraction,
10
+ } from './canvasInteractionStore.js'
11
+
12
+ describe('canvasInteractionStore', () => {
13
+ beforeEach(() => {
14
+ _resetCanvasInteraction()
15
+ })
16
+
17
+ it('starts with defaults: scrollAxis=both, zoomGestures=true', () => {
18
+ expect(getCanvasInteraction()).toEqual({ scrollAxis: 'both', zoomGestures: true })
19
+ })
20
+
21
+ it('initCanvasInteraction seeds from config', () => {
22
+ initCanvasInteraction({ scrollAxis: 'vertical', zoomGestures: false })
23
+ expect(getCanvasInteraction()).toEqual({ scrollAxis: 'vertical', zoomGestures: false })
24
+ })
25
+
26
+ it('initCanvasInteraction with no args / empty / invalid resets to defaults', () => {
27
+ initCanvasInteraction({ scrollAxis: 'vertical', zoomGestures: false })
28
+ initCanvasInteraction()
29
+ expect(getCanvasInteraction()).toEqual({ scrollAxis: 'both', zoomGestures: true })
30
+
31
+ initCanvasInteraction({ scrollAxis: 'vertical', zoomGestures: false })
32
+ initCanvasInteraction({})
33
+ expect(getCanvasInteraction()).toEqual({ scrollAxis: 'both', zoomGestures: true })
34
+ })
35
+
36
+ it('initCanvasInteraction ignores invalid scrollAxis values', () => {
37
+ initCanvasInteraction({ scrollAxis: 'sideways' })
38
+ expect(getCanvasInteraction().scrollAxis).toBe('both')
39
+
40
+ initCanvasInteraction({ scrollAxis: null })
41
+ expect(getCanvasInteraction().scrollAxis).toBe('both')
42
+
43
+ initCanvasInteraction({ scrollAxis: 42 })
44
+ expect(getCanvasInteraction().scrollAxis).toBe('both')
45
+ })
46
+
47
+ it('initCanvasInteraction ignores non-boolean zoomGestures', () => {
48
+ initCanvasInteraction({ zoomGestures: 'yes' })
49
+ expect(getCanvasInteraction().zoomGestures).toBe(true)
50
+
51
+ initCanvasInteraction({ zoomGestures: 1 })
52
+ expect(getCanvasInteraction().zoomGestures).toBe(true)
53
+ })
54
+
55
+ it('accepts all four valid axis values', () => {
56
+ for (const axis of ['both', 'vertical', 'horizontal', 'none']) {
57
+ initCanvasInteraction({ scrollAxis: axis })
58
+ expect(getCanvasInteraction().scrollAxis).toBe(axis)
59
+ }
60
+ })
61
+
62
+ it('setCanvasInteraction patches partial state without resetting other keys', () => {
63
+ initCanvasInteraction({ scrollAxis: 'vertical', zoomGestures: false })
64
+
65
+ setCanvasInteraction({ zoomGestures: true })
66
+ expect(getCanvasInteraction()).toEqual({ scrollAxis: 'vertical', zoomGestures: true })
67
+
68
+ setCanvasInteraction({ scrollAxis: 'horizontal' })
69
+ expect(getCanvasInteraction()).toEqual({ scrollAxis: 'horizontal', zoomGestures: true })
70
+ })
71
+
72
+ it('setCanvasInteraction with no-op patch does not notify subscribers', () => {
73
+ const cb = vi.fn()
74
+ subscribeToCanvasInteraction(cb)
75
+ // Reset count: subscribers fire on initial reset_+ subscribe etc.
76
+ cb.mockClear()
77
+
78
+ setCanvasInteraction({}) // no keys
79
+ setCanvasInteraction({ scrollAxis: 'invalid' }) // ignored
80
+ setCanvasInteraction({ scrollAxis: 'both' }) // same as default
81
+ setCanvasInteraction({ zoomGestures: true }) // same as default
82
+ expect(cb).not.toHaveBeenCalled()
83
+
84
+ setCanvasInteraction({ zoomGestures: false }) // real change
85
+ expect(cb).toHaveBeenCalledTimes(1)
86
+ })
87
+
88
+ it('subscribers are notified on init and meaningful set; unsubscribe stops further notifications', () => {
89
+ const cb = vi.fn()
90
+ const unsub = subscribeToCanvasInteraction(cb)
91
+
92
+ initCanvasInteraction({ scrollAxis: 'vertical' })
93
+ setCanvasInteraction({ zoomGestures: false })
94
+
95
+ expect(cb).toHaveBeenCalledTimes(2)
96
+ unsub()
97
+
98
+ setCanvasInteraction({ scrollAxis: 'horizontal' })
99
+ expect(cb).toHaveBeenCalledTimes(2)
100
+ })
101
+
102
+ it('snapshot version changes on every meaningful mutation', () => {
103
+ const v0 = getCanvasInteractionSnapshot()
104
+ initCanvasInteraction({ scrollAxis: 'vertical' })
105
+ const v1 = getCanvasInteractionSnapshot()
106
+ expect(v1).not.toBe(v0)
107
+
108
+ setCanvasInteraction({ zoomGestures: false })
109
+ const v2 = getCanvasInteractionSnapshot()
110
+ expect(v2).not.toBe(v1)
111
+ })
112
+
113
+ it('subscriber errors are isolated', () => {
114
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
115
+ const goodCb = vi.fn()
116
+
117
+ subscribeToCanvasInteraction(() => { throw new Error('boom') })
118
+ subscribeToCanvasInteraction(goodCb)
119
+
120
+ setCanvasInteraction({ zoomGestures: false })
121
+
122
+ expect(goodCb).toHaveBeenCalled()
123
+ expect(errorSpy).toHaveBeenCalled()
124
+ errorSpy.mockRestore()
125
+ })
126
+ })
@@ -42,7 +42,6 @@
42
42
  * @property {Record<string, CanvasAgentConfig>} [agents] — per-agent overrides
43
43
  * @property {CanvasZoomConfig} [zoom] — zoom min/max/step settings
44
44
  * @property {CanvasMessagingConfig} [messaging] — hub messaging settings
45
- * @property {boolean} [inlineStories] — render StoryWidget inline (no iframe). Default false.
46
45
  */
47
46
 
48
47
  /**
@@ -79,7 +78,7 @@
79
78
  * @property {number} [default_max_pool_size] — default surge cap per pool (default: 3)
80
79
  * @property {boolean} [load_balancer] — enable auto-scaling (default: true)
81
80
  * @property {number} [load_balancer_cooldown_mins] — minutes idle before scale-down (default: 10)
82
- * @property {Record<string, { pool_size?: number, max_pool_size?: number, webgl_ready_slots?: number }>} [pools] — per-pool overrides (terminal, prompt, copilot, claude, codex). webgl_ready_slots: how many front-of-queue sessions should start with PINNED WebGL priority (default: 0)
81
+ * @property {Record<string, { pool_size?: number, max_pool_size?: number }>} [pools] — per-pool overrides (terminal, prompt, copilot, claude, codex).
83
82
  */
84
83
 
85
84
  /**
@@ -121,10 +120,30 @@
121
120
 
122
121
  /**
123
122
  * @typedef {object} CustomerModeConfig
124
- * @property {boolean} enabled — master toggle for customer mode
125
- * @property {boolean} hideChrome — hides all toolbars (except canvas tools), branchbar, cmd+k, cmd+.
126
- * @property {boolean} hideHomepage — removes the storyboard homepage (leaves empty page)
127
- * @property {string} protoHomepage — internal /path that replaces homepage; redirects from / and /workspace
123
+ * @property {boolean} enabled — master toggle for customer mode
124
+ * @property {boolean} hideChrome — hides all toolbars (except canvas tools), branchbar, cmd+k, cmd+.
125
+ * @property {boolean} hideHomepage — removes the storyboard homepage (leaves empty page)
126
+ * @property {string} protoHomepage — internal /path that replaces homepage; redirects from / and /workspace
127
+ * @property {string} canvasHomepage — canvas id (e.g. "landing") that replaces homepage. Mutually exclusive with `protoHomepage` — when both are set, this wins.
128
+ */
129
+
130
+ /**
131
+ * @typedef {object} ThemeDefinition
132
+ * @property {string} label — display name shown in theme switchers
133
+ * @property {Record<string,string>} [attrs] — companion data attributes written onto each surface element when this theme is active (e.g. Primer's `data-color-mode`, `data-light-theme`). Storyboard core writes no companion attrs by default; consumers (Primer-using repos, etc.) add them via their own storyboard.config.json.
134
+ */
135
+
136
+ /**
137
+ * @typedef {object} SurfaceDefinition
138
+ * @property {string} label — display label shown in the "Apply theme to" sub-menu
139
+ * @property {boolean} sync — default value for whether this surface follows the global theme (true) or stays light (false). Persisted per-user in localStorage.
140
+ */
141
+
142
+ /**
143
+ * @typedef {object} ThemesConfig
144
+ * @property {Record<string, ThemeDefinition>} themes — registry of available themes. Theme IDs are the values written to `data-sb-<surface>-theme`. Storyboard core ships only `light` and `dark`; consumers add more (e.g. `dark_dimmed`, `light_colorblind`).
145
+ * @property {Record<string, SurfaceDefinition>} surfaces — registry of theming surfaces. Each surface ID is matched against `[data-sb-surface="<id>"]` elements in the DOM. The runtime writes `data-sb-<id>-theme` onto those elements plus the companion `attrs` from the active theme. Core ships `prototype`, `canvas`, `toolbar`, `codeBoxes`.
146
+ * @property {string} [default] — default global theme ID. Use `"system"` to follow OS preference.
128
147
  */
129
148
 
130
149
  /**
@@ -145,6 +164,7 @@
145
164
  * @property {HotPoolConfig} [hotPool]
146
165
  * @property {CommandPaletteConfig} [commandPalette]
147
166
  * @property {CustomerModeConfig} [customerMode]
167
+ * @property {ThemesConfig} [themes]
148
168
  */
149
169
 
150
170
  /** Built-in paste rules shipped with storyboard. */
@@ -169,7 +189,18 @@ export const configDefaults = {
169
189
  plugins: {},
170
190
  workshop: {
171
191
  enabled: false,
172
- features: { createPrototype: true, createFlow: true, createCanvas: true },
192
+ // Each `create<X>` flag controls whether the corresponding item appears
193
+ // in the workspace Create-Artifact menu. Default true — set to false in
194
+ // a consumer's storyboard.config.json to hide that item.
195
+ features: {
196
+ createPrototype: true,
197
+ createCanvas: true,
198
+ createComponent: true,
199
+ createFlow: true,
200
+ createPage: true,
201
+ createObject: true,
202
+ createRecord: true,
203
+ },
173
204
  },
174
205
  featureFlags: {},
175
206
  ui: {},
@@ -184,6 +215,10 @@ export const configDefaults = {
184
215
  min: 10,
185
216
  max: 250,
186
217
  step: 10,
218
+ gestures: true, // when false, disables cmd+wheel and pinch-to-zoom; +/- buttons still work
219
+ },
220
+ scroll: {
221
+ axis: 'both', // "both" | "vertical" | "horizontal" | "none"
187
222
  },
188
223
  terminal: {
189
224
  resizable: false,
@@ -196,7 +231,6 @@ export const configDefaults = {
196
231
  pollIntervalMs: 2000,
197
232
  maxMessagesPerHub: 200,
198
233
  },
199
- inlineStories: false,
200
234
  },
201
235
  commandPalette: {
202
236
  providers: ['prototypes', 'flows', 'canvases', 'pages'],
@@ -208,6 +242,28 @@ export const configDefaults = {
208
242
  hideChrome: false,
209
243
  hideHomepage: false,
210
244
  protoHomepage: '',
245
+ canvasHomepage: '',
246
+ },
247
+ theming: {
248
+ // Minimal default theme registry. Consumers add more in their own
249
+ // storyboard.config.json — see README#theming. IDs starting with "dark"
250
+ // are picked up by the Tailwind `dark:` variant via prefix match.
251
+ themes: {
252
+ light: { label: 'Light', attrs: {} },
253
+ dark: { label: 'Dark', attrs: {} },
254
+ },
255
+ // Surface registry. Each surface ID is looked up in the DOM as
256
+ // `[data-sb-surface="<id>"]`. The runtime writes
257
+ // `data-sb-<id>-theme="<resolved>"` plus the active theme's companion
258
+ // `attrs` onto every matching element.
259
+ surfaces: {
260
+ prototype: { label: 'Prototype', sync: true },
261
+ canvas: { label: 'Canvas', sync: true },
262
+ toolbar: { label: 'Tools', sync: false },
263
+ codeBoxes: { label: 'Code boxes', sync: true },
264
+ },
265
+ // Default global theme. "system" follows OS prefers-color-scheme.
266
+ default: 'system',
211
267
  },
212
268
  }
213
269
 
@@ -24,12 +24,15 @@ describe('configSchema', () => {
24
24
  expect(c.canvas.pasteRules).toEqual(builtinPasteRules)
25
25
  expect(c.canvas.github.embedBehavior).toBe('link-preview')
26
26
  expect(c.canvas.github.ghGuard).toBe('copy')
27
+ expect(c.canvas.zoom.gestures).toBe(true)
28
+ expect(c.canvas.scroll.axis).toBe('both')
27
29
  expect(c.commandPalette.providers).toEqual(['prototypes', 'flows', 'canvases', 'pages'])
28
30
  expect(c.commandPalette.ranking).toBe('frecency')
29
31
  expect(c.customerMode.enabled).toBe(false)
30
32
  expect(c.customerMode.hideChrome).toBe(false)
31
33
  expect(c.customerMode.hideHomepage).toBe(false)
32
34
  expect(c.customerMode.protoHomepage).toBe('')
35
+ expect(c.customerMode.canvasHomepage).toBe('')
33
36
  })
34
37
 
35
38
  it('returns full defaults when called with undefined', () => {
@@ -3,19 +3,40 @@
3
3
  * Framework-agnostic (zero npm dependencies).
4
4
  */
5
5
 
6
- let _config = { enabled: false, hideChrome: false, hideHomepage: false, protoHomepage: '' }
6
+ let _config = {
7
+ enabled: false,
8
+ hideChrome: false,
9
+ hideHomepage: false,
10
+ protoHomepage: '',
11
+ canvasHomepage: '',
12
+ }
7
13
 
8
14
  /**
9
15
  * Initialize customer mode config.
16
+ * If both `protoHomepage` and `canvasHomepage` are set, `canvasHomepage` wins
17
+ * (it's the more specific landing-page case) and a console warning is logged.
10
18
  * @param {object} config - The customerMode object from storyboard.config.json
11
19
  */
12
20
  export function initCustomerModeConfig(config) {
13
- _config = { enabled: false, hideChrome: false, hideHomepage: false, protoHomepage: '', ...config }
21
+ _config = {
22
+ enabled: false,
23
+ hideChrome: false,
24
+ hideHomepage: false,
25
+ protoHomepage: '',
26
+ canvasHomepage: '',
27
+ ...config,
28
+ }
29
+ if (_config.protoHomepage && _config.canvasHomepage) {
30
+ console.warn(
31
+ '[storyboard] customerMode has both `protoHomepage` and `canvasHomepage` set. ' +
32
+ '`canvasHomepage` takes precedence; ignoring `protoHomepage`.'
33
+ )
34
+ }
14
35
  }
15
36
 
16
37
  /**
17
38
  * Get the current customer mode config.
18
- * @returns {{ enabled: boolean, hideChrome: boolean, hideHomepage: boolean, protoHomepage: string }}
39
+ * @returns {{ enabled: boolean, hideChrome: boolean, hideHomepage: boolean, protoHomepage: string, canvasHomepage: string }}
19
40
  */
20
41
  export function getCustomerModeConfig() {
21
42
  return _config
@@ -0,0 +1,46 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+
3
+ import {
4
+ initCustomerModeConfig,
5
+ getCustomerModeConfig,
6
+ } from './customerModeConfig.js'
7
+
8
+ describe('customerModeConfig', () => {
9
+ beforeEach(() => {
10
+ // Reset to a known empty state between tests
11
+ initCustomerModeConfig({})
12
+ })
13
+
14
+ it('defaults canvasHomepage to empty string', () => {
15
+ const cfg = getCustomerModeConfig()
16
+ expect(cfg.canvasHomepage).toBe('')
17
+ expect(cfg.protoHomepage).toBe('')
18
+ })
19
+
20
+ it('accepts canvasHomepage from config', () => {
21
+ initCustomerModeConfig({ enabled: true, canvasHomepage: 'landing' })
22
+ expect(getCustomerModeConfig().canvasHomepage).toBe('landing')
23
+ })
24
+
25
+ it('warns when both protoHomepage and canvasHomepage are set', () => {
26
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
27
+ initCustomerModeConfig({
28
+ enabled: true,
29
+ protoHomepage: '/MyProto',
30
+ canvasHomepage: 'landing',
31
+ })
32
+ expect(warnSpy).toHaveBeenCalledTimes(1)
33
+ expect(warnSpy.mock.calls[0][0]).toMatch(/protoHomepage.*canvasHomepage/)
34
+ expect(warnSpy.mock.calls[0][0]).toMatch(/canvasHomepage.*takes precedence/)
35
+ warnSpy.mockRestore()
36
+ })
37
+
38
+ it('does not warn when only one is set', () => {
39
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
40
+ initCustomerModeConfig({ enabled: true, canvasHomepage: 'landing' })
41
+ expect(warnSpy).not.toHaveBeenCalled()
42
+ initCustomerModeConfig({ enabled: true, protoHomepage: '/Proto' })
43
+ expect(warnSpy).not.toHaveBeenCalled()
44
+ warnSpy.mockRestore()
45
+ })
46
+ })
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Presentation Store — consumer-supplied overrides for storyboard chrome elements.
3
+ *
4
+ * A chrome element is any rendered piece of storyboard UI that is wrapped in
5
+ * `<ChromeSlot id="...">` (toolbar buttons, canvas title, page selector, etc.).
6
+ *
7
+ * Consumers seed this registry through `mountStoryboardCore({ presentation })`
8
+ * and the store applies per-element overrides at render time:
9
+ *
10
+ * {
11
+ * "tool:command-palette": {
12
+ * style: { transform: 'translateY(20px)', opacity: 0 },
13
+ * className: 'landing-floating-button',
14
+ * props: { onPointerEnter: handleHover },
15
+ * decorator: (Element, ctx) => <ScrollAnimator>{Element}</ScrollAnimator>,
16
+ * },
17
+ * "canvas:title": { hidden: true },
18
+ * }
19
+ *
20
+ * This is an **advanced, undocumented** extension surface — intended for
21
+ * landing-page / hero canvas use cases where the consumer needs heavy
22
+ * customization of storyboard chrome. Not part of the public API.
23
+ *
24
+ * Framework-agnostic (zero npm dependencies).
25
+ */
26
+
27
+ /**
28
+ * @typedef {object} PresentationOverride
29
+ * @property {React.CSSProperties} [style] - Spread onto the chrome element root style
30
+ * @property {string} [className] - Appended to the chrome element root className
31
+ * @property {object} [props] - Spread as additional props on the chrome element
32
+ * @property {Function} [decorator] - (Element, ctx) => ReactNode wrapper
33
+ * @property {boolean} [hidden] - If true, render null (shorthand for full hide)
34
+ */
35
+
36
+ /** @type {Map<string, PresentationOverride>} */
37
+ let _overrides = new Map()
38
+
39
+ /** @type {Set<Function>} */
40
+ const _listeners = new Set()
41
+
42
+ let _snapshotVersion = 0
43
+
44
+ /**
45
+ * Seed the presentation registry from a consumer-supplied map.
46
+ * Replaces any previous overrides. Called once at app startup.
47
+ *
48
+ * @param {Record<string, PresentationOverride>} [map]
49
+ */
50
+ export function initPresentation(map) {
51
+ _overrides = new Map()
52
+ if (map && typeof map === 'object') {
53
+ for (const [id, override] of Object.entries(map)) {
54
+ if (override && typeof override === 'object') {
55
+ _overrides.set(id, override)
56
+ }
57
+ }
58
+ }
59
+ _notify()
60
+ }
61
+
62
+ /**
63
+ * Set or replace a single chrome element's presentation override.
64
+ * @param {string} id
65
+ * @param {PresentationOverride|null|undefined} override Pass null/undefined to clear.
66
+ */
67
+ export function setPresentation(id, override) {
68
+ if (override == null) {
69
+ _overrides.delete(id)
70
+ } else if (typeof override === 'object') {
71
+ _overrides.set(id, override)
72
+ }
73
+ _notify()
74
+ }
75
+
76
+ /**
77
+ * Get the presentation override for a chrome element ID, or null if none.
78
+ * @param {string} id
79
+ * @returns {PresentationOverride|null}
80
+ */
81
+ export function getPresentation(id) {
82
+ return _overrides.get(id) || null
83
+ }
84
+
85
+ /**
86
+ * Subscribe to presentation registry changes. Returns an unsubscribe fn.
87
+ * Compatible with `useSyncExternalStore` shape.
88
+ * @param {Function} callback
89
+ * @returns {Function}
90
+ */
91
+ export function subscribeToPresentation(callback) {
92
+ _listeners.add(callback)
93
+ return () => _listeners.delete(callback)
94
+ }
95
+
96
+ /**
97
+ * Snapshot version for change detection. Compatible with `useSyncExternalStore`.
98
+ * @returns {string}
99
+ */
100
+ export function getPresentationSnapshot() {
101
+ return String(_snapshotVersion)
102
+ }
103
+
104
+ function _notify() {
105
+ _snapshotVersion++
106
+ for (const cb of _listeners) {
107
+ try { cb() } catch (err) {
108
+ console.error('[storyboard] Error in presentation subscriber:', err)
109
+ }
110
+ }
111
+ }
112
+
113
+ /** Reset all state. Only for tests. */
114
+ export function _resetPresentation() {
115
+ _overrides = new Map()
116
+ _listeners.clear()
117
+ _snapshotVersion = 0
118
+ }
@@ -0,0 +1,114 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+
3
+ import {
4
+ initPresentation,
5
+ setPresentation,
6
+ getPresentation,
7
+ subscribeToPresentation,
8
+ getPresentationSnapshot,
9
+ _resetPresentation,
10
+ } from './presentationStore.js'
11
+
12
+ describe('presentationStore', () => {
13
+ beforeEach(() => {
14
+ _resetPresentation()
15
+ })
16
+
17
+ it('returns null when no override is registered', () => {
18
+ expect(getPresentation('tool:command-palette')).toBeNull()
19
+ })
20
+
21
+ it('seeds overrides from initPresentation', () => {
22
+ initPresentation({
23
+ 'tool:command-palette': { hidden: true },
24
+ 'canvas:title': { className: 'landing-title' },
25
+ })
26
+ expect(getPresentation('tool:command-palette')).toEqual({ hidden: true })
27
+ expect(getPresentation('canvas:title')).toEqual({ className: 'landing-title' })
28
+ expect(getPresentation('nope:nothing')).toBeNull()
29
+ })
30
+
31
+ it('ignores non-object values in initPresentation', () => {
32
+ initPresentation({
33
+ 'tool:a': null,
34
+ 'tool:b': 'invalid',
35
+ 'tool:c': 42,
36
+ 'tool:d': { hidden: true },
37
+ })
38
+ expect(getPresentation('tool:a')).toBeNull()
39
+ expect(getPresentation('tool:b')).toBeNull()
40
+ expect(getPresentation('tool:c')).toBeNull()
41
+ expect(getPresentation('tool:d')).toEqual({ hidden: true })
42
+ })
43
+
44
+ it('replaces previous overrides on subsequent initPresentation calls', () => {
45
+ initPresentation({ 'tool:a': { hidden: true } })
46
+ initPresentation({ 'tool:b': { hidden: true } })
47
+ expect(getPresentation('tool:a')).toBeNull()
48
+ expect(getPresentation('tool:b')).toEqual({ hidden: true })
49
+ })
50
+
51
+ it('initPresentation() with no arg clears all overrides', () => {
52
+ initPresentation({ 'tool:a': { hidden: true } })
53
+ initPresentation()
54
+ expect(getPresentation('tool:a')).toBeNull()
55
+ })
56
+
57
+ it('setPresentation adds and updates individual overrides', () => {
58
+ setPresentation('tool:a', { className: 'foo' })
59
+ expect(getPresentation('tool:a')).toEqual({ className: 'foo' })
60
+
61
+ setPresentation('tool:a', { className: 'bar' })
62
+ expect(getPresentation('tool:a')).toEqual({ className: 'bar' })
63
+ })
64
+
65
+ it('setPresentation with null/undefined deletes the override', () => {
66
+ setPresentation('tool:a', { hidden: true })
67
+ setPresentation('tool:a', null)
68
+ expect(getPresentation('tool:a')).toBeNull()
69
+
70
+ setPresentation('tool:b', { hidden: true })
71
+ setPresentation('tool:b', undefined)
72
+ expect(getPresentation('tool:b')).toBeNull()
73
+ })
74
+
75
+ it('subscribers are notified on init, set, and clear', () => {
76
+ const cb = vi.fn()
77
+ const unsub = subscribeToPresentation(cb)
78
+
79
+ initPresentation({ 'tool:a': { hidden: true } })
80
+ setPresentation('tool:b', { className: 'x' })
81
+ setPresentation('tool:a', null)
82
+
83
+ expect(cb).toHaveBeenCalledTimes(3)
84
+ unsub()
85
+
86
+ setPresentation('tool:c', { hidden: true })
87
+ expect(cb).toHaveBeenCalledTimes(3) // not called after unsubscribe
88
+ })
89
+
90
+ it('snapshot version changes on every mutation', () => {
91
+ const v0 = getPresentationSnapshot()
92
+ setPresentation('tool:a', { hidden: true })
93
+ const v1 = getPresentationSnapshot()
94
+ expect(v1).not.toBe(v0)
95
+
96
+ setPresentation('tool:b', { hidden: true })
97
+ const v2 = getPresentationSnapshot()
98
+ expect(v2).not.toBe(v1)
99
+ })
100
+
101
+ it('subscriber errors are isolated', () => {
102
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
103
+ const goodCb = vi.fn()
104
+
105
+ subscribeToPresentation(() => { throw new Error('boom') })
106
+ subscribeToPresentation(goodCb)
107
+
108
+ setPresentation('tool:a', { hidden: true })
109
+
110
+ expect(goodCb).toHaveBeenCalled()
111
+ expect(consoleSpy).toHaveBeenCalled()
112
+ consoleSpy.mockRestore()
113
+ })
114
+ })
@@ -1,13 +1,20 @@
1
1
  /**
2
- * Recent Artifacts — localStorage-backed recents for the command palette.
2
+ * Recent Artifacts — localStorage-backed recents for the workspace and
3
+ * command palette.
3
4
  *
4
5
  * Stores artifact identity (type + key + label), not routes.
5
6
  * Routes are derived at read time from the live data index so they
6
7
  * stay correct across branch/basePath changes.
8
+ *
9
+ * Subscribable via {@link subscribeToRecent} / {@link getRecentSnapshot}
10
+ * for `useSyncExternalStore` integration. Writes within the same tab
11
+ * are surfaced via a synthetic `storage` event so other listeners
12
+ * (including this module's own snapshot consumers) re-read immediately.
7
13
  */
8
14
 
9
15
  const STORAGE_KEY = 'storyboard:recent-artifacts'
10
- const MAX_ITEMS = 10
16
+ const MAX_ITEMS = 30
17
+ const CHANGE_EVENT = 'storyboard-recent-artifacts-changed'
11
18
 
12
19
  /**
13
20
  * @typedef {{ type: string, key: string, label: string }} RecentEntry
@@ -43,9 +50,42 @@ export function getRecent() {
43
50
  export function clearRecent() {
44
51
  try {
45
52
  localStorage.removeItem(STORAGE_KEY)
53
+ _notify()
46
54
  } catch { /* ignore */ }
47
55
  }
48
56
 
57
+ /**
58
+ * Subscribe to changes in the recent artifacts store. Fires for both
59
+ * intra-tab writes (via a synthetic event) and cross-tab writes (via
60
+ * the native `storage` event).
61
+ *
62
+ * Designed for `useSyncExternalStore` — pair with {@link getRecentSnapshot}.
63
+ *
64
+ * @param {() => void} callback
65
+ * @returns {() => void} unsubscribe
66
+ */
67
+ export function subscribeToRecent(callback) {
68
+ if (typeof window === 'undefined') return () => {}
69
+ const onStorage = (e) => { if (!e || !e.key || e.key === STORAGE_KEY) callback() }
70
+ window.addEventListener('storage', onStorage)
71
+ window.addEventListener(CHANGE_EVENT, callback)
72
+ return () => {
73
+ window.removeEventListener('storage', onStorage)
74
+ window.removeEventListener(CHANGE_EVENT, callback)
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Return a stable string snapshot of the recent artifacts store for
80
+ * `useSyncExternalStore`. Returns the raw JSON (or `'[]'` if absent).
81
+ *
82
+ * @returns {string}
83
+ */
84
+ export function getRecentSnapshot() {
85
+ try { return localStorage.getItem(STORAGE_KEY) || '[]' }
86
+ catch { return '[]' }
87
+ }
88
+
49
89
  // ---------------------------------------------------------------------------
50
90
  // Internal
51
91
  // ---------------------------------------------------------------------------
@@ -64,5 +104,12 @@ function _read() {
64
104
  function _write(entries) {
65
105
  try {
66
106
  localStorage.setItem(STORAGE_KEY, JSON.stringify(entries))
107
+ _notify()
67
108
  } catch { /* quota exceeded or unavailable */ }
68
109
  }
110
+
111
+ function _notify() {
112
+ if (typeof window === 'undefined') return
113
+ try { window.dispatchEvent(new Event(CHANGE_EVENT)) }
114
+ catch { /* ignore (e.g. SSR / non-browser) */ }
115
+ }