@dfosco/storyboard 0.5.0-alpha.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 (592) hide show
  1. package/commandpalette.config.json +152 -0
  2. package/dist/storyboard-ui.css +1 -0
  3. package/dist/storyboard-ui.js +21328 -0
  4. package/dist/storyboard-ui.js.map +1 -0
  5. package/dist/tailwind.css +2 -0
  6. package/dist/tiny-canvas.css +1 -0
  7. package/dist/tiny-canvas.js +389 -0
  8. package/package.json +121 -0
  9. package/paste.config.json +67 -0
  10. package/scaffold/AGENTS.md +432 -0
  11. package/scaffold/agents/prompt-agent.agent.md +181 -0
  12. package/scaffold/agents/terminal-agent.agent.md +351 -0
  13. package/scaffold/codex/config.toml +246 -0
  14. package/scaffold/deploy.yml +103 -0
  15. package/scaffold/githooks/pre-push +114 -0
  16. package/scaffold/gitignore +64 -0
  17. package/scaffold/manifest.json +56 -0
  18. package/scaffold/preview.yml +181 -0
  19. package/scaffold/scripts/link.sh +26 -0
  20. package/scaffold/scripts/unlink.sh +10 -0
  21. package/scaffold/skills/agent-browser/SKILL.md +260 -0
  22. package/scaffold/skills/canvas/SKILL.md +364 -0
  23. package/scaffold/skills/create/SKILL.md +501 -0
  24. package/scaffold/skills/ship/SKILL.md +237 -0
  25. package/scaffold/skills/storyboard/SKILL.md +360 -0
  26. package/scaffold/skills/update-storyboard/SKILL.md +16 -0
  27. package/scaffold/skills/update-storyboard/update-storyboard-packages.sh +26 -0
  28. package/scaffold/skills/vitest/GENERATION.md +5 -0
  29. package/scaffold/skills/vitest/SKILL.md +52 -0
  30. package/scaffold/skills/vitest/references/advanced-environments.md +264 -0
  31. package/scaffold/skills/vitest/references/advanced-projects.md +300 -0
  32. package/scaffold/skills/vitest/references/advanced-type-testing.md +237 -0
  33. package/scaffold/skills/vitest/references/advanced-vi.md +249 -0
  34. package/scaffold/skills/vitest/references/core-cli.md +166 -0
  35. package/scaffold/skills/vitest/references/core-config.md +174 -0
  36. package/scaffold/skills/vitest/references/core-describe.md +193 -0
  37. package/scaffold/skills/vitest/references/core-expect.md +219 -0
  38. package/scaffold/skills/vitest/references/core-hooks.md +244 -0
  39. package/scaffold/skills/vitest/references/core-test-api.md +233 -0
  40. package/scaffold/skills/vitest/references/features-concurrency.md +250 -0
  41. package/scaffold/skills/vitest/references/features-context.md +238 -0
  42. package/scaffold/skills/vitest/references/features-coverage.md +207 -0
  43. package/scaffold/skills/vitest/references/features-filtering.md +211 -0
  44. package/scaffold/skills/vitest/references/features-mocking.md +265 -0
  45. package/scaffold/skills/vitest/references/features-snapshots.md +207 -0
  46. package/scaffold/skills/worktree/SKILL.md +93 -0
  47. package/scaffold/storyboard.config.json +44 -0
  48. package/src/canvas/Canvas.jsx +78 -0
  49. package/src/canvas/Draggable.jsx +235 -0
  50. package/src/canvas/index.d.ts +41 -0
  51. package/src/canvas/index.js +6 -0
  52. package/src/canvas/style.css +118 -0
  53. package/src/canvas/useResetCanvas.js +17 -0
  54. package/src/canvas/utils.js +136 -0
  55. package/src/core/assets/fonts/IoskeleyMono-Bold.woff2 +0 -0
  56. package/src/core/assets/fonts/IoskeleyMono-Italic.woff2 +0 -0
  57. package/src/core/assets/fonts/IoskeleyMono-Medium.woff2 +0 -0
  58. package/src/core/assets/fonts/IoskeleyMono-Regular.woff2 +0 -0
  59. package/src/core/assets/fonts/IoskeleyMono-SemiBold.woff2 +0 -0
  60. package/src/core/autosync/server.js +714 -0
  61. package/src/core/autosync/server.test.js +158 -0
  62. package/src/core/canvas/__tests__/agent-integration.test.js +596 -0
  63. package/src/core/canvas/__tests__/helpers/browser.js +95 -0
  64. package/src/core/canvas/__tests__/helpers/canvas-api.js +129 -0
  65. package/src/core/canvas/__tests__/helpers/perf.js +118 -0
  66. package/src/core/canvas/__tests__/helpers/setup.js +176 -0
  67. package/src/core/canvas/__tests__/helpers/tmux.js +130 -0
  68. package/src/core/canvas/__tests__/helpers/transcript.js +132 -0
  69. package/src/core/canvas/__tests__/terminal-integration.test.js +177 -0
  70. package/src/core/canvas/collision.js +292 -0
  71. package/src/core/canvas/collision.test.js +371 -0
  72. package/src/core/canvas/compact.js +83 -0
  73. package/src/core/canvas/deriveCanvasId.test.js +40 -0
  74. package/src/core/canvas/githubEmbeds.js +527 -0
  75. package/src/core/canvas/githubEmbeds.test.js +302 -0
  76. package/src/core/canvas/hot-pool.js +766 -0
  77. package/src/core/canvas/identity.js +107 -0
  78. package/src/core/canvas/identity.test.js +100 -0
  79. package/src/core/canvas/materializer.js +259 -0
  80. package/src/core/canvas/materializer.test.js +356 -0
  81. package/src/core/canvas/selectedWidgets.js +270 -0
  82. package/src/core/canvas/selectedWidgets.test.js +321 -0
  83. package/src/core/canvas/server.js +3134 -0
  84. package/src/core/canvas/server.test.js +379 -0
  85. package/src/core/canvas/terminal-config.js +330 -0
  86. package/src/core/canvas/terminal-registry.js +465 -0
  87. package/src/core/canvas/terminal-server.js +1436 -0
  88. package/src/core/canvas/writeGuard.js +53 -0
  89. package/src/core/cli/agent.js +85 -0
  90. package/src/core/cli/branch.js +386 -0
  91. package/src/core/cli/canvasAdd.js +241 -0
  92. package/src/core/cli/canvasBatch.js +98 -0
  93. package/src/core/cli/canvasBounds.js +160 -0
  94. package/src/core/cli/canvasRead.js +236 -0
  95. package/src/core/cli/canvasUpdate.js +179 -0
  96. package/src/core/cli/code.js +67 -0
  97. package/src/core/cli/compact.js +62 -0
  98. package/src/core/cli/create.js +674 -0
  99. package/src/core/cli/dev-helpers.js +53 -0
  100. package/src/core/cli/dev-helpers.test.js +53 -0
  101. package/src/core/cli/dev.js +430 -0
  102. package/src/core/cli/exit.js +38 -0
  103. package/src/core/cli/flags.js +174 -0
  104. package/src/core/cli/flags.test.js +155 -0
  105. package/src/core/cli/index.js +233 -0
  106. package/src/core/cli/intro.js +37 -0
  107. package/src/core/cli/proxy.js +319 -0
  108. package/src/core/cli/proxy.test.js +63 -0
  109. package/src/core/cli/schemas.js +223 -0
  110. package/src/core/cli/server.js +192 -0
  111. package/src/core/cli/serverUrl.js +61 -0
  112. package/src/core/cli/sessions.js +459 -0
  113. package/src/core/cli/setup.js +404 -0
  114. package/src/core/cli/terminal-commands.js +287 -0
  115. package/src/core/cli/terminal-messaging.js +231 -0
  116. package/src/core/cli/terminal-welcome.js +515 -0
  117. package/src/core/cli/updateVersion.js +124 -0
  118. package/src/core/comments/api.js +284 -0
  119. package/src/core/comments/api.test.js +282 -0
  120. package/src/core/comments/auth.js +151 -0
  121. package/src/core/comments/auth.test.js +167 -0
  122. package/src/core/comments/commentCache.js +109 -0
  123. package/src/core/comments/commentCache.test.js +48 -0
  124. package/src/core/comments/commentDrafts.js +68 -0
  125. package/src/core/comments/commentMode.js +63 -0
  126. package/src/core/comments/commentMode.test.js +90 -0
  127. package/src/core/comments/config.js +47 -0
  128. package/src/core/comments/config.test.js +77 -0
  129. package/src/core/comments/graphql.js +65 -0
  130. package/src/core/comments/graphql.test.js +95 -0
  131. package/src/core/comments/index.js +42 -0
  132. package/src/core/comments/metadata.js +52 -0
  133. package/src/core/comments/metadata.test.js +110 -0
  134. package/src/core/comments/queries.js +245 -0
  135. package/src/core/comments/ui/AuthModal.jsx +114 -0
  136. package/src/core/comments/ui/CommentOverlay.js +52 -0
  137. package/src/core/comments/ui/CommentWindow.jsx +329 -0
  138. package/src/core/comments/ui/CommentsDrawer.jsx +102 -0
  139. package/src/core/comments/ui/Composer.jsx +64 -0
  140. package/src/core/comments/ui/authModal.js +66 -0
  141. package/src/core/comments/ui/authModal.test.js +76 -0
  142. package/src/core/comments/ui/comment-cursor-dark.svg +1 -0
  143. package/src/core/comments/ui/comment-cursor.svg +1 -0
  144. package/src/core/comments/ui/comment-layout.css +142 -0
  145. package/src/core/comments/ui/commentWindow.js +121 -0
  146. package/src/core/comments/ui/comments.css +242 -0
  147. package/src/core/comments/ui/commentsDrawer.js +84 -0
  148. package/src/core/comments/ui/composer.js +136 -0
  149. package/src/core/comments/ui/index.js +14 -0
  150. package/src/core/comments/ui/mount.js +687 -0
  151. package/src/core/comments/ui/mount.test.js +336 -0
  152. package/src/core/data/dotPath.js +53 -0
  153. package/src/core/data/dotPath.test.js +114 -0
  154. package/src/core/data/loader.js +409 -0
  155. package/src/core/data/loader.test.js +599 -0
  156. package/src/core/data/viewfinder.js +363 -0
  157. package/src/core/data/viewfinder.test.js +456 -0
  158. package/src/core/devtools/devtools-consumer.js +28 -0
  159. package/src/core/devtools/devtools.js +144 -0
  160. package/src/core/devtools/devtools.test.js +75 -0
  161. package/src/core/devtools/sceneDebug.js +112 -0
  162. package/src/core/devtools/sceneDebug.test.js +141 -0
  163. package/src/core/index.js +124 -0
  164. package/src/core/inspector/fiberWalker.js +239 -0
  165. package/src/core/inspector/highlighter.js +275 -0
  166. package/src/core/inspector/mouseMode.js +259 -0
  167. package/src/core/lib/components/ui/alert/alert-action.jsx +11 -0
  168. package/src/core/lib/components/ui/alert/alert-description.jsx +11 -0
  169. package/src/core/lib/components/ui/alert/alert-title.jsx +11 -0
  170. package/src/core/lib/components/ui/alert/alert.jsx +25 -0
  171. package/src/core/lib/components/ui/alert/index.js +17 -0
  172. package/src/core/lib/components/ui/avatar/avatar-badge.jsx +22 -0
  173. package/src/core/lib/components/ui/avatar/avatar-fallback.jsx +18 -0
  174. package/src/core/lib/components/ui/avatar/avatar-group-count.jsx +19 -0
  175. package/src/core/lib/components/ui/avatar/avatar-group.jsx +19 -0
  176. package/src/core/lib/components/ui/avatar/avatar-image.jsx +15 -0
  177. package/src/core/lib/components/ui/avatar/avatar.jsx +19 -0
  178. package/src/core/lib/components/ui/avatar/index.js +22 -0
  179. package/src/core/lib/components/ui/badge/badge.jsx +31 -0
  180. package/src/core/lib/components/ui/badge/index.js +2 -0
  181. package/src/core/lib/components/ui/button/button.jsx +100 -0
  182. package/src/core/lib/components/ui/button/index.js +12 -0
  183. package/src/core/lib/components/ui/card/card-action.jsx +11 -0
  184. package/src/core/lib/components/ui/card/card-content.jsx +11 -0
  185. package/src/core/lib/components/ui/card/card-description.jsx +11 -0
  186. package/src/core/lib/components/ui/card/card-footer.jsx +11 -0
  187. package/src/core/lib/components/ui/card/card-header.jsx +19 -0
  188. package/src/core/lib/components/ui/card/card-title.jsx +11 -0
  189. package/src/core/lib/components/ui/card/card.jsx +17 -0
  190. package/src/core/lib/components/ui/card/index.js +25 -0
  191. package/src/core/lib/components/ui/checkbox/checkbox.jsx +29 -0
  192. package/src/core/lib/components/ui/checkbox/index.js +6 -0
  193. package/src/core/lib/components/ui/collapsible/collapsible-content.jsx +7 -0
  194. package/src/core/lib/components/ui/collapsible/collapsible-trigger.jsx +7 -0
  195. package/src/core/lib/components/ui/collapsible/collapsible.jsx +7 -0
  196. package/src/core/lib/components/ui/collapsible/index.js +13 -0
  197. package/src/core/lib/components/ui/dialog/dialog-close.jsx +7 -0
  198. package/src/core/lib/components/ui/dialog/dialog-content.jsx +34 -0
  199. package/src/core/lib/components/ui/dialog/dialog-description.jsx +15 -0
  200. package/src/core/lib/components/ui/dialog/dialog-footer.jsx +23 -0
  201. package/src/core/lib/components/ui/dialog/dialog-header.jsx +11 -0
  202. package/src/core/lib/components/ui/dialog/dialog-overlay.jsx +15 -0
  203. package/src/core/lib/components/ui/dialog/dialog-portal.jsx +4 -0
  204. package/src/core/lib/components/ui/dialog/dialog-title.jsx +15 -0
  205. package/src/core/lib/components/ui/dialog/dialog-trigger.jsx +7 -0
  206. package/src/core/lib/components/ui/dialog/dialog.jsx +4 -0
  207. package/src/core/lib/components/ui/dialog/index.js +34 -0
  208. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.jsx +8 -0
  209. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.jsx +30 -0
  210. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-content.jsx +22 -0
  211. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.jsx +16 -0
  212. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-group.jsx +7 -0
  213. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-item.jsx +20 -0
  214. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-label.jsx +17 -0
  215. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-portal.jsx +4 -0
  216. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.jsx +7 -0
  217. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.jsx +29 -0
  218. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-separator.jsx +15 -0
  219. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.jsx +16 -0
  220. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.jsx +15 -0
  221. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.jsx +23 -0
  222. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-sub.jsx +4 -0
  223. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-trigger.jsx +7 -0
  224. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu.jsx +4 -0
  225. package/src/core/lib/components/ui/dropdown-menu/index.js +54 -0
  226. package/src/core/lib/components/ui/input/index.js +7 -0
  227. package/src/core/lib/components/ui/input/input.jsx +19 -0
  228. package/src/core/lib/components/ui/label/index.js +7 -0
  229. package/src/core/lib/components/ui/label/label.jsx +19 -0
  230. package/src/core/lib/components/ui/panel/index.js +24 -0
  231. package/src/core/lib/components/ui/panel/panel-body.jsx +11 -0
  232. package/src/core/lib/components/ui/panel/panel-close.jsx +16 -0
  233. package/src/core/lib/components/ui/panel/panel-content.jsx +29 -0
  234. package/src/core/lib/components/ui/panel/panel-footer.jsx +11 -0
  235. package/src/core/lib/components/ui/panel/panel-header.jsx +11 -0
  236. package/src/core/lib/components/ui/panel/panel-title.jsx +12 -0
  237. package/src/core/lib/components/ui/panel/panel.jsx +4 -0
  238. package/src/core/lib/components/ui/popover/index.js +28 -0
  239. package/src/core/lib/components/ui/popover/popover-close.jsx +7 -0
  240. package/src/core/lib/components/ui/popover/popover-content.jsx +22 -0
  241. package/src/core/lib/components/ui/popover/popover-description.jsx +11 -0
  242. package/src/core/lib/components/ui/popover/popover-header.jsx +11 -0
  243. package/src/core/lib/components/ui/popover/popover-portal.jsx +4 -0
  244. package/src/core/lib/components/ui/popover/popover-title.jsx +11 -0
  245. package/src/core/lib/components/ui/popover/popover-trigger.jsx +8 -0
  246. package/src/core/lib/components/ui/popover/popover.jsx +4 -0
  247. package/src/core/lib/components/ui/searchable-list.jsx +160 -0
  248. package/src/core/lib/components/ui/select/index.js +37 -0
  249. package/src/core/lib/components/ui/select/select-content.jsx +30 -0
  250. package/src/core/lib/components/ui/select/select-group-heading.jsx +17 -0
  251. package/src/core/lib/components/ui/select/select-group.jsx +15 -0
  252. package/src/core/lib/components/ui/select/select-item.jsx +26 -0
  253. package/src/core/lib/components/ui/select/select-label.jsx +11 -0
  254. package/src/core/lib/components/ui/select/select-portal.jsx +4 -0
  255. package/src/core/lib/components/ui/select/select-scroll-down-button.jsx +18 -0
  256. package/src/core/lib/components/ui/select/select-scroll-up-button.jsx +18 -0
  257. package/src/core/lib/components/ui/select/select-separator.jsx +15 -0
  258. package/src/core/lib/components/ui/select/select-trigger.jsx +25 -0
  259. package/src/core/lib/components/ui/select/select.jsx +4 -0
  260. package/src/core/lib/components/ui/separator/index.js +7 -0
  261. package/src/core/lib/components/ui/separator/separator.jsx +22 -0
  262. package/src/core/lib/components/ui/sheet/index.js +34 -0
  263. package/src/core/lib/components/ui/sheet/sheet-close.jsx +7 -0
  264. package/src/core/lib/components/ui/sheet/sheet-content.jsx +35 -0
  265. package/src/core/lib/components/ui/sheet/sheet-description.jsx +15 -0
  266. package/src/core/lib/components/ui/sheet/sheet-footer.jsx +11 -0
  267. package/src/core/lib/components/ui/sheet/sheet-header.jsx +11 -0
  268. package/src/core/lib/components/ui/sheet/sheet-overlay.jsx +15 -0
  269. package/src/core/lib/components/ui/sheet/sheet-portal.jsx +4 -0
  270. package/src/core/lib/components/ui/sheet/sheet-title.jsx +15 -0
  271. package/src/core/lib/components/ui/sheet/sheet-trigger.jsx +7 -0
  272. package/src/core/lib/components/ui/sheet/sheet.jsx +4 -0
  273. package/src/core/lib/components/ui/textarea/index.js +7 -0
  274. package/src/core/lib/components/ui/textarea/textarea.jsx +18 -0
  275. package/src/core/lib/components/ui/toggle/index.js +8 -0
  276. package/src/core/lib/components/ui/toggle/toggle.jsx +36 -0
  277. package/src/core/lib/components/ui/toggle-group/index.js +10 -0
  278. package/src/core/lib/components/ui/toggle-group/toggle-group-item.jsx +29 -0
  279. package/src/core/lib/components/ui/toggle-group/toggle-group.jsx +43 -0
  280. package/src/core/lib/components/ui/tooltip/index.js +3 -0
  281. package/src/core/lib/components/ui/tooltip/tooltip-content.jsx +21 -0
  282. package/src/core/lib/components/ui/tooltip/tooltip-trigger.jsx +23 -0
  283. package/src/core/lib/components/ui/tooltip/tooltip.jsx +11 -0
  284. package/src/core/lib/components/ui/trigger-button/index.js +6 -0
  285. package/src/core/lib/components/ui/trigger-button/trigger-button.css +38 -0
  286. package/src/core/lib/components/ui/trigger-button/trigger-button.jsx +63 -0
  287. package/src/core/lib/utils/index.js +6 -0
  288. package/src/core/logger/devLogger.js +238 -0
  289. package/src/core/logger/devLogger.test.js +193 -0
  290. package/src/core/modes/modes.css +98 -0
  291. package/src/core/modes/modes.js +492 -0
  292. package/src/core/modes/modes.test.js +562 -0
  293. package/src/core/mountStoryboardCore.js +478 -0
  294. package/src/core/rename-watcher/config.json +23 -0
  295. package/src/core/rename-watcher/watcher.js +531 -0
  296. package/src/core/scaffold.js +100 -0
  297. package/src/core/server/index.js +391 -0
  298. package/src/core/session/bodyClasses.js +128 -0
  299. package/src/core/session/bodyClasses.test.js +192 -0
  300. package/src/core/session/hashSubscribe.js +19 -0
  301. package/src/core/session/hashSubscribe.test.js +62 -0
  302. package/src/core/session/hideMode.js +424 -0
  303. package/src/core/session/hideMode.test.js +268 -0
  304. package/src/core/session/interceptHideParams.js +35 -0
  305. package/src/core/session/interceptHideParams.test.js +90 -0
  306. package/src/core/session/localStorage.js +134 -0
  307. package/src/core/session/localStorage.test.js +148 -0
  308. package/src/core/session/session.js +76 -0
  309. package/src/core/session/session.test.js +91 -0
  310. package/src/core/stores/canvasConfig.js +134 -0
  311. package/src/core/stores/canvasConfig.test.js +120 -0
  312. package/src/core/stores/commandActions.js +284 -0
  313. package/src/core/stores/commandPaletteConfig.js +31 -0
  314. package/src/core/stores/configSchema.js +232 -0
  315. package/src/core/stores/configSchema.test.js +72 -0
  316. package/src/core/stores/configStore.js +161 -0
  317. package/src/core/stores/customerModeConfig.js +30 -0
  318. package/src/core/stores/featureFlags.js +127 -0
  319. package/src/core/stores/paletteProviders.js +360 -0
  320. package/src/core/stores/paletteProviders.test.js +186 -0
  321. package/src/core/stores/plugins.js +40 -0
  322. package/src/core/stores/plugins.test.js +68 -0
  323. package/src/core/stores/recentArtifacts.js +68 -0
  324. package/src/core/stores/recentArtifacts.test.js +71 -0
  325. package/src/core/stores/sidePanelStore.ts +143 -0
  326. package/src/core/stores/themeStore.ts +291 -0
  327. package/src/core/stores/toolRegistry.js +227 -0
  328. package/src/core/stores/toolStateStore.js +183 -0
  329. package/src/core/stores/toolStateStore.test.js +220 -0
  330. package/src/core/stores/toolbarConfigStore.js +165 -0
  331. package/src/core/stores/uiConfig.js +64 -0
  332. package/src/core/stores/uiConfig.test.js +63 -0
  333. package/src/core/styles/tailwind.css +204 -0
  334. package/src/core/tools/handlers/autosync.js +12 -0
  335. package/src/core/tools/handlers/canvasAddWidget.js +11 -0
  336. package/src/core/tools/handlers/canvasAgents.js +20 -0
  337. package/src/core/tools/handlers/canvasToolbar.js +56 -0
  338. package/src/core/tools/handlers/commandPalette.js +9 -0
  339. package/src/core/tools/handlers/comments.js +16 -0
  340. package/src/core/tools/handlers/create.js +39 -0
  341. package/src/core/tools/handlers/devtools.js +122 -0
  342. package/src/core/tools/handlers/devtools.test.js +87 -0
  343. package/src/core/tools/handlers/featureFlags.js +21 -0
  344. package/src/core/tools/handlers/flows.js +68 -0
  345. package/src/core/tools/handlers/hideChrome.js +9 -0
  346. package/src/core/tools/handlers/hideToolbars.js +25 -0
  347. package/src/core/tools/handlers/inspector.js +19 -0
  348. package/src/core/tools/handlers/paletteTheme.js +35 -0
  349. package/src/core/tools/handlers/theme.js +9 -0
  350. package/src/core/tools/registry.js +26 -0
  351. package/src/core/tools/surfaces/canvasToolbar.js +10 -0
  352. package/src/core/tools/surfaces/commandList.js +10 -0
  353. package/src/core/tools/surfaces/mainToolbar.js +11 -0
  354. package/src/core/tools/surfaces/registry.js +19 -0
  355. package/src/core/ui/ActionMenuButton.jsx +114 -0
  356. package/src/core/ui/AutosyncMenuButton.css +67 -0
  357. package/src/core/ui/AutosyncMenuButton.jsx +242 -0
  358. package/src/core/ui/BranchSelect.jsx +29 -0
  359. package/src/core/ui/BranchSelect.module.css +30 -0
  360. package/src/core/ui/CanvasAgentsMenu.jsx +89 -0
  361. package/src/core/ui/CanvasCreateMenu.jsx +611 -0
  362. package/src/core/ui/CanvasSnap.css +27 -0
  363. package/src/core/ui/CanvasSnap.jsx +51 -0
  364. package/src/core/ui/CanvasUndoRedo.css +36 -0
  365. package/src/core/ui/CanvasUndoRedo.jsx +62 -0
  366. package/src/core/ui/CanvasZoomControl.css +53 -0
  367. package/src/core/ui/CanvasZoomControl.jsx +49 -0
  368. package/src/core/ui/CanvasZoomToFit.css +18 -0
  369. package/src/core/ui/CanvasZoomToFit.jsx +26 -0
  370. package/src/core/ui/CommandMenu.css +8 -0
  371. package/src/core/ui/CommandMenu.jsx +287 -0
  372. package/src/core/ui/CommandPalette.jsx +35 -0
  373. package/src/core/ui/CommandPaletteTrigger.jsx +25 -0
  374. package/src/core/ui/CommentsMenuButton.jsx +40 -0
  375. package/src/core/ui/CoreUIBar.css +47 -0
  376. package/src/core/ui/CoreUIBar.jsx +905 -0
  377. package/src/core/ui/CreateMenuButton.jsx +117 -0
  378. package/src/core/ui/HideChromeTrigger.jsx +48 -0
  379. package/src/core/ui/Icon.jsx +279 -0
  380. package/src/core/ui/InspectorPanel.css +109 -0
  381. package/src/core/ui/InspectorPanel.jsx +632 -0
  382. package/src/core/ui/PwaInstallBanner.css +42 -0
  383. package/src/core/ui/PwaInstallBanner.jsx +124 -0
  384. package/src/core/ui/SidePanel.jsx +261 -0
  385. package/src/core/ui/ThemeMenuButton.jsx +139 -0
  386. package/src/core/ui/core-ui-colors.css +129 -0
  387. package/src/core/ui/design-modes.ts +7 -0
  388. package/src/core/ui/sidepanel.css +301 -0
  389. package/src/core/ui/viewfinder.ts +7 -0
  390. package/src/core/ui-entry.js +30 -0
  391. package/src/core/utils/fuzzySearch.js +117 -0
  392. package/src/core/utils/fuzzySearch.test.js +119 -0
  393. package/src/core/utils/mobileViewport.js +57 -0
  394. package/src/core/utils/mobileViewport.test.js +68 -0
  395. package/src/core/utils/prodMode.js +38 -0
  396. package/src/core/utils/smoothCorners.js +20 -0
  397. package/src/core/vite/docs-handler.js +155 -0
  398. package/src/core/vite/server-plugin.js +797 -0
  399. package/src/core/workshop/features/createCanvas/CreateCanvasForm.jsx +260 -0
  400. package/src/core/workshop/features/createCanvas/index.js +14 -0
  401. package/src/core/workshop/features/createFlow/CreateFlowForm.jsx +334 -0
  402. package/src/core/workshop/features/createFlow/index.js +19 -0
  403. package/src/core/workshop/features/createFlow/server.js +663 -0
  404. package/src/core/workshop/features/createPage/CreatePageForm.jsx +304 -0
  405. package/src/core/workshop/features/createPage/index.js +11 -0
  406. package/src/core/workshop/features/createPrototype/CreatePrototypeForm.jsx +289 -0
  407. package/src/core/workshop/features/createPrototype/index.js +19 -0
  408. package/src/core/workshop/features/createPrototype/server.js +433 -0
  409. package/src/core/workshop/features/createStory/CreateStoryForm.jsx +208 -0
  410. package/src/core/workshop/features/createStory/index.js +14 -0
  411. package/src/core/workshop/features/registry-server.js +22 -0
  412. package/src/core/workshop/features/registry.js +28 -0
  413. package/src/core/workshop/features/templateIndex.js +155 -0
  414. package/src/core/workshop/ui/WorkshopPanel.jsx +98 -0
  415. package/src/core/workshop/ui/mount.ts +6 -0
  416. package/src/core/worktree/port.js +268 -0
  417. package/src/core/worktree/port.test.js +222 -0
  418. package/src/core/worktree/serverRegistry.js +120 -0
  419. package/src/internals/AuthModal/AuthModal.jsx +132 -0
  420. package/src/internals/AuthModal/AuthModal.module.css +221 -0
  421. package/src/internals/BranchBar/BranchBar.jsx +87 -0
  422. package/src/internals/BranchBar/BranchBar.module.css +247 -0
  423. package/src/internals/BranchBar/useBranches.js +93 -0
  424. package/src/internals/BranchBar/useBranches.test.js +68 -0
  425. package/src/internals/CommandPalette/CommandPalette.jsx +1361 -0
  426. package/src/internals/CommandPalette/CreateDialog.jsx +219 -0
  427. package/src/internals/CommandPalette/command-palette.css +180 -0
  428. package/src/internals/FlowError.module.css +30 -0
  429. package/src/internals/Icon.jsx +279 -0
  430. package/src/internals/StoryboardContext.js +3 -0
  431. package/src/internals/Viewfinder.jsx +1479 -0
  432. package/src/internals/Viewfinder.module.css +1540 -0
  433. package/src/internals/Workspace.jsx +7 -0
  434. package/src/internals/__mocks__/virtual-storyboard-data-index.js +4 -0
  435. package/src/internals/canvas/CanvasControls.jsx +112 -0
  436. package/src/internals/canvas/CanvasControls.module.css +135 -0
  437. package/src/internals/canvas/CanvasPage.bridge.test.jsx +387 -0
  438. package/src/internals/canvas/CanvasPage.dragdrop.test.jsx +350 -0
  439. package/src/internals/canvas/CanvasPage.jsx +3092 -0
  440. package/src/internals/canvas/CanvasPage.module.css +187 -0
  441. package/src/internals/canvas/CanvasPage.multiselect.test.jsx +358 -0
  442. package/src/internals/canvas/CanvasToolbar.jsx +73 -0
  443. package/src/internals/canvas/CanvasToolbar.module.css +92 -0
  444. package/src/internals/canvas/ComponentErrorBoundary.jsx +50 -0
  445. package/src/internals/canvas/ConnectorLayer.jsx +208 -0
  446. package/src/internals/canvas/ConnectorLayer.module.css +129 -0
  447. package/src/internals/canvas/MarqueeOverlay.jsx +20 -0
  448. package/src/internals/canvas/PageSelector.jsx +587 -0
  449. package/src/internals/canvas/PageSelector.module.css +261 -0
  450. package/src/internals/canvas/PageSelector.test.jsx +113 -0
  451. package/src/internals/canvas/WebGLContextPool.jsx +292 -0
  452. package/src/internals/canvas/WebGLContextPool.test.jsx +165 -0
  453. package/src/internals/canvas/canvasApi.js +164 -0
  454. package/src/internals/canvas/canvasReloadGuard.js +37 -0
  455. package/src/internals/canvas/canvasReloadGuard.test.js +27 -0
  456. package/src/internals/canvas/canvasTheme.js +118 -0
  457. package/src/internals/canvas/componentIsolate.jsx +165 -0
  458. package/src/internals/canvas/componentSetIsolate.jsx +257 -0
  459. package/src/internals/canvas/computeCanvasBounds.test.js +121 -0
  460. package/src/internals/canvas/connectorGeometry.js +132 -0
  461. package/src/internals/canvas/hotPoolDevLogs.js +25 -0
  462. package/src/internals/canvas/textSelection.js +10 -0
  463. package/src/internals/canvas/textSelection.test.js +26 -0
  464. package/src/internals/canvas/useCanvas.js +126 -0
  465. package/src/internals/canvas/useCanvas.test.js +26 -0
  466. package/src/internals/canvas/useMarqueeSelect.js +213 -0
  467. package/src/internals/canvas/useMarqueeSelect.test.js +78 -0
  468. package/src/internals/canvas/useUndoRedo.js +86 -0
  469. package/src/internals/canvas/useUndoRedo.test.js +231 -0
  470. package/src/internals/canvas/widgets/CodePenEmbed.jsx +293 -0
  471. package/src/internals/canvas/widgets/CodePenEmbed.module.css +161 -0
  472. package/src/internals/canvas/widgets/ComponentSetWidget.jsx +2 -0
  473. package/src/internals/canvas/widgets/ComponentSetWidget.module.css +89 -0
  474. package/src/internals/canvas/widgets/ComponentWidget.jsx +14 -0
  475. package/src/internals/canvas/widgets/ComponentWidget.module.css +0 -0
  476. package/src/internals/canvas/widgets/CropOverlay.jsx +179 -0
  477. package/src/internals/canvas/widgets/CropOverlay.module.css +154 -0
  478. package/src/internals/canvas/widgets/ExpandedPane.jsx +474 -0
  479. package/src/internals/canvas/widgets/ExpandedPane.module.css +179 -0
  480. package/src/internals/canvas/widgets/ExpandedPane.test.jsx +240 -0
  481. package/src/internals/canvas/widgets/ExpandedPaneTopBar.jsx +111 -0
  482. package/src/internals/canvas/widgets/ExpandedPaneTopBar.module.css +59 -0
  483. package/src/internals/canvas/widgets/ExpandedPaneTopBar.test.jsx +45 -0
  484. package/src/internals/canvas/widgets/FigmaEmbed.jsx +296 -0
  485. package/src/internals/canvas/widgets/FigmaEmbed.module.css +222 -0
  486. package/src/internals/canvas/widgets/FrozenTerminalOverlay.jsx +151 -0
  487. package/src/internals/canvas/widgets/FrozenTerminalOverlay.module.css +83 -0
  488. package/src/internals/canvas/widgets/ImageWidget.jsx +287 -0
  489. package/src/internals/canvas/widgets/ImageWidget.module.css +81 -0
  490. package/src/internals/canvas/widgets/LinkPreview.jsx +439 -0
  491. package/src/internals/canvas/widgets/LinkPreview.module.css +585 -0
  492. package/src/internals/canvas/widgets/LinkPreview.test.jsx +193 -0
  493. package/src/internals/canvas/widgets/MarkdownBlock.jsx +354 -0
  494. package/src/internals/canvas/widgets/MarkdownBlock.module.css +377 -0
  495. package/src/internals/canvas/widgets/MarkdownBlock.test.jsx +92 -0
  496. package/src/internals/canvas/widgets/PromptWidget.jsx +428 -0
  497. package/src/internals/canvas/widgets/PromptWidget.module.css +273 -0
  498. package/src/internals/canvas/widgets/PrototypeEmbed.jsx +463 -0
  499. package/src/internals/canvas/widgets/PrototypeEmbed.module.css +579 -0
  500. package/src/internals/canvas/widgets/PrototypeEmbed.test.jsx +10 -0
  501. package/src/internals/canvas/widgets/ResizeHandle.jsx +67 -0
  502. package/src/internals/canvas/widgets/ResizeHandle.module.css +29 -0
  503. package/src/internals/canvas/widgets/StickyNote.jsx +92 -0
  504. package/src/internals/canvas/widgets/StickyNote.module.css +70 -0
  505. package/src/internals/canvas/widgets/StickyNote.test.jsx +116 -0
  506. package/src/internals/canvas/widgets/StorySetWidget.jsx +208 -0
  507. package/src/internals/canvas/widgets/StorySetWidget.module.css +89 -0
  508. package/src/internals/canvas/widgets/StoryWidget.jsx +334 -0
  509. package/src/internals/canvas/widgets/StoryWidget.module.css +211 -0
  510. package/src/internals/canvas/widgets/TerminalReadWidget.jsx +146 -0
  511. package/src/internals/canvas/widgets/TerminalReadWidget.module.css +94 -0
  512. package/src/internals/canvas/widgets/TerminalWidget.jsx +704 -0
  513. package/src/internals/canvas/widgets/TerminalWidget.module.css +444 -0
  514. package/src/internals/canvas/widgets/TilesWidget.jsx +300 -0
  515. package/src/internals/canvas/widgets/TilesWidget.module.css +133 -0
  516. package/src/internals/canvas/widgets/WidgetChrome.jsx +580 -0
  517. package/src/internals/canvas/widgets/WidgetChrome.module.css +421 -0
  518. package/src/internals/canvas/widgets/WidgetWrapper.jsx +15 -0
  519. package/src/internals/canvas/widgets/WidgetWrapper.module.css +25 -0
  520. package/src/internals/canvas/widgets/codepenUrl.js +75 -0
  521. package/src/internals/canvas/widgets/codepenUrl.test.js +76 -0
  522. package/src/internals/canvas/widgets/embedInteraction.test.jsx +173 -0
  523. package/src/internals/canvas/widgets/embedOverlay.module.css +35 -0
  524. package/src/internals/canvas/widgets/embedTheme.js +148 -0
  525. package/src/internals/canvas/widgets/expandUtils.js +559 -0
  526. package/src/internals/canvas/widgets/expandUtils.test.js +155 -0
  527. package/src/internals/canvas/widgets/figmaUrl.js +118 -0
  528. package/src/internals/canvas/widgets/figmaUrl.test.js +139 -0
  529. package/src/internals/canvas/widgets/githubUrl.js +82 -0
  530. package/src/internals/canvas/widgets/githubUrl.test.js +74 -0
  531. package/src/internals/canvas/widgets/iframeDevLogs.js +49 -0
  532. package/src/internals/canvas/widgets/iframeDevLogs.test.jsx +81 -0
  533. package/src/internals/canvas/widgets/index.js +42 -0
  534. package/src/internals/canvas/widgets/pasteRules.js +295 -0
  535. package/src/internals/canvas/widgets/pasteRules.test.js +474 -0
  536. package/src/internals/canvas/widgets/snapshotDisplay.test.jsx +211 -0
  537. package/src/internals/canvas/widgets/tilePool.js +23 -0
  538. package/src/internals/canvas/widgets/tiles/diagonal-bl.png +0 -0
  539. package/src/internals/canvas/widgets/tiles/diagonal-br.png +0 -0
  540. package/src/internals/canvas/widgets/tiles/diagonal-tl.png +0 -0
  541. package/src/internals/canvas/widgets/tiles/leaf.png +0 -0
  542. package/src/internals/canvas/widgets/tiles/quarter-tl.png +0 -0
  543. package/src/internals/canvas/widgets/tiles/quarter-tr.png +0 -0
  544. package/src/internals/canvas/widgets/tiles/solid-a.png +0 -0
  545. package/src/internals/canvas/widgets/tiles/solid-b.png +0 -0
  546. package/src/internals/canvas/widgets/widgetConfig.js +291 -0
  547. package/src/internals/canvas/widgets/widgetConfig.test.js +68 -0
  548. package/src/internals/canvas/widgets/widgetIcons.jsx +190 -0
  549. package/src/internals/canvas/widgets/widgetProps.js +133 -0
  550. package/src/internals/context/FormContext.js +13 -0
  551. package/src/internals/context/FormContext.test.js +48 -0
  552. package/src/internals/context.jsx +481 -0
  553. package/src/internals/context.test.jsx +296 -0
  554. package/src/internals/hashPreserver.js +73 -0
  555. package/src/internals/hashPreserver.test.js +107 -0
  556. package/src/internals/hooks/useConfig.js +14 -0
  557. package/src/internals/hooks/useFeatureFlag.js +14 -0
  558. package/src/internals/hooks/useFlows.js +50 -0
  559. package/src/internals/hooks/useFlows.test.js +134 -0
  560. package/src/internals/hooks/useHideMode.js +31 -0
  561. package/src/internals/hooks/useHideMode.test.js +43 -0
  562. package/src/internals/hooks/useLocalStorage.js +57 -0
  563. package/src/internals/hooks/useLocalStorage.test.js +75 -0
  564. package/src/internals/hooks/useMode.js +43 -0
  565. package/src/internals/hooks/useObject.js +101 -0
  566. package/src/internals/hooks/useObject.test.js +74 -0
  567. package/src/internals/hooks/useOverride.js +84 -0
  568. package/src/internals/hooks/useOverride.test.js +71 -0
  569. package/src/internals/hooks/usePrototypeReloadGuard.js +64 -0
  570. package/src/internals/hooks/useRecord.js +158 -0
  571. package/src/internals/hooks/useRecord.test.js +221 -0
  572. package/src/internals/hooks/useScene.js +38 -0
  573. package/src/internals/hooks/useScene.test.js +66 -0
  574. package/src/internals/hooks/useSceneData.js +108 -0
  575. package/src/internals/hooks/useSceneData.test.js +136 -0
  576. package/src/internals/hooks/useSession.js +4 -0
  577. package/src/internals/hooks/useSession.test.js +8 -0
  578. package/src/internals/hooks/useThemeState.js +61 -0
  579. package/src/internals/hooks/useThemeState.test.js +66 -0
  580. package/src/internals/hooks/useUndoRedo.js +28 -0
  581. package/src/internals/hooks/useUndoRedo.test.js +64 -0
  582. package/src/internals/index.js +58 -0
  583. package/src/internals/story/ComponentSetPage.jsx +198 -0
  584. package/src/internals/story/ComponentSetPage.module.css +129 -0
  585. package/src/internals/story/StoryPage.jsx +147 -0
  586. package/src/internals/story/StoryPage.module.css +18 -0
  587. package/src/internals/test-utils.js +45 -0
  588. package/src/internals/vite/data-plugin.js +1508 -0
  589. package/src/internals/vite/data-plugin.test.js +1223 -0
  590. package/src/test-utils.js +44 -0
  591. package/toolbar.config.json +271 -0
  592. package/widgets.config.json +1537 -0
@@ -0,0 +1,1508 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { execSync } from 'node:child_process'
4
+ import { globSync } from 'glob'
5
+ import { parse as parseJsonc } from 'jsonc-parser'
6
+ import { materializeFromText } from '../../core/canvas/materializer.js'
7
+ import { toCanvasId } from '../../core/canvas/identity.js'
8
+ import { isCanvasWriteInFlight } from '../../core/canvas/writeGuard.js'
9
+ import { getConfig } from '../../core/stores/configSchema.js'
10
+ import { list as listRunningServers } from '../../core/worktree/serverRegistry.js'
11
+
12
+ const VIRTUAL_MODULE_ID = 'virtual:storyboard-data-index'
13
+ const RESOLVED_ID = '\0' + VIRTUAL_MODULE_ID
14
+
15
+ const GLOB_PATTERN = '**/*.{flow,scene,object,record,prototype,folder}.{json,jsonc}'
16
+ const CANVAS_GLOB_PATTERN = '**/*.canvas.jsonl'
17
+ const CANVAS_META_GLOB_PATTERN = '**/*.meta.json'
18
+ const STORY_GLOB_PATTERN = '**/*.story.{jsx,tsx}'
19
+
20
+ /**
21
+ * Extract the data name and type suffix from a file path.
22
+ * Flows, records, and objects inside src/prototypes/{Name}/ get prefixed with
23
+ * the prototype name (e.g. "Dashboard/default", "Dashboard/helpers").
24
+ * Directories ending in .folder/ are skipped when extracting prototype scope.
25
+ *
26
+ * e.g. "src/data/default.flow.json" → { name: "default", suffix: "flow" }
27
+ * "src/prototypes/Dashboard/default.flow.json" → { name: "Dashboard/default", suffix: "flow" }
28
+ * "src/prototypes/Dashboard/helpers.object.json"→ { name: "Dashboard/helpers", suffix: "object" }
29
+ * "src/prototypes/X.folder/Dashboard/default.flow.json" → { name: "Dashboard/default", suffix: "flow", folder: "X" }
30
+ */
31
+ function parseDataFile(filePath) {
32
+ const base = path.basename(filePath)
33
+
34
+ // Handle .canvas.jsonl files
35
+ const canvasJsonlMatch = base.match(/^(.+)\.canvas\.jsonl$/)
36
+ if (canvasJsonlMatch) {
37
+ if (canvasJsonlMatch[1].startsWith('_')) return null
38
+ const normalized = filePath.replace(/\\/g, '/')
39
+ if (normalized.split('/').some(seg => seg.startsWith('_'))) return null
40
+
41
+ const baseName = canvasJsonlMatch[1]
42
+ let name = baseName
43
+ let inferredRoute = null
44
+ const canvasFolderMatch = normalized.match(/(?:^|\/)src\/canvas\/([^/]+)\.folder\//)
45
+ const canvasFolderName = canvasFolderMatch ? canvasFolderMatch[1] : null
46
+ const folderDirMatch = normalized.match(/(?:^|\/)src\/prototypes\/([^/]+)\.folder\//)
47
+ const folderName = folderDirMatch ? folderDirMatch[1] : null
48
+
49
+ const canvasCheck = normalized.match(/(?:^|\/)src\/canvas\//)
50
+ if (canvasCheck) {
51
+ const dirPath = normalized.substring(0, normalized.lastIndexOf('/'))
52
+ // Path-based ID: include folder context for uniqueness.
53
+ // .folder dirs contribute their name (sans .folder suffix) to the ID.
54
+ const idBase = (dirPath + '/')
55
+ .replace(/^.*?src\/canvas\//, '')
56
+ .replace(/\.folder\/?/g, '/')
57
+ .replace(/\/+/g, '/')
58
+ .replace(/\/$/, '')
59
+ name = idBase ? `${idBase}/${baseName}` : baseName
60
+ inferredRoute = '/canvas/' + name
61
+ inferredRoute = inferredRoute.replace(/\/+/g, '/').replace(/\/$/, '') || '/canvas'
62
+ }
63
+ const protoCheck = normalized.match(/(?:^|\/)src\/prototypes\//)
64
+ if (!canvasCheck && protoCheck) {
65
+ const dirPath = normalized.substring(0, normalized.lastIndexOf('/'))
66
+ // For prototypes, .folder is purely organizational — strip entirely
67
+ const idBase = (dirPath + '/')
68
+ .replace(/^.*?src\/prototypes\//, '')
69
+ .replace(/[^/]*\.folder\/?/g, '')
70
+ .replace(/\/+/g, '/')
71
+ .replace(/\/$/, '')
72
+ name = idBase ? `${idBase}/${baseName}` : baseName
73
+ inferredRoute = '/canvas/' + name
74
+ inferredRoute = inferredRoute.replace(/\/+/g, '/').replace(/\/$/, '') || '/canvas'
75
+ }
76
+ // Derive group: canvases sharing a directory form a group
77
+ const slashIdx = name.lastIndexOf('/')
78
+ const group = canvasFolderName || (slashIdx > 0 ? name.substring(0, slashIdx) : null)
79
+ // Extract a relative path for toCanvasId (it expects src/canvas/... or src/prototypes/...)
80
+ const canvasIdInput = normalized.replace(/^.*?(src\/(?:canvas|prototypes)\/)/, '$1')
81
+ return { name, suffix: 'canvas', ext: 'jsonl', folder: canvasFolderName || folderName, inferredRoute, id: toCanvasId(canvasIdInput), group }
82
+ }
83
+
84
+ // Handle canvas .meta.json files
85
+ const metaMatch = base.match(/^(.+)\.meta\.json$/)
86
+ if (metaMatch) {
87
+ const normalized = filePath.replace(/\\/g, '/')
88
+ // Only handle meta files inside src/canvas/ directories
89
+ const canvasCheck = normalized.match(/(?:^|\/)src\/canvas\//)
90
+ if (!canvasCheck) return null
91
+ // Skip _-prefixed
92
+ if (metaMatch[1].startsWith('_')) return null
93
+ if (normalized.split('/').some(seg => seg.startsWith('_'))) return null
94
+ return { name: metaMatch[1], suffix: 'canvas-meta', ext: 'json', inferredRoute: null }
95
+ }
96
+
97
+ // Handle .story.jsx / .story.tsx files
98
+ const storyMatch = base.match(/^(.+)\.story\.(jsx|tsx)$/)
99
+ if (storyMatch) {
100
+ if (storyMatch[1].startsWith('_')) return null
101
+ const normalized = filePath.replace(/\\/g, '/')
102
+ if (normalized.split('/').some(seg => seg.startsWith('_'))) return null
103
+
104
+ const name = storyMatch[1]
105
+ let inferredRoute = null
106
+
107
+ // All stories route under /components/ regardless of directory location
108
+ const canvasCheck = normalized.match(/(?:^|\/)src\/canvas\//)
109
+ const componentsCheck = normalized.match(/(?:^|\/)src\/components\//)
110
+ if (canvasCheck) {
111
+ const dirPath = normalized.substring(0, normalized.lastIndexOf('/'))
112
+ const routeBase = (dirPath + '/')
113
+ .replace(/^.*?src\/canvas\//, '')
114
+ .replace(/[^/]*\.folder\/?/g, '')
115
+ .replace(/\/$/, '')
116
+ inferredRoute = '/components/' + (routeBase ? routeBase + '/' : '') + name
117
+ inferredRoute = inferredRoute.replace(/\/+/g, '/').replace(/\/$/, '') || '/components'
118
+ } else if (componentsCheck) {
119
+ const dirPath = normalized.substring(0, normalized.lastIndexOf('/'))
120
+ const routeBase = (dirPath + '/')
121
+ .replace(/^.*?src\/components\//, '')
122
+ .replace(/[^/]*\.folder\/?/g, '')
123
+ .replace(/\/$/, '')
124
+ inferredRoute = '/components/' + (routeBase ? routeBase + '/' : '') + name
125
+ inferredRoute = inferredRoute.replace(/\/+/g, '/').replace(/\/$/, '') || '/components'
126
+ }
127
+
128
+ return { name, suffix: 'story', ext: storyMatch[2], inferredRoute }
129
+ }
130
+
131
+ const match = base.match(/^(.+)\.(flow|scene|object|record|prototype|folder)\.(jsonc?)$/)
132
+ if (!match) return null
133
+
134
+ // Skip _-prefixed files (drafts/internal)
135
+ if (match[1].startsWith('_')) return null
136
+
137
+ // Skip files inside _-prefixed directories
138
+ const normalized = filePath.replace(/\\/g, '/')
139
+ if (normalized.split('/').some(seg => seg.startsWith('_'))) return null
140
+ // Normalize .scene → .flow for backward compatibility
141
+ const suffix = match[2] === 'scene' ? 'flow' : match[2]
142
+ let name = match[1]
143
+
144
+ // Detect if this file is inside a .folder/ directory
145
+ const folderDirMatch = normalized.match(/(?:^|\/)src\/prototypes\/([^/]+)\.folder\//)
146
+ const folderName = folderDirMatch ? folderDirMatch[1] : null
147
+
148
+ // Folder metadata files are keyed by their folder directory name (sans .folder suffix)
149
+ if (suffix === 'folder') {
150
+ if (folderName) {
151
+ name = folderName
152
+ }
153
+ return { name, suffix, ext: match[3] }
154
+ }
155
+
156
+ // Prototype metadata files are keyed by their prototype directory name
157
+ // (skip .folder/ segments when determining prototype name)
158
+ if (suffix === 'prototype') {
159
+ const protoMatch = normalized.match(/(?:^|\/)src\/prototypes\/(?:[^/]+\.folder\/)?([^/]+)\//)
160
+ if (protoMatch) {
161
+ name = protoMatch[1]
162
+ }
163
+ return { name, suffix, ext: match[3], folder: folderName }
164
+ }
165
+
166
+ // Scope flows, records, and objects inside src/prototypes/{Name}/ with a prefix
167
+ // (skip .folder/ segments when determining prototype name)
168
+ const protoMatch = normalized.match(/(?:^|\/)src\/prototypes\/(?:[^/]+\.folder\/)?([^/]+)\//)
169
+ if (protoMatch) {
170
+ name = `${protoMatch[1]}/${name}`
171
+ }
172
+
173
+ // Infer route for prototype-scoped flows from their file path.
174
+ // Mirrors the generouted route regex: strip src/prototypes/ and *.folder/ segments.
175
+ let inferredRoute = null
176
+ if (suffix === 'flow') {
177
+ const protoCheck = normalized.match(/(?:^|\/)src\/prototypes\//)
178
+ if (protoCheck) {
179
+ const dirPath = normalized.substring(0, normalized.lastIndexOf('/'))
180
+ inferredRoute = '/' + dirPath
181
+ .replace(/^.*?src\/prototypes\//, '')
182
+ .replace(/[^/]*\.folder\//g, '')
183
+ // Normalize trailing slash and double slashes
184
+ inferredRoute = inferredRoute.replace(/\/+/g, '/').replace(/\/$/, '') || '/'
185
+ }
186
+ }
187
+
188
+ return { name, suffix, ext: match[3], inferredRoute }
189
+ }
190
+
191
+ /**
192
+ * Batch-fetch git metadata (author + lastModified) for multiple files in a
193
+ * single subprocess, avoiding per-file git overhead during startup.
194
+ *
195
+ * Returns a Map<absPath, { gitAuthor: string|null, lastModified: string|null }>
196
+ */
197
+ function batchGitMetadata(root, filePaths) {
198
+ const result = new Map()
199
+ if (filePaths.length === 0) return result
200
+
201
+ // Initialize all entries
202
+ for (const fp of filePaths) {
203
+ result.set(fp, { gitAuthor: null, lastModified: null })
204
+ }
205
+
206
+ try {
207
+ // Batch lastModified: one git log call with all paths
208
+ // git log -1 gives the most recent commit touching any of these paths,
209
+ // but we need per-path data. Use --name-only to correlate.
210
+ // For efficiency, use a single git log with --format and --name-only
211
+ // that outputs one record per commit touching these files.
212
+ const allDirs = [...new Set(filePaths.map(fp => path.dirname(fp)))]
213
+ const dirsArg = allDirs.map(d => `"${d}"`).join(' ')
214
+
215
+ // Get lastModified per directory in one call using git log --format
216
+ // We output "MARKER<sep>dir<sep>date" per commit, then take the latest per dir.
217
+ const logResult = execSync(
218
+ `git log --format="%aI" --name-only -- ${dirsArg}`,
219
+ { cwd: root, encoding: 'utf-8', timeout: 10000, maxBuffer: 1024 * 1024 },
220
+ ).trim()
221
+
222
+ if (logResult) {
223
+ // Parse: alternating date lines and filename lines separated by blank lines
224
+ const blocks = logResult.split('\n\n')
225
+ const dirDates = new Map() // dir → most recent date
226
+ for (const block of blocks) {
227
+ const lines = block.split('\n').filter(Boolean)
228
+ if (lines.length < 2) continue
229
+ const date = lines[0]
230
+ for (let li = 1; li < lines.length; li++) {
231
+ const fileLine = lines[li].trim()
232
+ if (!fileLine) continue
233
+ const dir = path.dirname(path.resolve(root, fileLine))
234
+ if (!dirDates.has(dir)) {
235
+ dirDates.set(dir, date)
236
+ }
237
+ }
238
+ }
239
+ for (const fp of filePaths) {
240
+ const dir = path.dirname(fp)
241
+ const entry = result.get(fp)
242
+ if (dirDates.has(dir) && entry) {
243
+ entry.lastModified = dirDates.get(dir)
244
+ }
245
+ }
246
+ }
247
+ } catch { /* git not available or failed — leave nulls */ }
248
+
249
+ // Batch gitAuthor: use git log for each file's creation author.
250
+ // Unfortunately --follow --diff-filter=A doesn't combine well with multiple
251
+ // paths, so batch them in a single shell invocation using a for loop.
252
+ try {
253
+ const relPaths = filePaths.map(fp => path.relative(root, fp))
254
+ // Build a shell script that outputs "PATH<tab>AUTHOR" per file
255
+ const cmds = relPaths.map(rp =>
256
+ `echo -n "${rp}\\t"; git log --follow --diff-filter=A --format="%aN" -- "${rp}" | tail -1`
257
+ ).join('; ')
258
+ const authorResult = execSync(cmds, {
259
+ cwd: root, encoding: 'utf-8', timeout: 10000, shell: true, maxBuffer: 1024 * 1024,
260
+ }).trim()
261
+
262
+ if (authorResult) {
263
+ for (const line of authorResult.split('\n')) {
264
+ const tabIdx = line.indexOf('\t')
265
+ if (tabIdx < 0) continue
266
+ const relPath = line.slice(0, tabIdx)
267
+ const author = line.slice(tabIdx + 1).trim()
268
+ if (!author) continue
269
+ const absPath2 = path.resolve(root, relPath)
270
+ const entry = result.get(absPath2)
271
+ if (entry) entry.gitAuthor = author
272
+ }
273
+ }
274
+ } catch { /* git not available */ }
275
+
276
+ return result
277
+ }
278
+
279
+ /**
280
+ * Scan the repo for all data files, validate uniqueness, return the index.
281
+ */
282
+ function buildIndex(root) {
283
+ const ignore = ['node_modules/**', 'dist/**', '.git/**', '.worktrees/**', 'public/**']
284
+ const files = globSync(GLOB_PATTERN, { cwd: root, ignore, absolute: false })
285
+ const canvasFiles = globSync(CANVAS_GLOB_PATTERN, { cwd: root, ignore, absolute: false })
286
+ const canvasMetaFiles = globSync(CANVAS_META_GLOB_PATTERN, { cwd: root, ignore, absolute: false })
287
+ const storyFiles = globSync(STORY_GLOB_PATTERN, { cwd: root, ignore, absolute: false })
288
+
289
+ // Detect nested .folder/ directories (not supported)
290
+ // Scan directories directly since empty nested folders have no data files
291
+ const folderDirs = globSync('src/prototypes/**/*.folder', { cwd: root, ignore, absolute: false })
292
+ for (const dir of folderDirs) {
293
+ const normalized = dir.replace(/\\/g, '/')
294
+ const segments = normalized.split('/').filter(s => s.endsWith('.folder'))
295
+ if (segments.length > 1) {
296
+ throw new Error(
297
+ `[storyboard-data] Nested .folder directories are not supported.\n` +
298
+ ` Found at: ${dir}\n` +
299
+ ` Folders can only be one level deep inside src/prototypes/.`
300
+ )
301
+ }
302
+ }
303
+
304
+ const index = { flow: {}, object: {}, record: {}, prototype: {}, folder: {}, canvas: {}, 'canvas-meta': {}, story: {} }
305
+ const seen = {} // "name.suffix" or "id.suffix" → absolute path (for duplicate detection)
306
+ const protoFolders = {} // prototype name → folder name (for injection)
307
+ const flowRoutes = {} // flow name → inferred route (for _route injection)
308
+ const canvasRoutes = {} // canvas name → inferred route
309
+ const canvasAliases = {} // basename → canonical ID (only when unique)
310
+ const canvasNameCount = {} // canvas basename → count (for ambiguity detection)
311
+ const canvasGroups = {} // canvas name → group name (shared folder prefix)
312
+ const storyRoutes = {} // story name → inferred route
313
+
314
+ for (const relPath of [...files, ...canvasFiles, ...canvasMetaFiles, ...storyFiles]) {
315
+ const parsed = parseDataFile(relPath)
316
+ if (!parsed) continue
317
+
318
+ // Canvas files use path-based IDs for dedup; others use basename
319
+ const dedupKey = parsed.suffix === 'canvas' && parsed.id
320
+ ? `${parsed.id}.${parsed.suffix}`
321
+ : `${parsed.name}.${parsed.suffix}`
322
+ const absPath = path.resolve(root, relPath)
323
+
324
+ if (seen[dedupKey]) {
325
+ const hint = parsed.suffix === 'folder'
326
+ ? ' Folder names must be unique across the project.'
327
+ : parsed.suffix === 'canvas'
328
+ ? ' Canvas IDs must be unique. Move or rename one file to resolve the collision.'
329
+ : ' Flows, records, and objects are scoped to their prototype directory.\n' +
330
+ ' If both files are global (outside src/prototypes/), rename one to avoid the collision.'
331
+
332
+ throw new Error(
333
+ `[storyboard-data] Duplicate ${parsed.suffix} "${parsed.id || parsed.name}"\n` +
334
+ ` Found at: ${seen[dedupKey]}\n` +
335
+ ` And at: ${absPath}\n` +
336
+ hint
337
+ )
338
+ }
339
+
340
+ seen[dedupKey] = absPath
341
+
342
+ // Canvas: index only by canonical ID. Basename aliases go in a separate map
343
+ // so listCanvases() and viewfinder don't show duplicates.
344
+ if (parsed.suffix === 'canvas' && parsed.id) {
345
+ index.canvas[parsed.id] = absPath
346
+ // Track basename for alias resolution (only when unique)
347
+ canvasNameCount[parsed.name] = (canvasNameCount[parsed.name] || 0) + 1
348
+ if (canvasNameCount[parsed.name] === 1) {
349
+ canvasAliases[parsed.name] = parsed.id
350
+ } else {
351
+ delete canvasAliases[parsed.name]
352
+ }
353
+ } else {
354
+ index[parsed.suffix][parsed.name] = absPath
355
+ }
356
+
357
+ // Track which folder a prototype belongs to
358
+ if (parsed.suffix === 'prototype' && parsed.folder) {
359
+ protoFolders[parsed.name] = parsed.folder
360
+ }
361
+
362
+ // Track inferred routes for flows
363
+ if (parsed.suffix === 'flow' && parsed.inferredRoute) {
364
+ flowRoutes[parsed.name] = parsed.inferredRoute
365
+ }
366
+
367
+ // Track inferred routes for canvases (keyed by canonical ID)
368
+ if (parsed.suffix === 'canvas' && parsed.inferredRoute) {
369
+ const canvasKey = parsed.id || parsed.name
370
+ canvasRoutes[canvasKey] = parsed.inferredRoute
371
+ }
372
+
373
+ // Track canvas groups (canvases sharing a folder prefix)
374
+ // Use canonical ID as key to match the canvas index
375
+ if (parsed.suffix === 'canvas' && parsed.group) {
376
+ const groupKey = parsed.id || parsed.name
377
+ canvasGroups[groupKey] = parsed.group
378
+ }
379
+
380
+ // Track inferred routes for stories
381
+ if (parsed.suffix === 'story' && parsed.inferredRoute) {
382
+ storyRoutes[parsed.name] = parsed.inferredRoute
383
+ }
384
+ }
385
+
386
+ return { index, protoFolders, flowRoutes, canvasRoutes, canvasAliases, canvasGroups, storyRoutes }
387
+ }
388
+
389
+ /**
390
+ * Recursively walk a parsed JSON value and replace `${varName}` patterns
391
+ * in every string value. Only string values are processed — keys, numbers,
392
+ * booleans, and null are left untouched.
393
+ */
394
+ function resolveTemplateVars(obj, vars) {
395
+ if (typeof obj === 'string') {
396
+ let result = obj
397
+ for (const [key, value] of Object.entries(vars)) {
398
+ result = result.replaceAll(`\${${key}}`, value)
399
+ }
400
+ return result
401
+ }
402
+ if (Array.isArray(obj)) return obj.map(item => resolveTemplateVars(item, vars))
403
+ if (obj !== null && typeof obj === 'object') {
404
+ const out = {}
405
+ for (const [key, value] of Object.entries(obj)) {
406
+ out[key] = resolveTemplateVars(value, vars)
407
+ }
408
+ return out
409
+ }
410
+ return obj
411
+ }
412
+
413
+ /**
414
+ * Compute path-based template variables for a data file.
415
+ *
416
+ * - currentDir: directory of the file, relative to project root
417
+ * - currentProto: path to the prototype directory (e.g. src/prototypes/main.folder/Example)
418
+ * - currentProtoDir: path to the first parent *.folder directory (e.g. src/prototypes/main.folder)
419
+ */
420
+ function computeTemplateVars(absPath, root) {
421
+ const relPath = path.relative(root, absPath).replace(/\\/g, '/')
422
+ const currentDir = path.dirname(relPath).replace(/\\/g, '/')
423
+
424
+ const protoMatch = relPath.match(/^(src\/prototypes\/(?:[^/]+\.folder\/)?[^/]+)\//)
425
+ const currentProto = protoMatch && !protoMatch[1].endsWith('.folder') ? protoMatch[1] : ''
426
+
427
+ const folderMatch = relPath.match(/^(src\/prototypes\/[^/]+\.folder)\//)
428
+ const currentProtoDir = folderMatch ? folderMatch[1] : ''
429
+
430
+ return { currentDir, currentProto, currentProtoDir }
431
+ }
432
+
433
+ /**
434
+ * Generate the virtual module source code.
435
+ * Reads each data file, parses JSONC at build time, and emits pre-parsed
436
+ * JavaScript objects — no runtime parsing needed.
437
+ */
438
+ /**
439
+ * Read storyboard.config.json from the project root (if it exists).
440
+ * Returns the parsed and defaulted config object, or null if not found.
441
+ */
442
+ function readConfig(root) {
443
+ const configPath = path.resolve(root, 'storyboard.config.json')
444
+ try {
445
+ const raw = fs.readFileSync(configPath, 'utf-8')
446
+ const errors = []
447
+ const config = parseJsonc(raw, errors)
448
+ // Treat malformed JSON (e.g. mid-edit partial saves) as missing config
449
+ if (errors.length > 0) return { config: null, configPath }
450
+ return { config: getConfig(config), configPath }
451
+ } catch {
452
+ return { config: null, configPath }
453
+ }
454
+ }
455
+
456
+ /**
457
+ * Read toolbar.config.json from @dfosco/storyboard.
458
+ * Returns the full config object with modes array.
459
+ * Falls back to hardcoded defaults if not found.
460
+ */
461
+ function readModesConfig(root) {
462
+ const fallback = {
463
+ modes: [
464
+ { name: 'prototype', label: 'Navigate', hue: '#2a2a2a' },
465
+ { name: 'inspect', label: 'Develop', hue: '#7655a4' },
466
+ { name: 'present', label: 'Collaborate', hue: '#2a9d8f' },
467
+ { name: 'plan', label: 'Canvas', hue: '#4a7fad' },
468
+ ],
469
+ }
470
+
471
+ // Try local workspace path first (monorepo), then node_modules
472
+ const candidates = [
473
+ path.resolve(root, 'packages/storyboard/toolbar.config.json'),
474
+ path.resolve(root, 'packages/storyboard/configs/modes.config.json'),
475
+ path.resolve(root, 'node_modules/@dfosco/storyboard/toolbar.config.json'),
476
+ path.resolve(root, 'node_modules/@dfosco/storyboard/configs/modes.config.json'),
477
+ ]
478
+
479
+ for (const filePath of candidates) {
480
+ try {
481
+ const raw = fs.readFileSync(filePath, 'utf-8')
482
+ const parsed = JSON.parse(raw)
483
+ if (Array.isArray(parsed.modes) && parsed.modes.length > 0) {
484
+ return { modes: parsed.modes }
485
+ }
486
+ } catch {
487
+ // try next candidate
488
+ }
489
+ }
490
+
491
+ return fallback
492
+ }
493
+
494
+ /**
495
+ * Read a JSON/JSONC file, returning null on failure.
496
+ */
497
+ function readJsonFile(filePath) {
498
+ try {
499
+ const raw = fs.readFileSync(filePath, 'utf-8')
500
+ const errors = []
501
+ const parsed = parseJsonc(raw, errors)
502
+ return errors.length === 0 ? parsed : null
503
+ } catch {
504
+ return null
505
+ }
506
+ }
507
+
508
+ /**
509
+ * Find a core config file from either the monorepo workspace or node_modules.
510
+ */
511
+ function readCoreConfigFile(root, filename) {
512
+ const candidates = [
513
+ path.resolve(root, `packages/storyboard/${filename}`),
514
+ path.resolve(root, `node_modules/@dfosco/storyboard/${filename}`),
515
+ ]
516
+ for (const p of candidates) {
517
+ const parsed = readJsonFile(p)
518
+ if (parsed) return parsed
519
+ }
520
+ return null
521
+ }
522
+
523
+ /**
524
+ * Deep-merge helper (same as loader.js deepMerge but available at build time).
525
+ * Arrays are replaced, not concatenated. Objects are recursively merged.
526
+ */
527
+ function deepMergeBuild(target, source) {
528
+ if (!source || typeof source !== 'object') return target
529
+ if (!target || typeof target !== 'object') return source
530
+ const result = { ...target }
531
+ for (const key of Object.keys(source)) {
532
+ const sv = source[key]
533
+ const tv = target[key]
534
+ if (sv && typeof sv === 'object' && !Array.isArray(sv) && tv && typeof tv === 'object' && !Array.isArray(tv)) {
535
+ result[key] = deepMergeBuild(tv, sv)
536
+ } else if (Array.isArray(sv) && Array.isArray(tv) && sv.length > 0 && tv.length > 0 && sv[0]?.id && tv[0]?.id) {
537
+ // Id-based array merge: override matching entries by id, keep the rest, append new ones
538
+ const targetMap = new Map(tv.map(item => [item.id, item]))
539
+ for (const item of sv) {
540
+ targetMap.set(item.id, item.id && targetMap.has(item.id)
541
+ ? deepMergeBuild(targetMap.get(item.id), item)
542
+ : item)
543
+ }
544
+ result[key] = [...targetMap.values()]
545
+ } else {
546
+ result[key] = sv
547
+ }
548
+ }
549
+ return result
550
+ }
551
+
552
+ /**
553
+ * Build the unified config object by reading and merging all config sources.
554
+ *
555
+ * Priority (lowest → highest):
556
+ * configSchema defaults → core domain configs → storyboard.config.json → user domain configs
557
+ *
558
+ * Domain-specific config files (toolbar.config.json, commandpalette.config.json, etc.)
559
+ * always win over storyboard.config.json — specificity beats generality.
560
+ * Deep merge is used at every layer: objects are recursively merged (keys append),
561
+ * arrays and scalars are replaced.
562
+ *
563
+ * Returns { unified, warnings } where warnings is an array of overlap messages.
564
+ */
565
+ function buildUnifiedConfig(root) {
566
+ const warnings = []
567
+
568
+ // 1. Read core defaults (lowest priority domain configs)
569
+ const coreToolbar = readCoreConfigFile(root, 'toolbar.config.json') || {}
570
+ const coreCommandPalette = readCoreConfigFile(root, 'commandpalette.config.json') || {}
571
+ const corePaste = readCoreConfigFile(root, 'paste.config.json') || {}
572
+ const coreWidgets = readCoreConfigFile(root, 'widgets.config.json') || {}
573
+
574
+ // 2. Read storyboard.config.json (middle priority)
575
+ // Use the schema-defaulted config for most things, but also read
576
+ // the raw file to know which keys were explicitly set by the user.
577
+ const { config: sbConfig } = readConfig(root)
578
+ const rawSbConfig = readJsonFile(path.resolve(root, 'storyboard.config.json')) || {}
579
+
580
+ // 3. Apply storyboard.config.json overrides on top of core domain configs.
581
+ // Only merge when the user explicitly defined the key in storyboard.config.json
582
+ // (not from configSchema defaults, which would overwrite core config with empty arrays).
583
+ const afterSbToolbar = rawSbConfig.toolbar
584
+ ? deepMergeBuild(coreToolbar, sbConfig.toolbar)
585
+ : coreToolbar
586
+ const afterSbCommandPalette = rawSbConfig.commandPalette
587
+ ? deepMergeBuild(coreCommandPalette, sbConfig.commandPalette)
588
+ : coreCommandPalette
589
+ const afterSbPaste = rawSbConfig.paste
590
+ ? deepMergeBuild(corePaste, sbConfig.paste || {})
591
+ : corePaste
592
+ const afterSbWidgets = rawSbConfig.widgets
593
+ ? deepMergeBuild(coreWidgets, sbConfig.widgets || {})
594
+ : coreWidgets
595
+
596
+ // 4. Read user domain config files (highest priority)
597
+ const userFiles = [
598
+ { domain: 'widgets', filename: 'widgets.config.json' },
599
+ { domain: 'paste', filename: 'paste.config.json' },
600
+ { domain: 'toolbar', filename: 'toolbar.config.json' },
601
+ { domain: 'commandPalette', filename: 'commandpalette.config.json' },
602
+ ]
603
+
604
+ const userConfigs = {}
605
+ for (const { domain, filename } of userFiles) {
606
+ const filePath = path.resolve(root, filename)
607
+ const parsed = readJsonFile(filePath)
608
+ if (parsed) userConfigs[domain] = { data: parsed, filename }
609
+ }
610
+
611
+ // 5. Apply user domain configs on top of everything (highest priority)
612
+ const finalToolbar = userConfigs.toolbar
613
+ ? deepMergeBuild(afterSbToolbar, userConfigs.toolbar.data)
614
+ : afterSbToolbar
615
+ const finalCommandPalette = userConfigs.commandPalette
616
+ ? deepMergeBuild(afterSbCommandPalette, userConfigs.commandPalette.data)
617
+ : afterSbCommandPalette
618
+ const finalPaste = userConfigs.paste
619
+ ? deepMergeBuild(afterSbPaste, userConfigs.paste.data)
620
+ : afterSbPaste
621
+ const finalWidgets = userConfigs.widgets
622
+ ? deepMergeBuild(afterSbWidgets, userConfigs.widgets.data)
623
+ : afterSbWidgets
624
+
625
+ // 6. Detect overlaps between storyboard.config.json and user domain configs
626
+ const domainOverlapChecks = [
627
+ { sbKey: 'toolbar', domain: 'toolbar', label: 'toolbar.config.json' },
628
+ { sbKey: 'commandPalette', domain: 'commandPalette', label: 'commandpalette.config.json' },
629
+ { sbKey: 'paste', domain: 'paste', label: 'paste.config.json' },
630
+ { sbKey: 'widgets', domain: 'widgets', label: 'widgets.config.json' },
631
+ ]
632
+ for (const { sbKey, domain, label } of domainOverlapChecks) {
633
+ if (rawSbConfig[sbKey] && userConfigs[domain]) {
634
+ const overlaps = findOverlappingKeys(rawSbConfig[sbKey], userConfigs[domain].data)
635
+ for (const key of overlaps) {
636
+ warnings.push(`Config overlap: "${key}" is defined in both storyboard.config.json.${sbKey} and ${label} — ${label} wins.`)
637
+ }
638
+ }
639
+ }
640
+
641
+ // 7. Build the unified config object
642
+ const unified = {
643
+ toolbar: finalToolbar,
644
+ commandPalette: finalCommandPalette,
645
+ paste: finalPaste,
646
+ widgets: finalWidgets,
647
+ featureFlags: sbConfig?.featureFlags || {},
648
+ modes: sbConfig?.modes || {},
649
+ ui: sbConfig?.ui || {},
650
+ canvas: sbConfig?.canvas || {},
651
+ comments: sbConfig?.comments || {},
652
+ customerMode: sbConfig?.customerMode || {},
653
+ plugins: sbConfig?.plugins || {},
654
+ repository: sbConfig?.repository || {},
655
+ workshop: sbConfig?.workshop || {},
656
+ }
657
+
658
+ return { unified, warnings }
659
+ }
660
+
661
+ /**
662
+ * Find top-level keys that exist in both objects (overlap detection).
663
+ */
664
+ function findOverlappingKeys(a, b, prefix = '') {
665
+ const overlaps = []
666
+ if (!a || !b || typeof a !== 'object' || typeof b !== 'object') return overlaps
667
+ for (const key of Object.keys(a)) {
668
+ if (key in b) {
669
+ const path = prefix ? `${prefix}.${key}` : key
670
+ overlaps.push(path)
671
+ }
672
+ }
673
+ return overlaps
674
+ }
675
+
676
+ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes, canvasAliases, canvasGroups, storyRoutes }, root) {
677
+ const declarations = []
678
+ const INDEX_KEYS = ['flow', 'object', 'record', 'prototype', 'folder', 'canvas']
679
+ const entries = { flow: [], object: [], record: [], prototype: [], folder: [], canvas: [] }
680
+ const storyEntries = [] // handled separately (code modules, not JSON data)
681
+ const resolvedFlowRoutes = {} // flow name → resolved route (for multi-flow logging)
682
+ let i = 0
683
+
684
+ // Batch-fetch git metadata for all prototype + canvas files in 1-2 subprocesses
685
+ const gitPaths = [
686
+ ...Object.values(index.prototype || {}),
687
+ ...Object.values(index.canvas || {}),
688
+ ]
689
+ const gitMeta = batchGitMetadata(root, gitPaths)
690
+
691
+ // Read canvas-meta files and build a directory-based lookup
692
+ const canvasMetaByDir = {}
693
+ for (const [, absPath] of Object.entries(index['canvas-meta'] || {})) {
694
+ try {
695
+ const raw = fs.readFileSync(absPath, 'utf-8')
696
+ const parsed = parseJsonc(raw)
697
+ if (parsed) {
698
+ // Key by the parent directory path relative to src/canvas/
699
+ const dirPath = path.dirname(absPath).replace(/\\/g, '/')
700
+ const canvasRelDir = dirPath.replace(/^.*?src\/canvas\//, '')
701
+ canvasMetaByDir[canvasRelDir] = parsed
702
+ }
703
+ } catch { /* skip invalid meta files */ }
704
+ }
705
+
706
+ for (const suffix of INDEX_KEYS) {
707
+ for (const [name, absPath] of Object.entries(index[suffix])) {
708
+ const varName = `_d${i++}`
709
+ const raw = fs.readFileSync(absPath, 'utf-8')
710
+ let parsed = suffix === 'canvas'
711
+ ? materializeFromText(raw)
712
+ : parseJsonc(raw)
713
+
714
+ // Auto-fill gitAuthor for prototype metadata from git history
715
+ if (suffix === 'prototype' && parsed && !parsed.gitAuthor) {
716
+ const meta = gitMeta.get(absPath)
717
+ if (meta?.gitAuthor) {
718
+ parsed = { ...parsed, gitAuthor: meta.gitAuthor }
719
+ }
720
+ }
721
+
722
+ // Auto-fill lastModified from git history for prototypes
723
+ if (suffix === 'prototype' && parsed) {
724
+ const meta = gitMeta.get(absPath)
725
+ if (meta?.lastModified) {
726
+ parsed = { ...parsed, lastModified: meta.lastModified }
727
+ }
728
+ }
729
+
730
+ // Inject folder association into prototype metadata
731
+ if (suffix === 'prototype' && protoFolders[name]) {
732
+ parsed = { ...parsed, folder: protoFolders[name] }
733
+ }
734
+
735
+ // Load prototype-level config overrides from the prototype directory.
736
+ // Any config file placed alongside the .prototype.json becomes an override
737
+ // for that domain when the prototype is active.
738
+ if (suffix === 'prototype') {
739
+ const protoDir = path.dirname(absPath)
740
+ const protoConfigFiles = [
741
+ { filename: 'toolbar.config.json', key: 'toolbarConfig' },
742
+ { filename: 'commandpalette.config.json', key: 'commandPaletteConfig' },
743
+ { filename: 'widgets.config.json', key: 'widgetsConfig' },
744
+ { filename: 'paste.config.json', key: 'pasteConfig' },
745
+ ]
746
+ for (const { filename, key } of protoConfigFiles) {
747
+ const cfgPath = path.join(protoDir, filename)
748
+ if (fs.existsSync(cfgPath)) {
749
+ try {
750
+ const raw = fs.readFileSync(cfgPath, 'utf-8')
751
+ const cfg = parseJsonc(raw)
752
+ if (cfg) {
753
+ parsed = { ...parsed, [key]: cfg }
754
+ }
755
+ } catch { /* skip invalid config */ }
756
+ }
757
+ }
758
+ }
759
+
760
+ // Inject inferred _route into flow data (explicit route takes precedence)
761
+ if (suffix === 'flow' && flowRoutes[name] && !parsed?.route) {
762
+ parsed = { ...parsed, _route: flowRoutes[name] }
763
+ }
764
+
765
+ // Track resolved route for multi-flow logging
766
+ if (suffix === 'flow') {
767
+ const route = parsed?.route || parsed?._route || null
768
+ if (route) {
769
+ resolvedFlowRoutes[name] = { route, isDefault: parsed?.meta?.default === true }
770
+ }
771
+ }
772
+
773
+ // Auto-fill gitAuthor for canvas metadata from git history
774
+ if (suffix === 'canvas' && parsed && !parsed.gitAuthor) {
775
+ const meta = gitMeta.get(absPath)
776
+ if (meta?.gitAuthor) {
777
+ parsed = { ...parsed, gitAuthor: meta.gitAuthor }
778
+ }
779
+ }
780
+
781
+ // Inject inferred route, group, and resolve JSX companion for canvases
782
+ if (suffix === 'canvas') {
783
+ if (canvasRoutes[name]) {
784
+ parsed = { ...parsed, _route: canvasRoutes[name] }
785
+ }
786
+ if (canvasGroups[name]) {
787
+ parsed = { ...parsed, _group: canvasGroups[name] }
788
+ }
789
+ // Inject canvas folder metadata from .meta.json
790
+ if (canvasGroups[name] && canvasMetaByDir[canvasGroups[name]]) {
791
+ parsed = { ...parsed, _canvasMeta: canvasMetaByDir[canvasGroups[name]] }
792
+ }
793
+ // Inject folder association
794
+ const folderDirMatch = path.relative(root, absPath).replace(/\\/g, '/').match(/(?:^|\/)src\/(?:prototypes|canvas)\/([^/]+)\.folder\//)
795
+ if (folderDirMatch) {
796
+ parsed = { ...parsed, _folder: folderDirMatch[1] }
797
+ }
798
+ // Resolve JSX companion file path
799
+ if (parsed?.jsx) {
800
+ const jsxPath = path.resolve(path.dirname(absPath), parsed.jsx)
801
+ if (fs.existsSync(jsxPath)) {
802
+ const relJsx = '/' + path.relative(root, jsxPath).replace(/\\/g, '/')
803
+ parsed = { ...parsed, _jsxModule: relJsx }
804
+ } else {
805
+ console.warn(
806
+ `[storyboard-data] Canvas "${name}" references JSX file "${parsed.jsx}" but it was not found at ${jsxPath}`
807
+ )
808
+ }
809
+ }
810
+ }
811
+
812
+ // Resolve template variables (${currentDir}, ${currentProto}, ${currentProtoDir})
813
+ const templateVars = computeTemplateVars(absPath, root)
814
+ if (!templateVars.currentProto && raw.includes('${currentProto}')) {
815
+ console.warn(
816
+ `[storyboard-data] \${currentProto} used in "${path.relative(root, absPath)}" ` +
817
+ `but file is not inside a prototype directory. Variable resolves to empty string.`
818
+ )
819
+ }
820
+ if (!templateVars.currentProtoDir && raw.includes('${currentProtoDir}')) {
821
+ console.warn(
822
+ `[storyboard-data] \${currentProtoDir} used in "${path.relative(root, absPath)}" ` +
823
+ `but file is not inside a .folder directory. Variable resolves to empty string.`
824
+ )
825
+ }
826
+ parsed = resolveTemplateVars(parsed, templateVars)
827
+
828
+ if (suffix === 'canvas' && parsed._jsxModule) {
829
+ declarations.push(`const ${varName} = Object.assign(${JSON.stringify(parsed)}, { _jsxImport: () => import(${JSON.stringify(parsed._jsxModule)}) })`)
830
+ } else {
831
+ declarations.push(`const ${varName} = ${JSON.stringify(parsed)}`)
832
+ }
833
+ entries[suffix].push(` ${JSON.stringify(name)}: ${varName}`)
834
+ }
835
+ }
836
+
837
+ // Generate story entries (code modules with dynamic imports, not JSON data)
838
+ for (const [name, absPath] of Object.entries(index.story || {})) {
839
+ const varName = `_d${i++}`
840
+ const relModule = '/' + path.relative(root, absPath).replace(/\\/g, '/')
841
+ const storyMeta = { _storyModule: relModule }
842
+ if (storyRoutes[name]) {
843
+ storyMeta._route = storyRoutes[name]
844
+ }
845
+ declarations.push(
846
+ `const ${varName} = Object.assign(${JSON.stringify(storyMeta)}, { _storyImport: () => import(${JSON.stringify(relModule)}) })`
847
+ )
848
+ storyEntries.push(` ${JSON.stringify(name)}: ${varName}`)
849
+ }
850
+
851
+ const imports = [`import { init } from '@dfosco/storyboard/core'`]
852
+ const initCalls = [`init({ flows, objects, records, prototypes, folders, canvases, stories })`]
853
+
854
+ // Build unified config from all sources
855
+ const { unified: unifiedConfig, warnings: configWarnings } = buildUnifiedConfig(root)
856
+ for (const w of configWarnings) {
857
+ console.warn(`[storyboard] ⚠ ${w}`)
858
+ }
859
+ imports.push(`import { initConfig } from '@dfosco/storyboard/core'`)
860
+ initCalls.push(`initConfig(${JSON.stringify(unifiedConfig)})`)
861
+
862
+ // Feature flags from storyboard.config.json
863
+ const { config } = readConfig(root)
864
+ if (config?.featureFlags && Object.keys(config.featureFlags).length > 0) {
865
+ imports.push(`import { initFeatureFlags } from '@dfosco/storyboard/core'`)
866
+ initCalls.push(`initFeatureFlags(${JSON.stringify(config.featureFlags)})`)
867
+ }
868
+
869
+ // Plugin configuration from storyboard.config.json
870
+ if (config?.plugins && Object.keys(config.plugins).length > 0) {
871
+ imports.push(`import { initPlugins } from '@dfosco/storyboard/core'`)
872
+ initCalls.push(`initPlugins(${JSON.stringify(config.plugins)})`)
873
+ }
874
+
875
+ // Modes configuration from storyboard.config.json
876
+ if (config?.modes) {
877
+ imports.push(`import { initModesConfig, registerMode, syncModeClasses, initTools } from '@dfosco/storyboard/core'`)
878
+ initCalls.push(`initModesConfig(${JSON.stringify(config.modes)})`)
879
+
880
+ if (config.modes.enabled) {
881
+ imports.push(`import '@dfosco/storyboard/modes.css'`)
882
+
883
+ const modesConfig = readModesConfig(root)
884
+ const modes = config.modes.defaults || modesConfig.modes
885
+ for (const m of modes) {
886
+ initCalls.push(`registerMode(${JSON.stringify(m.name)}, { label: ${JSON.stringify(m.label)} })`)
887
+ }
888
+
889
+ initCalls.push(`syncModeClasses()`)
890
+ }
891
+ }
892
+
893
+ // UI config from storyboard.config.json (menu visibility overrides)
894
+ if (config?.ui) {
895
+ imports.push(`import { initUIConfig } from '@dfosco/storyboard/core'`)
896
+ initCalls.push(`initUIConfig(${JSON.stringify(config.ui)})`)
897
+ }
898
+
899
+ // Customer mode config from storyboard.config.json
900
+ if (config?.customerMode) {
901
+ imports.push(`import { initCustomerModeConfig } from '@dfosco/storyboard/core'`)
902
+ initCalls.push(`initCustomerModeConfig(${JSON.stringify(config.customerMode)})`)
903
+ }
904
+
905
+ // Client toolbar overrides from root toolbar.config.json
906
+ const clientToolbarPath = path.resolve(root, 'toolbar.config.json')
907
+ try {
908
+ if (fs.existsSync(clientToolbarPath)) {
909
+ const raw = fs.readFileSync(clientToolbarPath, 'utf-8')
910
+ const errors = []
911
+ const parsed = parseJsonc(raw, errors)
912
+ if (parsed && errors.length === 0) {
913
+ imports.push(`import { setClientToolbarOverrides } from '@dfosco/storyboard/core'`)
914
+ initCalls.push(`setClientToolbarOverrides(${JSON.stringify(parsed)})`)
915
+ }
916
+ }
917
+ } catch { /* skip if unreadable */ }
918
+
919
+ // Log info when multiple flows target the same route
920
+ const routeGroups = {}
921
+ for (const [name, { route, isDefault }] of Object.entries(resolvedFlowRoutes)) {
922
+ if (!routeGroups[route]) routeGroups[route] = []
923
+ routeGroups[route].push({ name, isDefault })
924
+ }
925
+ for (const [route, flows] of Object.entries(routeGroups)) {
926
+ if (flows.length > 1) {
927
+ const defaults = flows.filter(f => f.isDefault)
928
+ if (defaults.length > 1) {
929
+ console.warn(
930
+ `[storyboard-data] Warning: Route "${route}" has ${defaults.length} flows with meta.default: true.\n` +
931
+ ` Only one flow per route should be marked as default.`
932
+ )
933
+ }
934
+ }
935
+ }
936
+
937
+ return [
938
+ imports.join('\n'),
939
+ '',
940
+ declarations.join('\n'),
941
+ '',
942
+ `const flows = {\n${entries.flow.join(',\n')}\n}`,
943
+ `const objects = {\n${entries.object.join(',\n')}\n}`,
944
+ `const records = {\n${entries.record.join(',\n')}\n}`,
945
+ `const prototypes = {\n${entries.prototype.join(',\n')}\n}`,
946
+ `const folders = {\n${entries.folder.join(',\n')}\n}`,
947
+ `const canvases = {\n${entries.canvas.join(',\n')}\n}`,
948
+ `const stories = {\n${storyEntries.join(',\n')}\n}`,
949
+ '',
950
+ `// Legacy basename → canonical ID aliases (only unique basenames)`,
951
+ `const canvasAliases = ${JSON.stringify(canvasAliases || {})}`,
952
+ '',
953
+ '// Backward-compatible alias',
954
+ 'const scenes = flows',
955
+ '',
956
+ initCalls.join('\n'),
957
+ '',
958
+ `export { flows, scenes, objects, records, prototypes, folders, canvases, canvasAliases, stories }`,
959
+ `export const index = { flows, scenes, objects, records, prototypes, folders, canvases, canvasAliases, stories }`,
960
+ `export default index`,
961
+ '',
962
+ '// Live-patch canvas data on HMR events so SPA navigation shows fresh state',
963
+ 'if (import.meta.hot) {',
964
+ ' import.meta.hot.on("storyboard:canvas-file-changed", (data) => {',
965
+ ' if (!data) return',
966
+ ' const id = data.canvasId || data.name',
967
+ ' if (data.removed) {',
968
+ ' delete canvases[id]',
969
+ ' } else if (data.metadata) {',
970
+ ' // Merge into existing entry to preserve build-time fields (_jsxModule, _jsxImport, etc.)',
971
+ ' canvases[id] = canvases[id]',
972
+ ' ? Object.assign({}, canvases[id], data.metadata)',
973
+ ' : data.metadata',
974
+ ' }',
975
+ ' init({ flows, objects, records, prototypes, folders, canvases, stories })',
976
+ ' })',
977
+ ' import.meta.hot.on("storyboard:story-file-changed", (data) => {',
978
+ ' if (!data) return',
979
+ ' if (data.removed) {',
980
+ ' delete stories[data.name]',
981
+ ' } else {',
982
+ ' stories[data.name] = { _storyModule: data._storyModule, _route: data._route,',
983
+ ' _storyImport: () => import(/* @vite-ignore */ data._storyModule) }',
984
+ ' }',
985
+ ' init({ flows, objects, records, prototypes, folders, canvases, stories })',
986
+ ' document.dispatchEvent(new CustomEvent("storyboard:story-index-changed"))',
987
+ ' })',
988
+ '}',
989
+ ].join('\n')
990
+ }
991
+
992
+ /**
993
+ * Vite plugin for storyboard data discovery.
994
+ *
995
+ * - Scans the repo for *.flow.json, *.scene.json (compat), *.object.json, *.record.json, *.canvas.jsonl, *.story.{jsx,tsx}
996
+ * - Validates no two files share the same name+suffix (hard build error)
997
+ * - Generates a virtual module `virtual:storyboard-data-index`
998
+ * - Watches for file additions/removals in dev mode
999
+ */
1000
+ export default function storyboardDataPlugin() {
1001
+ let root = ''
1002
+ let buildResult = null
1003
+
1004
+ return {
1005
+ name: 'storyboard-data',
1006
+ enforce: 'pre',
1007
+
1008
+ config() {
1009
+ return {
1010
+ optimizeDeps: {
1011
+ // @dfosco/storyboard is excluded (virtual module), so Vite
1012
+ // can't trace into its deps. Include the remark entry points so
1013
+ // Vite pre-bundles the full chain — covers all transitive CJS
1014
+ // packages (debug, extend, etc.) without whack-a-mole.
1015
+ include: ['cmdk', 'remark', 'remark-gfm', 'remark-html', 'use-sync-external-store/shim', 'use-sync-external-store/shim/with-selector'],
1016
+ exclude: ['@dfosco/storyboard'],
1017
+ },
1018
+ }
1019
+ },
1020
+
1021
+ configResolved(config) {
1022
+ root = config.root
1023
+ },
1024
+
1025
+ resolveId(id) {
1026
+ if (id === VIRTUAL_MODULE_ID) return RESOLVED_ID
1027
+ },
1028
+
1029
+ load(id) {
1030
+ if (id !== RESOLVED_ID) return null
1031
+ if (!buildResult) buildResult = buildIndex(root)
1032
+ return generateModule(buildResult, root)
1033
+ },
1034
+
1035
+ configureServer(server) {
1036
+ // ── Component isolate middleware ───────────────────────────────
1037
+ // Serves a minimal HTML shell for iframe-isolated component widgets.
1038
+ // The iframe loads componentIsolate.jsx which reads query params
1039
+ // (module, export, theme) and renders a single story export.
1040
+ const isolateEntryPath = new URL('../canvas/componentIsolate.jsx', import.meta.url).pathname
1041
+ // Component-set isolate — renders all exports in a grid, bypassing the full SPA.
1042
+ const componentSetIsolateEntryPath = new URL('../canvas/componentSetIsolate.jsx', import.meta.url).pathname
1043
+ server.middlewares.use(async (req, res, next) => {
1044
+ if (!req.url) return next()
1045
+ let url = req.url
1046
+ const baseNoTrail = (server.config.base || '/').replace(/\/$/, '')
1047
+ if (baseNoTrail && url.startsWith(baseNoTrail)) {
1048
+ url = url.slice(baseNoTrail.length) || '/'
1049
+ }
1050
+ // Match both single-component and component-set isolate routes
1051
+ const isComponentSet = url.startsWith('/_storyboard/canvas/isolate-set')
1052
+ const isSingle = !isComponentSet && url.startsWith('/_storyboard/canvas/isolate')
1053
+ if (!isSingle && !isComponentSet) return next()
1054
+
1055
+ const entryPath = isComponentSet ? componentSetIsolateEntryPath : isolateEntryPath
1056
+ const rawHtml = [
1057
+ '<!DOCTYPE html>',
1058
+ '<html><head>',
1059
+ '<style>html,body{margin:0;padding:0;width:100%;height:100%;background:var(--bgColor-default,transparent)}#root{width:100%;height:100%}</style>',
1060
+ '</head><body>',
1061
+ '<div id="root"></div>',
1062
+ `<script type="module" src="/@fs${entryPath}"></script>`,
1063
+ '</body></html>',
1064
+ ].join('\n')
1065
+
1066
+ try {
1067
+ const html = await server.transformIndexHtml(req.url, rawHtml)
1068
+ res.writeHead(200, { 'Content-Type': 'text/html' })
1069
+ res.end(html)
1070
+ } catch (err) {
1071
+ console.error('[storyboard] Component isolate HTML transform failed:', err)
1072
+ res.writeHead(500, { 'Content-Type': 'text/plain' })
1073
+ res.end('Component isolate failed')
1074
+ }
1075
+ })
1076
+
1077
+ // ── Stories list API ──────────────────────────────────────────
1078
+ // Serves the list of discovered stories for the CLI and UI story picker.
1079
+ server.middlewares.use(async (req, res, next) => {
1080
+ if (!req.url) return next()
1081
+ let url = req.url
1082
+ const baseNoTrail = (server.config.base || '/').replace(/\/$/, '')
1083
+ if (baseNoTrail && url.startsWith(baseNoTrail)) {
1084
+ url = url.slice(baseNoTrail.length) || '/'
1085
+ }
1086
+ if (!url.startsWith('/_storyboard/stories/list')) return next()
1087
+
1088
+ if (!buildResult) buildResult = buildIndex(root)
1089
+ const storyEntries = Object.entries(buildResult.index.story || {})
1090
+ const storyRoutes = buildResult.storyRoutes || {}
1091
+ const stories = storyEntries.map(([name]) => ({
1092
+ name,
1093
+ route: storyRoutes[name] || null,
1094
+ }))
1095
+
1096
+ res.writeHead(200, { 'Content-Type': 'application/json' })
1097
+ res.end(JSON.stringify({ stories }))
1098
+ })
1099
+
1100
+ // Watch for data file changes in dev mode
1101
+ const watcher = server.watcher
1102
+ if (!buildResult) buildResult = buildIndex(root)
1103
+ const knownCanvasIds = new Set(Object.keys(buildResult.index.canvas || {}))
1104
+ const pendingCanvasUnlinks = new Map()
1105
+
1106
+ const triggerFullReload = () => {
1107
+ buildResult = null
1108
+ const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
1109
+ if (mod) {
1110
+ server.moduleGraph.invalidateModule(mod)
1111
+ server.ws.send({ type: 'full-reload' })
1112
+ }
1113
+ }
1114
+
1115
+ // Mark the virtual module as stale so the next page load rebuilds it,
1116
+ // but do NOT trigger a full-reload (avoids losing canvas editing state).
1117
+ const softInvalidate = () => {
1118
+ buildResult = null
1119
+ const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
1120
+ if (mod) server.moduleGraph.invalidateModule(mod)
1121
+ }
1122
+
1123
+ // Read a canvas file and build HMR metadata for the client-side listener.
1124
+ const readCanvasMetadata = (filePath, parsed) => {
1125
+ try {
1126
+ const absPath = path.resolve(root, filePath)
1127
+ const raw = fs.readFileSync(absPath, 'utf-8')
1128
+ const materialized = materializeFromText(raw)
1129
+ const result = { ...materialized }
1130
+ // Inject _route and _folder the same way generateModule does
1131
+ if (parsed.inferredRoute) result._route = parsed.inferredRoute
1132
+ const folderDirMatch = path.relative(root, absPath).replace(/\\/g, '/').match(/(?:^|\/)src\/(?:prototypes|canvas)\/([^/]+)\.folder\//)
1133
+ if (folderDirMatch) result._folder = folderDirMatch[1]
1134
+ return result
1135
+ } catch {
1136
+ return null
1137
+ }
1138
+ }
1139
+
1140
+ const invalidate = (filePath) => {
1141
+ const normalized = filePath.replace(/\\/g, '/')
1142
+ // Canvas .jsonl content changes are mutated at runtime by the canvas
1143
+ // server API. A full-reload would create a feedback loop (save →
1144
+ // file change → reload → lose editing state). Instead, soft-invalidate
1145
+ // the virtual module (so page refresh picks up changes) and send a
1146
+ // custom HMR event with updated metadata so the canvas page and
1147
+ // viewfinder can react in place.
1148
+ if (/\.canvas\.jsonl$/.test(normalized)) {
1149
+ // If this file change was caused by the canvas server API, it has
1150
+ // already pushed an HMR event via pushCanvasUpdate(). Skip the
1151
+ // duplicate watcher-triggered event to prevent stale-data rollbacks.
1152
+ const absPath = path.resolve(root, filePath)
1153
+ if (!isCanvasWriteInFlight(absPath)) {
1154
+ const parsed = parseDataFile(filePath)
1155
+ if (parsed?.suffix === 'canvas' && parsed?.id) {
1156
+ const metadata = readCanvasMetadata(filePath, parsed)
1157
+ server.ws.send({
1158
+ type: 'custom',
1159
+ event: 'storyboard:canvas-file-changed',
1160
+ data: { canvasId: parsed.id, name: parsed.id, ...(metadata ? { metadata } : {}) },
1161
+ })
1162
+ }
1163
+ }
1164
+ softInvalidate()
1165
+ return
1166
+ }
1167
+
1168
+ // Invalidate when any config file inside a prototype changes
1169
+ const protoConfigPattern = /\/(toolbar|commandpalette|widgets|paste)\.config\.json$/
1170
+ if (protoConfigPattern.test(normalized) && normalized.includes('/prototypes/')) {
1171
+ buildResult = null
1172
+ const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
1173
+ if (mod) {
1174
+ server.moduleGraph.invalidateModule(mod)
1175
+ server.ws.send({ type: 'full-reload' })
1176
+ }
1177
+ return
1178
+ }
1179
+
1180
+ // Invalidate when root toolbar.config.json changes
1181
+ if (normalized === path.resolve(root, 'toolbar.config.json').split(path.sep).join('/') ||
1182
+ normalized === path.resolve(root, 'toolbar.config.json')) {
1183
+ buildResult = null
1184
+ const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
1185
+ if (mod) {
1186
+ server.moduleGraph.invalidateModule(mod)
1187
+ server.ws.send({ type: 'full-reload' })
1188
+ }
1189
+ return
1190
+ }
1191
+
1192
+ const parsed = parseDataFile(filePath)
1193
+ // Also invalidate when files are added/removed inside .folder/ directories
1194
+ const inFolder = normalized.includes('.folder/')
1195
+ if (!parsed && !inFolder) return
1196
+ // Source files inside .folder/ dirs (jsx, css, etc.) are handled by
1197
+ // Vite's built-in HMR / React Fast Refresh — don't full-reload for them.
1198
+ if (!parsed && inFolder) return
1199
+
1200
+ // Story file content changes are handled by Vite's built-in HMR
1201
+ // (React Fast Refresh). Only soft-invalidate the virtual module so
1202
+ // the next page load picks up updated metadata — don't full-reload,
1203
+ // which would destroy canvas state and cause embedded iframes to
1204
+ // reload unnecessarily.
1205
+ if (parsed?.suffix === 'story') {
1206
+ softInvalidate()
1207
+ return
1208
+ }
1209
+
1210
+ // Rebuild index and invalidate virtual module
1211
+ buildResult = null
1212
+ const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
1213
+ if (mod) {
1214
+ server.moduleGraph.invalidateModule(mod)
1215
+ server.ws.send({ type: 'full-reload' })
1216
+ }
1217
+ }
1218
+
1219
+ const invalidateOnAddRemove = (filePath, eventType) => {
1220
+ const parsed = parseDataFile(filePath)
1221
+ const inFolder = filePath.replace(/\\/g, '/').includes('.folder/')
1222
+ if (!parsed && !inFolder) return
1223
+ // Source files (jsx, css, etc.) inside .folder/ dirs are handled by
1224
+ // Vite's built-in HMR — don't trigger a full-reload for them.
1225
+ if (!parsed && inFolder) return
1226
+
1227
+ // Canvas writers/editors can emit unlink+add for an in-place save.
1228
+ // Treat canvas add/unlink as runtime data updates and never full-reload
1229
+ // from watcher events. Canvas pages sync from disk via custom WS events.
1230
+ if (parsed?.suffix === 'canvas') {
1231
+ const canvasId = parsed.id || parsed.name
1232
+ if (eventType === 'unlink') {
1233
+ const timer = setTimeout(() => {
1234
+ pendingCanvasUnlinks.delete(canvasId)
1235
+ knownCanvasIds.delete(canvasId)
1236
+ server.ws.send({
1237
+ type: 'custom',
1238
+ event: 'storyboard:canvas-file-changed',
1239
+ data: { canvasId, name: canvasId, removed: true },
1240
+ })
1241
+ softInvalidate()
1242
+ }, 1500)
1243
+ pendingCanvasUnlinks.set(canvasId, timer)
1244
+ return
1245
+ }
1246
+
1247
+ if (eventType === 'add') {
1248
+ const metadata = readCanvasMetadata(filePath, parsed)
1249
+ const pending = pendingCanvasUnlinks.get(canvasId)
1250
+ if (pending) {
1251
+ // unlink+add pair = in-place save (atomic write), not a real remove
1252
+ clearTimeout(pending)
1253
+ pendingCanvasUnlinks.delete(canvasId)
1254
+ server.ws.send({
1255
+ type: 'custom',
1256
+ event: 'storyboard:canvas-file-changed',
1257
+ data: { canvasId, name: canvasId, ...(metadata ? { metadata } : {}) },
1258
+ })
1259
+ softInvalidate()
1260
+ return
1261
+ }
1262
+
1263
+ if (knownCanvasIds.has(canvasId)) {
1264
+ server.ws.send({
1265
+ type: 'custom',
1266
+ event: 'storyboard:canvas-file-changed',
1267
+ data: { canvasId, name: canvasId, ...(metadata ? { metadata } : {}) },
1268
+ })
1269
+ softInvalidate()
1270
+ return
1271
+ }
1272
+
1273
+ knownCanvasIds.add(canvasId)
1274
+ server.ws.send({
1275
+ type: 'custom',
1276
+ event: 'storyboard:canvas-file-changed',
1277
+ data: { canvasId, name: canvasId, ...(metadata ? { metadata } : {}) },
1278
+ })
1279
+ softInvalidate()
1280
+ return
1281
+ }
1282
+ }
1283
+
1284
+ // Story add/remove: soft-invalidate + custom HMR event (full-reload
1285
+ // is blocked by the canvas reload guard). The virtual module HMR
1286
+ // handler live-patches `stories` and re-runs init().
1287
+ if (parsed?.suffix === 'story') {
1288
+ softInvalidate()
1289
+ if (!buildResult) buildResult = buildIndex(root)
1290
+ const storyRoutes = buildResult.storyRoutes || {}
1291
+ const storyIndex = buildResult.index.story || {}
1292
+ const name = parsed.name
1293
+ if (eventType === 'unlink') {
1294
+ server.ws.send({
1295
+ type: 'custom',
1296
+ event: 'storyboard:story-file-changed',
1297
+ data: { name, removed: true },
1298
+ })
1299
+ } else if (eventType === 'add' && storyIndex[name]) {
1300
+ const relModule = '/' + path.relative(root, storyIndex[name]).replace(/\\/g, '/')
1301
+ server.ws.send({
1302
+ type: 'custom',
1303
+ event: 'storyboard:story-file-changed',
1304
+ data: {
1305
+ name,
1306
+ _storyModule: relModule,
1307
+ _route: storyRoutes[name] || null,
1308
+ },
1309
+ })
1310
+ }
1311
+ return
1312
+ }
1313
+
1314
+ // Non-canvas additions/removals and folder changes update the route/data graph.
1315
+ triggerFullReload()
1316
+ }
1317
+
1318
+ // Watch storyboard.config.json for changes
1319
+ const { configPath } = readConfig(root)
1320
+ watcher.add(configPath)
1321
+
1322
+ // Watch all root domain config files for changes
1323
+ const domainConfigFiles = [
1324
+ 'toolbar.config.json',
1325
+ 'commandpalette.config.json',
1326
+ 'paste.config.json',
1327
+ 'widgets.config.json',
1328
+ ].map(f => path.resolve(root, f))
1329
+ const watchedConfigPaths = new Set([configPath, ...domainConfigFiles])
1330
+ for (const p of domainConfigFiles) watcher.add(p)
1331
+
1332
+ const invalidateConfig = (filePath) => {
1333
+ const resolved = path.resolve(filePath)
1334
+ if (watchedConfigPaths.has(resolved)) {
1335
+ buildResult = null
1336
+ const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
1337
+ if (mod) {
1338
+ server.moduleGraph.invalidateModule(mod)
1339
+ server.ws.send({ type: 'full-reload' })
1340
+ }
1341
+ }
1342
+ }
1343
+
1344
+ watcher.on('add', (filePath) => invalidateOnAddRemove(filePath, 'add'))
1345
+ watcher.on('unlink', (filePath) => invalidateOnAddRemove(filePath, 'unlink'))
1346
+ watcher.on('change', (filePath) => {
1347
+ invalidate(filePath)
1348
+ invalidateConfig(filePath)
1349
+ })
1350
+ },
1351
+
1352
+ handleHotUpdate(ctx) {
1353
+ const normalized = ctx.file.replace(/\\/g, '/')
1354
+ if (!/\.canvas\.jsonl$/.test(normalized)) return
1355
+
1356
+ // Prevent Vite's default fallback behavior (full page reload) for
1357
+ // non-module .canvas.jsonl edits. The watcher 'change' handler
1358
+ // (invalidate) already sends the custom HMR event and soft-invalidates
1359
+ // the virtual module — no duplicate event needed here.
1360
+ return []
1361
+ },
1362
+
1363
+ // Inject __SB_BRANCHES__ into HTML so the Viewfinder branch selector works.
1364
+ // Uses server registry (live running processes) instead of stale ports.json.
1365
+ transformIndexHtml(html, ctx) {
1366
+ // Only inject in dev mode
1367
+ if (!ctx.server) return html
1368
+
1369
+ try {
1370
+ const servers = listRunningServers()
1371
+ const branches = servers
1372
+ .filter(srv => srv.worktree !== 'main')
1373
+ .map(srv => ({ branch: srv.worktree, folder: `branch--${srv.worktree}`, port: srv.port }))
1374
+
1375
+ if (branches.length === 0) return html
1376
+
1377
+ const script = `<script>window.__SB_BRANCHES__ = ${JSON.stringify(branches)};</script>`
1378
+ return html.replace('</head>', `${script}\n</head>`)
1379
+ } catch {
1380
+ return html
1381
+ }
1382
+ },
1383
+
1384
+ // Rebuild index on each build start
1385
+ buildStart() {
1386
+ buildResult = null
1387
+ },
1388
+
1389
+ // Emit terminal snapshots into the build so TerminalReadWidget can
1390
+ // fetch them as static files in production (no dev-server API).
1391
+ generateBundle() {
1392
+ const emittedIds = new Set()
1393
+
1394
+ // 1. New public snapshots (flat structure) — .json and .txt
1395
+ const publicDir = path.resolve('assets/.storyboard-public/terminal-snapshots')
1396
+ if (fs.existsSync(publicDir)) {
1397
+ for (const file of fs.readdirSync(publicDir)) {
1398
+ if (file.startsWith('~') || file.startsWith('.')) continue
1399
+ const isJson = file.endsWith('.snapshot.json')
1400
+ const isTxt = file.endsWith('.snapshot.txt')
1401
+ if (!isJson && !isTxt) continue
1402
+ if (isJson) {
1403
+ const widgetId = file.replace(/\.snapshot\.json$/, '')
1404
+ if (widgetId) emittedIds.add(widgetId)
1405
+ }
1406
+ this.emitFile({
1407
+ type: 'asset',
1408
+ fileName: `_storyboard/terminal-snapshots/${file}`,
1409
+ source: fs.readFileSync(path.join(publicDir, file), 'utf-8'),
1410
+ })
1411
+ }
1412
+ }
1413
+
1414
+ // 2. Legacy snapshots (nested by canvas dir) — skip if already emitted
1415
+ const legacyDir = path.resolve('.storyboard/terminal-snapshots')
1416
+ if (fs.existsSync(legacyDir)) {
1417
+ const walk = (dir) => {
1418
+ const entries = fs.readdirSync(dir, { withFileTypes: true })
1419
+ for (const entry of entries) {
1420
+ const full = path.join(dir, entry.name)
1421
+ if (entry.isDirectory()) {
1422
+ walk(full)
1423
+ } else if (entry.name.endsWith('.json') && !entry.name.startsWith('~')) {
1424
+ const widgetId = entry.name.replace(/\.json$/, '')
1425
+ if (emittedIds.has(widgetId)) continue
1426
+ const rel = path.relative(legacyDir, full).replace(/\\/g, '/')
1427
+ this.emitFile({
1428
+ type: 'asset',
1429
+ fileName: `_storyboard/terminal-snapshots/${rel}`,
1430
+ source: fs.readFileSync(full, 'utf-8'),
1431
+ })
1432
+ }
1433
+ }
1434
+ }
1435
+ walk(legacyDir)
1436
+ }
1437
+ },
1438
+ }
1439
+ }
1440
+
1441
+ /**
1442
+ * Vite plugin that copies terminal snapshots into the build output
1443
+ * so TerminalReadWidget can fetch them as static files in production.
1444
+ *
1445
+ * Sources (in priority order):
1446
+ * 1. assets/.storyboard-public/terminal-snapshots/<widgetId>.snapshot.json (new, flat)
1447
+ * 2. assets/.storyboard-public/terminal-snapshots/<widgetId>.snapshot.txt (human-readable companion)
1448
+ * 3. .storyboard/terminal-snapshots/<canvasDir>/<widgetId>.json (legacy, nested)
1449
+ *
1450
+ * All are emitted to `_storyboard/terminal-snapshots/` in the build.
1451
+ * Tilde-prefixed files (~) are excluded (private).
1452
+ */
1453
+ export function terminalSnapshotPlugin() {
1454
+ return {
1455
+ name: 'storyboard-terminal-snapshots',
1456
+
1457
+ generateBundle() {
1458
+ const emittedIds = new Set()
1459
+
1460
+ // 1. New public snapshots (flat structure) — .json and .txt
1461
+ const publicDir = path.resolve('assets/.storyboard-public/terminal-snapshots')
1462
+ if (fs.existsSync(publicDir)) {
1463
+ for (const file of fs.readdirSync(publicDir)) {
1464
+ if (file.startsWith('~') || file.startsWith('.')) continue
1465
+ const isJson = file.endsWith('.snapshot.json')
1466
+ const isTxt = file.endsWith('.snapshot.txt')
1467
+ if (!isJson && !isTxt) continue
1468
+ if (isJson) {
1469
+ const widgetId = file.replace(/\.snapshot\.json$/, '')
1470
+ if (widgetId) emittedIds.add(widgetId)
1471
+ }
1472
+ this.emitFile({
1473
+ type: 'asset',
1474
+ fileName: `_storyboard/terminal-snapshots/${file}`,
1475
+ source: fs.readFileSync(path.join(publicDir, file), 'utf-8'),
1476
+ })
1477
+ }
1478
+ }
1479
+
1480
+ // 2. Legacy snapshots (nested by canvas dir) — skip if already emitted
1481
+ const legacyDir = path.resolve('.storyboard/terminal-snapshots')
1482
+ if (fs.existsSync(legacyDir)) {
1483
+ const walk = (dir) => {
1484
+ const entries = fs.readdirSync(dir, { withFileTypes: true })
1485
+ for (const entry of entries) {
1486
+ const full = path.join(dir, entry.name)
1487
+ if (entry.isDirectory()) {
1488
+ walk(full)
1489
+ } else if (entry.name.endsWith('.json') && !entry.name.startsWith('~')) {
1490
+ const widgetId = entry.name.replace(/\.json$/, '')
1491
+ if (emittedIds.has(widgetId)) continue // new format takes priority
1492
+ const rel = path.relative(legacyDir, full).replace(/\\/g, '/')
1493
+ this.emitFile({
1494
+ type: 'asset',
1495
+ fileName: `_storyboard/terminal-snapshots/${rel}`,
1496
+ source: fs.readFileSync(full, 'utf-8'),
1497
+ })
1498
+ }
1499
+ }
1500
+ }
1501
+ walk(legacyDir)
1502
+ }
1503
+ },
1504
+ }
1505
+ }
1506
+
1507
+ // Exported for testing
1508
+ export { resolveTemplateVars, computeTemplateVars, parseDataFile }