@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,1223 @@
1
+ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import path from 'node:path'
4
+ import storyboardDataPlugin, { resolveTemplateVars, computeTemplateVars, parseDataFile } from './data-plugin.js'
5
+
6
+ const RESOLVED_ID = '\0virtual:storyboard-data-index'
7
+
8
+ let tmpDir
9
+
10
+ beforeEach(() => {
11
+ tmpDir = mkdtempSync(path.join(tmpdir(), 'sb-test-'))
12
+ })
13
+
14
+ afterEach(() => {
15
+ rmSync(tmpDir, { recursive: true, force: true })
16
+ })
17
+
18
+ function createPlugin(root) {
19
+ const plugin = storyboardDataPlugin()
20
+ plugin.configResolved({ root: root ?? tmpDir })
21
+ return plugin
22
+ }
23
+
24
+ function writeDataFiles(dir) {
25
+ writeFileSync(
26
+ path.join(dir, 'default.scene.json'),
27
+ JSON.stringify({ title: 'Test' }),
28
+ )
29
+ writeFileSync(
30
+ path.join(dir, 'user.object.json'),
31
+ JSON.stringify({ name: 'Jane' }),
32
+ )
33
+ writeFileSync(
34
+ path.join(dir, 'posts.record.json'),
35
+ JSON.stringify([{ id: '1', title: 'First' }]),
36
+ )
37
+ }
38
+
39
+ describe('storyboardDataPlugin', () => {
40
+ it("has name 'storyboard-data'", () => {
41
+ const plugin = storyboardDataPlugin()
42
+ expect(plugin.name).toBe('storyboard-data')
43
+ })
44
+
45
+ it("has enforce 'pre'", () => {
46
+ const plugin = storyboardDataPlugin()
47
+ expect(plugin.enforce).toBe('pre')
48
+ })
49
+
50
+ it('config() excludes @dfosco/storyboard from optimizeDeps', () => {
51
+ const plugin = storyboardDataPlugin()
52
+ const config = plugin.config()
53
+ expect(config.optimizeDeps.exclude).toContain('@dfosco/storyboard')
54
+ })
55
+
56
+ it('config() includes remark stack in optimizeDeps so Vite pre-bundles transitive CJS deps', () => {
57
+ const plugin = storyboardDataPlugin()
58
+ const config = plugin.config()
59
+ expect(config.optimizeDeps.include).toContain('remark')
60
+ expect(config.optimizeDeps.include).toContain('remark-gfm')
61
+ expect(config.optimizeDeps.include).toContain('remark-html')
62
+ })
63
+
64
+ it("resolveId returns resolved ID for 'virtual:storyboard-data-index'", () => {
65
+ const plugin = createPlugin()
66
+ expect(plugin.resolveId('virtual:storyboard-data-index')).toBe(RESOLVED_ID)
67
+ })
68
+
69
+ it('resolveId returns undefined for other IDs', () => {
70
+ const plugin = createPlugin()
71
+ expect(plugin.resolveId('some-other-module')).toBeUndefined()
72
+ })
73
+
74
+ it('load generates valid module code with init() call', () => {
75
+ writeDataFiles(tmpDir)
76
+ const plugin = createPlugin()
77
+ const code = plugin.load(RESOLVED_ID)
78
+
79
+ expect(code).toContain("import { init } from '@dfosco/storyboard/core'")
80
+ expect(code).toContain('init({ flows, objects, records, prototypes, folders, canvases, stories })')
81
+ expect(code).toContain('"Test"')
82
+ expect(code).toContain('"Jane"')
83
+ expect(code).toContain('"First"')
84
+ // Backward-compat alias
85
+ expect(code).toContain('const scenes = flows')
86
+ expect(code).toContain('export { flows, scenes, objects, records, prototypes, folders, canvases, canvasAliases, stories }')
87
+ })
88
+
89
+ it('load returns null for other IDs', () => {
90
+ const plugin = createPlugin()
91
+ expect(plugin.load('other-id')).toBeNull()
92
+ })
93
+
94
+ it('duplicate data files throw an error', () => {
95
+ writeFileSync(
96
+ path.join(tmpDir, 'dup.scene.json'),
97
+ JSON.stringify({ a: 1 }),
98
+ )
99
+ const subDir = path.join(tmpDir, 'nested')
100
+ mkdirSync(subDir, { recursive: true })
101
+ writeFileSync(
102
+ path.join(subDir, 'dup.scene.json'),
103
+ JSON.stringify({ a: 2 }),
104
+ )
105
+
106
+ const plugin = createPlugin()
107
+ expect(() => plugin.load(RESOLVED_ID)).toThrow(/Duplicate flow "dup"/)
108
+ })
109
+
110
+ it('allows same object name in global and prototype without clash', () => {
111
+ mkdirSync(path.join(tmpDir, 'src', 'data'), { recursive: true })
112
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'Dashboard'), { recursive: true })
113
+ writeFileSync(
114
+ path.join(tmpDir, 'src', 'data', 'user.object.json'),
115
+ JSON.stringify({ name: 'Global' }),
116
+ )
117
+ writeFileSync(
118
+ path.join(tmpDir, 'src', 'prototypes', 'Dashboard', 'user.object.json'),
119
+ JSON.stringify({ name: 'Local' }),
120
+ )
121
+
122
+ const plugin = createPlugin()
123
+ const code = plugin.load(RESOLVED_ID)
124
+
125
+ // Both should exist without error
126
+ expect(code).toContain('"user"')
127
+ expect(code).toContain('"Dashboard/user"')
128
+ expect(code).toContain('"Global"')
129
+ expect(code).toContain('"Local"')
130
+ })
131
+
132
+ it('allows same object name in different prototypes without clash', () => {
133
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'A'), { recursive: true })
134
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'B'), { recursive: true })
135
+ writeFileSync(
136
+ path.join(tmpDir, 'src', 'prototypes', 'A', 'nav.object.json'),
137
+ JSON.stringify({ from: 'A' }),
138
+ )
139
+ writeFileSync(
140
+ path.join(tmpDir, 'src', 'prototypes', 'B', 'nav.object.json'),
141
+ JSON.stringify({ from: 'B' }),
142
+ )
143
+
144
+ const plugin = createPlugin()
145
+ const code = plugin.load(RESOLVED_ID)
146
+
147
+ expect(code).toContain('"A/nav"')
148
+ expect(code).toContain('"B/nav"')
149
+ })
150
+
151
+ it('handles JSONC files (with comments)', () => {
152
+ writeFileSync(
153
+ path.join(tmpDir, 'commented.scene.jsonc'),
154
+ '{\n // This is a comment\n "title": "JSONC Scene"\n}\n',
155
+ )
156
+ const plugin = createPlugin()
157
+ const code = plugin.load(RESOLVED_ID)
158
+
159
+ expect(code).toContain('"JSONC Scene"')
160
+ })
161
+
162
+ it('normalizes .scene files into flow category in the index', () => {
163
+ writeFileSync(
164
+ path.join(tmpDir, 'legacy.scene.json'),
165
+ JSON.stringify({ title: 'Legacy Scene' }),
166
+ )
167
+ const plugin = createPlugin()
168
+ const code = plugin.load(RESOLVED_ID)
169
+
170
+ // .scene.json files should be normalized to the flows category
171
+ expect(code).toContain('"Legacy Scene"')
172
+ expect(code).toContain('init({ flows, objects, records, prototypes, folders, canvases, stories })')
173
+ })
174
+
175
+ it('buildStart resets the index cache', () => {
176
+ writeDataFiles(tmpDir)
177
+ const plugin = createPlugin()
178
+
179
+ // First load builds the index
180
+ const code1 = plugin.load(RESOLVED_ID)
181
+ expect(code1).toContain('"Test"')
182
+
183
+ // Add a new file
184
+ writeFileSync(
185
+ path.join(tmpDir, 'extra.scene.json'),
186
+ JSON.stringify({ title: 'Extra' }),
187
+ )
188
+
189
+ // Without buildStart, cached index is used — "Extra" won't appear
190
+ const code2 = plugin.load(RESOLVED_ID)
191
+ expect(code2).not.toContain('"Extra"')
192
+
193
+ // After buildStart, index is rebuilt
194
+ plugin.buildStart()
195
+ const code3 = plugin.load(RESOLVED_ID)
196
+ expect(code3).toContain('"Extra"')
197
+ })
198
+ })
199
+
200
+ describe('prototype scoping', () => {
201
+ it('prefixes flows inside src/prototypes/{Name}/ with the prototype name', () => {
202
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'Dashboard'), { recursive: true })
203
+ writeFileSync(
204
+ path.join(tmpDir, 'src', 'prototypes', 'Dashboard', 'default.flow.json'),
205
+ JSON.stringify({ title: 'Dashboard Default' }),
206
+ )
207
+ writeFileSync(
208
+ path.join(tmpDir, 'src', 'prototypes', 'Dashboard', 'signup.flow.json'),
209
+ JSON.stringify({ title: 'Dashboard Signup' }),
210
+ )
211
+ // Global flow in src/data/
212
+ mkdirSync(path.join(tmpDir, 'src', 'data'), { recursive: true })
213
+ writeFileSync(
214
+ path.join(tmpDir, 'src', 'data', 'default.flow.json'),
215
+ JSON.stringify({ title: 'Global Default' }),
216
+ )
217
+
218
+ const plugin = createPlugin()
219
+ const code = plugin.load(RESOLVED_ID)
220
+
221
+ expect(code).toContain('"Dashboard/default"')
222
+ expect(code).toContain('"Dashboard/signup"')
223
+ expect(code).toContain('"default"')
224
+ expect(code).toContain('"Dashboard Default"')
225
+ expect(code).toContain('"Global Default"')
226
+ })
227
+
228
+ it('prefixes records inside src/prototypes/{Name}/ with the prototype name', () => {
229
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'Blog'), { recursive: true })
230
+ writeFileSync(
231
+ path.join(tmpDir, 'src', 'prototypes', 'Blog', 'posts.record.json'),
232
+ JSON.stringify([{ id: '1', title: 'Scoped Post' }]),
233
+ )
234
+ // Global record
235
+ mkdirSync(path.join(tmpDir, 'src', 'data'), { recursive: true })
236
+ writeFileSync(
237
+ path.join(tmpDir, 'src', 'data', 'posts.record.json'),
238
+ JSON.stringify([{ id: '1', title: 'Global Post' }]),
239
+ )
240
+
241
+ const plugin = createPlugin()
242
+ const code = plugin.load(RESOLVED_ID)
243
+
244
+ expect(code).toContain('"Blog/posts"')
245
+ expect(code).toContain('"posts"')
246
+ expect(code).toContain('"Scoped Post"')
247
+ expect(code).toContain('"Global Post"')
248
+ })
249
+
250
+ it('prefixes objects inside src/prototypes/{Name}/', () => {
251
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'Dashboard'), { recursive: true })
252
+ writeFileSync(
253
+ path.join(tmpDir, 'src', 'prototypes', 'Dashboard', 'helpers.object.json'),
254
+ JSON.stringify({ util: true }),
255
+ )
256
+
257
+ const plugin = createPlugin()
258
+ const code = plugin.load(RESOLVED_ID)
259
+
260
+ // Object should be scoped as "Dashboard/helpers"
261
+ expect(code).toContain('"Dashboard/helpers"')
262
+ })
263
+
264
+ it('allows same flow name in different prototypes without clash', () => {
265
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'A'), { recursive: true })
266
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'B'), { recursive: true })
267
+ writeFileSync(
268
+ path.join(tmpDir, 'src', 'prototypes', 'A', 'default.flow.json'),
269
+ JSON.stringify({ from: 'A' }),
270
+ )
271
+ writeFileSync(
272
+ path.join(tmpDir, 'src', 'prototypes', 'B', 'default.flow.json'),
273
+ JSON.stringify({ from: 'B' }),
274
+ )
275
+
276
+ const plugin = createPlugin()
277
+ // Should not throw (no duplicate)
278
+ const code = plugin.load(RESOLVED_ID)
279
+ expect(code).toContain('"A/default"')
280
+ expect(code).toContain('"B/default"')
281
+ })
282
+
283
+ it('normalizes .scene.json inside prototypes to scoped flow', () => {
284
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'Legacy'), { recursive: true })
285
+ writeFileSync(
286
+ path.join(tmpDir, 'src', 'prototypes', 'Legacy', 'old.scene.json'),
287
+ JSON.stringify({ compat: true }),
288
+ )
289
+
290
+ const plugin = createPlugin()
291
+ const code = plugin.load(RESOLVED_ID)
292
+
293
+ // Should be indexed as a scoped flow, not a scene
294
+ expect(code).toContain('"Legacy/old"')
295
+ expect(code).toContain('flows')
296
+ })
297
+ })
298
+
299
+ describe('flow route inference', () => {
300
+ it('injects _route for flows inside src/prototypes/', () => {
301
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'Dashboard'), { recursive: true })
302
+ writeFileSync(
303
+ path.join(tmpDir, 'src', 'prototypes', 'Dashboard', 'default.flow.json'),
304
+ JSON.stringify({ title: 'Dashboard Flow' }),
305
+ )
306
+
307
+ const plugin = createPlugin()
308
+ const code = plugin.load(RESOLVED_ID)
309
+
310
+ expect(code).toContain('"_route":"/Dashboard"')
311
+ })
312
+
313
+ it('injects _route for flows inside .folder/ directories', () => {
314
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'MyFolder.folder', 'Example'), { recursive: true })
315
+ writeFileSync(
316
+ path.join(tmpDir, 'src', 'prototypes', 'MyFolder.folder', 'Example', 'basic.flow.json'),
317
+ JSON.stringify({ title: 'Example Flow' }),
318
+ )
319
+
320
+ const plugin = createPlugin()
321
+ const code = plugin.load(RESOLVED_ID)
322
+
323
+ // .folder/ should be stripped from the inferred route
324
+ expect(code).toContain('"_route":"/Example"')
325
+ expect(code).not.toContain('MyFolder')
326
+ })
327
+
328
+ it('injects _route with nested path for deeply placed flows', () => {
329
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'App', 'settings'), { recursive: true })
330
+ writeFileSync(
331
+ path.join(tmpDir, 'src', 'prototypes', 'App', 'settings', 'prefs.flow.json'),
332
+ JSON.stringify({ title: 'Settings Prefs' }),
333
+ )
334
+
335
+ const plugin = createPlugin()
336
+ const code = plugin.load(RESOLVED_ID)
337
+
338
+ expect(code).toContain('"_route":"/App/settings"')
339
+ })
340
+
341
+ it('does NOT inject _route for global flows outside src/prototypes/', () => {
342
+ mkdirSync(path.join(tmpDir, 'src', 'data'), { recursive: true })
343
+ writeFileSync(
344
+ path.join(tmpDir, 'src', 'data', 'global.flow.json'),
345
+ JSON.stringify({ title: 'Global Flow' }),
346
+ )
347
+
348
+ const plugin = createPlugin()
349
+ const code = plugin.load(RESOLVED_ID)
350
+
351
+ expect(code).not.toContain('"_route"')
352
+ })
353
+
354
+ it('does NOT inject _route when flow has explicit route field', () => {
355
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'Dashboard'), { recursive: true })
356
+ writeFileSync(
357
+ path.join(tmpDir, 'src', 'prototypes', 'Dashboard', 'custom.flow.json'),
358
+ JSON.stringify({ route: '/custom-page', title: 'Custom Route' }),
359
+ )
360
+
361
+ const plugin = createPlugin()
362
+ const code = plugin.load(RESOLVED_ID)
363
+
364
+ // Should have the explicit route but NOT _route
365
+ expect(code).toContain('"route":"/custom-page"')
366
+ expect(code).not.toContain('"_route"')
367
+ })
368
+
369
+ it('does not log info when multiple flows share the same route', () => {
370
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'Dashboard'), { recursive: true })
371
+ writeFileSync(
372
+ path.join(tmpDir, 'src', 'prototypes', 'Dashboard', 'happy.flow.json'),
373
+ JSON.stringify({ title: 'Happy Path' }),
374
+ )
375
+ writeFileSync(
376
+ path.join(tmpDir, 'src', 'prototypes', 'Dashboard', 'error.flow.json'),
377
+ JSON.stringify({ title: 'Error State' }),
378
+ )
379
+
380
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
381
+ const plugin = createPlugin()
382
+ plugin.load(RESOLVED_ID)
383
+
384
+ const routeLog = logSpy.mock.calls.find(call =>
385
+ typeof call[0] === 'string' && call[0].includes('Route "/Dashboard" has 2 flows')
386
+ )
387
+ expect(routeLog).toBeUndefined()
388
+ logSpy.mockRestore()
389
+ })
390
+
391
+ it('warns when multiple flows on same route have meta.default: true', () => {
392
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'Dashboard'), { recursive: true })
393
+ writeFileSync(
394
+ path.join(tmpDir, 'src', 'prototypes', 'Dashboard', 'a.flow.json'),
395
+ JSON.stringify({ meta: { default: true }, title: 'A' }),
396
+ )
397
+ writeFileSync(
398
+ path.join(tmpDir, 'src', 'prototypes', 'Dashboard', 'b.flow.json'),
399
+ JSON.stringify({ meta: { default: true }, title: 'B' }),
400
+ )
401
+
402
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
403
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
404
+ const plugin = createPlugin()
405
+ plugin.load(RESOLVED_ID)
406
+
407
+ const warnCall = warnSpy.mock.calls.find(call =>
408
+ typeof call[0] === 'string' && call[0].includes('meta.default: true')
409
+ )
410
+ expect(warnCall).toBeTruthy()
411
+ logSpy.mockRestore()
412
+ warnSpy.mockRestore()
413
+ })
414
+ })
415
+
416
+ describe('folder grouping', () => {
417
+ it('discovers .folder.json files and keys them by folder directory name', () => {
418
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'Getting Started.folder'), { recursive: true })
419
+ writeFileSync(
420
+ path.join(tmpDir, 'src', 'prototypes', 'Getting Started.folder', 'getting-started.folder.json'),
421
+ JSON.stringify({ meta: { title: 'Getting Started', description: 'Intro' } }),
422
+ )
423
+
424
+ const plugin = createPlugin()
425
+ const code = plugin.load(RESOLVED_ID)
426
+
427
+ expect(code).toContain('"Getting Started"')
428
+ expect(code).toContain('"Intro"')
429
+ expect(code).toContain('folders')
430
+ })
431
+
432
+ it('scopes prototypes inside .folder/ directories correctly', () => {
433
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'MyFolder.folder', 'Dashboard'), { recursive: true })
434
+ writeFileSync(
435
+ path.join(tmpDir, 'src', 'prototypes', 'MyFolder.folder', 'Dashboard', 'default.flow.json'),
436
+ JSON.stringify({ title: 'Dashboard Default' }),
437
+ )
438
+ writeFileSync(
439
+ path.join(tmpDir, 'src', 'prototypes', 'MyFolder.folder', 'Dashboard', 'dashboard.prototype.json'),
440
+ JSON.stringify({ meta: { title: 'Dashboard' } }),
441
+ )
442
+
443
+ const plugin = createPlugin()
444
+ const code = plugin.load(RESOLVED_ID)
445
+
446
+ // Flow should be scoped to prototype, not folder
447
+ expect(code).toContain('"Dashboard/default"')
448
+ expect(code).not.toContain('"MyFolder.folder/default"')
449
+ expect(code).not.toContain('"MyFolder/default"')
450
+ // Prototype should have folder field injected
451
+ expect(code).toContain('"folder":"MyFolder"')
452
+ })
453
+
454
+ it('scopes objects inside .folder/ directories to their prototype', () => {
455
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'X.folder', 'Proto'), { recursive: true })
456
+ writeFileSync(
457
+ path.join(tmpDir, 'src', 'prototypes', 'X.folder', 'Proto', 'helpers.object.json'),
458
+ JSON.stringify({ util: true }),
459
+ )
460
+
461
+ const plugin = createPlugin()
462
+ const code = plugin.load(RESOLVED_ID)
463
+
464
+ // Object should be scoped to prototype, not folder
465
+ expect(code).toContain('"Proto/helpers"')
466
+ expect(code).not.toContain('"X/helpers"')
467
+ })
468
+
469
+ it('scopes records inside .folder/ directories to their prototype', () => {
470
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'X.folder', 'Blog'), { recursive: true })
471
+ writeFileSync(
472
+ path.join(tmpDir, 'src', 'prototypes', 'X.folder', 'Blog', 'posts.record.json'),
473
+ JSON.stringify([{ id: '1', title: 'Post' }]),
474
+ )
475
+
476
+ const plugin = createPlugin()
477
+ const code = plugin.load(RESOLVED_ID)
478
+
479
+ expect(code).toContain('"Blog/posts"')
480
+ expect(code).not.toContain('"X/posts"')
481
+ })
482
+
483
+ it('allows prototypes with same name in different folders without clash', () => {
484
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'A.folder', 'Settings'), { recursive: true })
485
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'B.folder', 'Settings'), { recursive: true })
486
+ writeFileSync(
487
+ path.join(tmpDir, 'src', 'prototypes', 'A.folder', 'Settings', 'default.flow.json'),
488
+ JSON.stringify({ from: 'A' }),
489
+ )
490
+ writeFileSync(
491
+ path.join(tmpDir, 'src', 'prototypes', 'B.folder', 'Settings', 'default.flow.json'),
492
+ JSON.stringify({ from: 'B' }),
493
+ )
494
+
495
+ const plugin = createPlugin()
496
+ // Same flow name in same prototype name → duplicate collision
497
+ expect(() => plugin.load(RESOLVED_ID)).toThrow(/Duplicate flow "Settings\/default"/)
498
+ })
499
+
500
+ it('throws on nested .folder/ directories', () => {
501
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'Outer.folder', 'Inner.folder', 'Proto'), { recursive: true })
502
+ writeFileSync(
503
+ path.join(tmpDir, 'src', 'prototypes', 'Outer.folder', 'Inner.folder', 'Proto', 'default.flow.json'),
504
+ JSON.stringify({ title: 'Nested' }),
505
+ )
506
+
507
+ const plugin = createPlugin()
508
+ expect(() => plugin.load(RESOLVED_ID)).toThrow(/Nested .folder directories are not supported/)
509
+ })
510
+
511
+ it('throws on empty nested .folder/ directories', () => {
512
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'Outer.folder', 'Inner.folder'), { recursive: true })
513
+
514
+ const plugin = createPlugin()
515
+ expect(() => plugin.load(RESOLVED_ID)).toThrow(/Nested .folder directories are not supported/)
516
+ })
517
+ })
518
+
519
+ describe('underscore prefix ignoring', () => {
520
+ it('ignores _-prefixed data files', () => {
521
+ writeFileSync(
522
+ path.join(tmpDir, '_draft.flow.json'),
523
+ JSON.stringify({ title: 'Draft' }),
524
+ )
525
+ writeFileSync(
526
+ path.join(tmpDir, 'visible.flow.json'),
527
+ JSON.stringify({ title: 'Visible' }),
528
+ )
529
+
530
+ const plugin = createPlugin()
531
+ const code = plugin.load(RESOLVED_ID)
532
+
533
+ expect(code).toContain('"Visible"')
534
+ expect(code).not.toContain('"Draft"')
535
+ })
536
+
537
+ it('ignores data files inside _-prefixed directories', () => {
538
+ mkdirSync(path.join(tmpDir, '_archive'), { recursive: true })
539
+ writeFileSync(
540
+ path.join(tmpDir, '_archive', 'old.flow.json'),
541
+ JSON.stringify({ title: 'Archived' }),
542
+ )
543
+ writeFileSync(
544
+ path.join(tmpDir, 'current.flow.json'),
545
+ JSON.stringify({ title: 'Current' }),
546
+ )
547
+
548
+ const plugin = createPlugin()
549
+ const code = plugin.load(RESOLVED_ID)
550
+
551
+ expect(code).toContain('"Current"')
552
+ expect(code).not.toContain('"Archived"')
553
+ })
554
+
555
+ it('ignores prototype.json inside _-prefixed directories', () => {
556
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', '_WIP'), { recursive: true })
557
+ writeFileSync(
558
+ path.join(tmpDir, 'src', 'prototypes', '_WIP', 'wip.prototype.json'),
559
+ JSON.stringify({ meta: { title: 'Work in Progress' } }),
560
+ )
561
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'Live'), { recursive: true })
562
+ writeFileSync(
563
+ path.join(tmpDir, 'src', 'prototypes', 'Live', 'live.prototype.json'),
564
+ JSON.stringify({ meta: { title: 'Live' } }),
565
+ )
566
+
567
+ const plugin = createPlugin()
568
+ const code = plugin.load(RESOLVED_ID)
569
+
570
+ expect(code).toContain('"Live"')
571
+ expect(code).not.toContain('"Work in Progress"')
572
+ })
573
+
574
+ it('does not ignore files with _ in the middle of the name', () => {
575
+ writeFileSync(
576
+ path.join(tmpDir, 'my_flow.flow.json'),
577
+ JSON.stringify({ title: 'Has Underscore' }),
578
+ )
579
+
580
+ const plugin = createPlugin()
581
+ const code = plugin.load(RESOLVED_ID)
582
+
583
+ expect(code).toContain('"Has Underscore"')
584
+ })
585
+ })
586
+
587
+ describe('resolveTemplateVars', () => {
588
+ it('replaces variables in a simple string', () => {
589
+ const result = resolveTemplateVars('/${currentDir}/page', { currentDir: 'src/data' })
590
+ expect(result).toBe('/src/data/page')
591
+ })
592
+
593
+ it('replaces multiple variables in one string', () => {
594
+ const result = resolveTemplateVars('${currentProto} in ${currentProtoDir}', {
595
+ currentProto: 'src/prototypes/main.folder/Example',
596
+ currentProtoDir: 'src/prototypes/main.folder',
597
+ })
598
+ expect(result).toBe('src/prototypes/main.folder/Example in src/prototypes/main.folder')
599
+ })
600
+
601
+ it('replaces variables in nested objects', () => {
602
+ const input = {
603
+ nav: { url: '/${currentDir}/page', label: 'Go' },
604
+ meta: { proto: '${currentProto}' },
605
+ }
606
+ const vars = { currentDir: 'src/data', currentProto: 'src/prototypes/App' }
607
+ const result = resolveTemplateVars(input, vars)
608
+
609
+ expect(result.nav.url).toBe('/src/data/page')
610
+ expect(result.nav.label).toBe('Go')
611
+ expect(result.meta.proto).toBe('src/prototypes/App')
612
+ })
613
+
614
+ it('replaces variables in arrays', () => {
615
+ const input = ['/${currentDir}/a', '/${currentDir}/b']
616
+ const result = resolveTemplateVars(input, { currentDir: 'here' })
617
+ expect(result).toEqual(['/here/a', '/here/b'])
618
+ })
619
+
620
+ it('replaces variables in deeply nested structures', () => {
621
+ const input = {
622
+ items: [
623
+ { links: [{ url: '/${currentDir}/x' }] },
624
+ ],
625
+ }
626
+ const result = resolveTemplateVars(input, { currentDir: 'deep' })
627
+ expect(result.items[0].links[0].url).toBe('/deep/x')
628
+ })
629
+
630
+ it('does not modify non-string values', () => {
631
+ const input = { count: 42, active: true, empty: null }
632
+ const result = resolveTemplateVars(input, { currentDir: 'test' })
633
+ expect(result).toEqual({ count: 42, active: true, empty: null })
634
+ })
635
+
636
+ it('returns input unchanged when no variables match', () => {
637
+ const input = { url: '/static/path', name: 'no vars here' }
638
+ const result = resolveTemplateVars(input, { currentDir: 'test' })
639
+ expect(result).toEqual(input)
640
+ })
641
+
642
+ it('leaves unknown variable patterns as-is', () => {
643
+ const result = resolveTemplateVars('${unknownVar}/path', { currentDir: 'test' })
644
+ expect(result).toBe('${unknownVar}/path')
645
+ })
646
+
647
+ it('does not mutate the original object', () => {
648
+ const input = { url: '/${currentDir}/page' }
649
+ const original = JSON.parse(JSON.stringify(input))
650
+ resolveTemplateVars(input, { currentDir: 'test' })
651
+ expect(input).toEqual(original)
652
+ })
653
+
654
+ it('handles empty vars object', () => {
655
+ const input = { url: '/${currentDir}/page' }
656
+ const result = resolveTemplateVars(input, {})
657
+ expect(result.url).toBe('/${currentDir}/page')
658
+ })
659
+
660
+ it('handles multiple occurrences of the same variable', () => {
661
+ const result = resolveTemplateVars('${currentDir}/${currentDir}', { currentDir: 'x' })
662
+ expect(result).toBe('x/x')
663
+ })
664
+ })
665
+
666
+ describe('computeTemplateVars', () => {
667
+ it('computes currentDir for a file in src/data/', () => {
668
+ const root = '/project'
669
+ const absPath = '/project/src/data/nav.object.json'
670
+ const vars = computeTemplateVars(absPath, root)
671
+
672
+ expect(vars.currentDir).toBe('src/data')
673
+ expect(vars.currentProto).toBe('')
674
+ expect(vars.currentProtoDir).toBe('')
675
+ })
676
+
677
+ it('computes all three vars for a file in a prototype inside a folder', () => {
678
+ const root = '/project'
679
+ const absPath = '/project/src/prototypes/main.folder/Example/sidenav.object.json'
680
+ const vars = computeTemplateVars(absPath, root)
681
+
682
+ expect(vars.currentDir).toBe('src/prototypes/main.folder/Example')
683
+ expect(vars.currentProto).toBe('src/prototypes/main.folder/Example')
684
+ expect(vars.currentProtoDir).toBe('src/prototypes/main.folder')
685
+ })
686
+
687
+ it('computes vars for a file in a subdirectory of a prototype', () => {
688
+ const root = '/project'
689
+ const absPath = '/project/src/prototypes/main.folder/Example/data/deep.object.json'
690
+ const vars = computeTemplateVars(absPath, root)
691
+
692
+ expect(vars.currentDir).toBe('src/prototypes/main.folder/Example/data')
693
+ expect(vars.currentProto).toBe('src/prototypes/main.folder/Example')
694
+ expect(vars.currentProtoDir).toBe('src/prototypes/main.folder')
695
+ })
696
+
697
+ it('computes vars for a file in a prototype without a folder', () => {
698
+ const root = '/project'
699
+ const absPath = '/project/src/prototypes/Dashboard/nav.object.json'
700
+ const vars = computeTemplateVars(absPath, root)
701
+
702
+ expect(vars.currentDir).toBe('src/prototypes/Dashboard')
703
+ expect(vars.currentProto).toBe('src/prototypes/Dashboard')
704
+ expect(vars.currentProtoDir).toBe('')
705
+ })
706
+
707
+ it('computes vars for a root-level file', () => {
708
+ const root = '/project'
709
+ const absPath = '/project/config.object.json'
710
+ const vars = computeTemplateVars(absPath, root)
711
+
712
+ expect(vars.currentDir).toBe('.')
713
+ expect(vars.currentProto).toBe('')
714
+ expect(vars.currentProtoDir).toBe('')
715
+ })
716
+
717
+ it('returns empty currentProto for a file directly inside a .folder (not in a prototype)', () => {
718
+ const root = '/project'
719
+ const absPath = '/project/src/prototypes/main.folder/nav.object.json'
720
+ const vars = computeTemplateVars(absPath, root)
721
+
722
+ expect(vars.currentDir).toBe('src/prototypes/main.folder')
723
+ expect(vars.currentProto).toBe('')
724
+ expect(vars.currentProtoDir).toBe('src/prototypes/main.folder')
725
+ })
726
+ })
727
+
728
+ describe('template variable integration', () => {
729
+ it('resolves ${currentDir} in object files', () => {
730
+ mkdirSync(path.join(tmpDir, 'src', 'data'), { recursive: true })
731
+ writeFileSync(
732
+ path.join(tmpDir, 'src', 'data', 'nav.object.json'),
733
+ JSON.stringify({ url: '/${currentDir}/page' }),
734
+ )
735
+
736
+ const plugin = createPlugin()
737
+ const code = plugin.load(RESOLVED_ID)
738
+
739
+ expect(code).toContain('/src/data/page')
740
+ expect(code).not.toContain('${currentDir}')
741
+ })
742
+
743
+ it('resolves ${currentProto} and ${currentProtoDir} in prototype files', () => {
744
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'App.folder', 'Dashboard'), { recursive: true })
745
+ writeFileSync(
746
+ path.join(tmpDir, 'src', 'prototypes', 'App.folder', 'Dashboard', 'nav.object.json'),
747
+ JSON.stringify({
748
+ proto: '${currentProto}',
749
+ folder: '${currentProtoDir}',
750
+ dir: '${currentDir}',
751
+ }),
752
+ )
753
+
754
+ const plugin = createPlugin()
755
+ const code = plugin.load(RESOLVED_ID)
756
+
757
+ expect(code).toContain('src/prototypes/App.folder/Dashboard')
758
+ expect(code).toContain('src/prototypes/App.folder')
759
+ expect(code).not.toContain('${currentProto}')
760
+ expect(code).not.toContain('${currentProtoDir}')
761
+ expect(code).not.toContain('${currentDir}')
762
+ })
763
+
764
+ it('resolves variables in flow files', () => {
765
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'Example.folder', 'Demo'), { recursive: true })
766
+ writeFileSync(
767
+ path.join(tmpDir, 'src', 'prototypes', 'Example.folder', 'Demo', 'default.flow.json'),
768
+ JSON.stringify({
769
+ nav: [{ label: 'Home', url: '/${currentDir}' }],
770
+ }),
771
+ )
772
+
773
+ const plugin = createPlugin()
774
+ const code = plugin.load(RESOLVED_ID)
775
+
776
+ expect(code).toContain('/src/prototypes/Example.folder/Demo')
777
+ expect(code).not.toContain('${currentDir}')
778
+ })
779
+
780
+ it('resolves variables in record files', () => {
781
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'Blog'), { recursive: true })
782
+ writeFileSync(
783
+ path.join(tmpDir, 'src', 'prototypes', 'Blog', 'posts.record.json'),
784
+ JSON.stringify([
785
+ { id: '1', link: '/${currentProto}/post/1' },
786
+ ]),
787
+ )
788
+
789
+ const plugin = createPlugin()
790
+ const code = plugin.load(RESOLVED_ID)
791
+
792
+ expect(code).toContain('/src/prototypes/Blog/post/1')
793
+ expect(code).not.toContain('${currentProto}')
794
+ })
795
+
796
+ it('warns when ${currentProto} is used outside a prototype', () => {
797
+ mkdirSync(path.join(tmpDir, 'src', 'data'), { recursive: true })
798
+ writeFileSync(
799
+ path.join(tmpDir, 'src', 'data', 'nav.object.json'),
800
+ JSON.stringify({ url: '/${currentProto}/page' }),
801
+ )
802
+
803
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
804
+ const plugin = createPlugin()
805
+ plugin.load(RESOLVED_ID)
806
+
807
+ const warnCall = warnSpy.mock.calls.find(call =>
808
+ typeof call[0] === 'string' && call[0].includes('${currentProto}')
809
+ )
810
+ expect(warnCall).toBeTruthy()
811
+ warnSpy.mockRestore()
812
+ })
813
+
814
+ it('warns when ${currentProtoDir} is used outside a .folder', () => {
815
+ mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'Dashboard'), { recursive: true })
816
+ writeFileSync(
817
+ path.join(tmpDir, 'src', 'prototypes', 'Dashboard', 'nav.object.json'),
818
+ JSON.stringify({ folder: '${currentProtoDir}' }),
819
+ )
820
+
821
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
822
+ const plugin = createPlugin()
823
+ plugin.load(RESOLVED_ID)
824
+
825
+ const warnCall = warnSpy.mock.calls.find(call =>
826
+ typeof call[0] === 'string' && call[0].includes('${currentProtoDir}')
827
+ )
828
+ expect(warnCall).toBeTruthy()
829
+ warnSpy.mockRestore()
830
+ })
831
+ })
832
+
833
+ // ── Canvas watcher / HMR tests ──────────────────────────────────────
834
+
835
+ describe('canvas watcher behavior', () => {
836
+ /** Helper: create a mock Vite dev server for configureServer */
837
+ function createMockServer(root) {
838
+ const listeners = {}
839
+ const wsSent = []
840
+ const invalidatedModules = []
841
+
842
+ return {
843
+ wsSent,
844
+ invalidatedModules,
845
+ listeners,
846
+ config: { root, base: '/' },
847
+ watcher: {
848
+ add: vi.fn(),
849
+ on(event, fn) {
850
+ if (!listeners[event]) listeners[event] = []
851
+ listeners[event].push(fn)
852
+ },
853
+ },
854
+ moduleGraph: {
855
+ getModuleById(id) {
856
+ if (id === RESOLVED_ID) return { id: RESOLVED_ID }
857
+ return null
858
+ },
859
+ invalidateModule(mod) {
860
+ invalidatedModules.push(mod.id)
861
+ },
862
+ },
863
+ ws: {
864
+ send(msg) { wsSent.push(msg) },
865
+ },
866
+ middlewares: {
867
+ use: vi.fn(),
868
+ },
869
+ }
870
+ }
871
+
872
+ /** Emit a watcher event on the mock server */
873
+ function emit(server, event, filePath) {
874
+ for (const fn of (server.listeners[event] || [])) {
875
+ fn(filePath)
876
+ }
877
+ }
878
+
879
+ function writeCanvasFile(dir, name, title) {
880
+ const canvasDir = path.join(dir, 'src', 'canvas')
881
+ mkdirSync(canvasDir, { recursive: true })
882
+ const evt = { event: 'canvas_created', title: title || name, timestamp: Date.now() }
883
+ writeFileSync(path.join(canvasDir, `${name}.canvas.jsonl`), JSON.stringify(evt) + '\n')
884
+ }
885
+
886
+ it('soft-invalidates virtual module on canvas content change (no full-reload)', () => {
887
+ writeCanvasFile(tmpDir, 'test-canvas', 'Original Title')
888
+ const plugin = createPlugin()
889
+ // Force initial buildResult
890
+ plugin.load(RESOLVED_ID)
891
+
892
+ const server = createMockServer(tmpDir)
893
+ plugin.configureServer(server)
894
+
895
+ // Simulate a canvas file content change
896
+ const canvasPath = path.join(tmpDir, 'src', 'canvas', 'test-canvas.canvas.jsonl')
897
+ emit(server, 'change', canvasPath)
898
+
899
+ // Should have sent custom HMR event (not full-reload)
900
+ const customEvents = server.wsSent.filter(m => m.type === 'custom')
901
+ const fullReloads = server.wsSent.filter(m => m.type === 'full-reload')
902
+
903
+ expect(customEvents.length).toBe(1)
904
+ expect(customEvents[0].event).toBe('storyboard:canvas-file-changed')
905
+ expect(customEvents[0].data.canvasId).toBe('test-canvas')
906
+ expect(fullReloads.length).toBe(0)
907
+
908
+ // Should have invalidated the virtual module
909
+ expect(server.invalidatedModules).toContain(RESOLVED_ID)
910
+ })
911
+
912
+ it('includes metadata in HMR event for canvas content changes', () => {
913
+ writeCanvasFile(tmpDir, 'meta-canvas', 'My Canvas Title')
914
+ const plugin = createPlugin()
915
+ plugin.load(RESOLVED_ID)
916
+
917
+ const server = createMockServer(tmpDir)
918
+ plugin.configureServer(server)
919
+
920
+ emit(server, 'change', path.join(tmpDir, 'src', 'canvas', 'meta-canvas.canvas.jsonl'))
921
+
922
+ const event = server.wsSent.find(m => m.type === 'custom')
923
+ expect(event.data.metadata).toBeDefined()
924
+ expect(event.data.metadata.title).toBe('My Canvas Title')
925
+ })
926
+
927
+ it('soft-invalidates on canvas file add (new canvas)', () => {
928
+ const plugin = createPlugin()
929
+ plugin.load(RESOLVED_ID)
930
+
931
+ const server = createMockServer(tmpDir)
932
+ plugin.configureServer(server)
933
+
934
+ // Create the file after the server is configured
935
+ writeCanvasFile(tmpDir, 'new-canvas', 'Brand New')
936
+ emit(server, 'add', path.join(tmpDir, 'src', 'canvas', 'new-canvas.canvas.jsonl'))
937
+
938
+ const customEvents = server.wsSent.filter(m => m.type === 'custom')
939
+ const fullReloads = server.wsSent.filter(m => m.type === 'full-reload')
940
+
941
+ expect(customEvents.length).toBe(1)
942
+ expect(customEvents[0].data.canvasId).toBe('new-canvas')
943
+ expect(customEvents[0].data.metadata).toBeDefined()
944
+ expect(fullReloads.length).toBe(0)
945
+ expect(server.invalidatedModules).toContain(RESOLVED_ID)
946
+ })
947
+
948
+ it('soft-invalidates on canvas file unlink after timeout (true delete)', async () => {
949
+ writeCanvasFile(tmpDir, 'doomed-canvas', 'Gone Soon')
950
+ const plugin = createPlugin()
951
+ plugin.load(RESOLVED_ID)
952
+
953
+ const server = createMockServer(tmpDir)
954
+ plugin.configureServer(server)
955
+
956
+ emit(server, 'unlink', path.join(tmpDir, 'src', 'canvas', 'doomed-canvas.canvas.jsonl'))
957
+
958
+ // Immediately after unlink — no event yet (deferred by 1500ms)
959
+ expect(server.wsSent.length).toBe(0)
960
+
961
+ // Wait for deferred timer
962
+ await new Promise(resolve => setTimeout(resolve, 1600))
963
+
964
+ const customEvents = server.wsSent.filter(m => m.type === 'custom')
965
+ expect(customEvents.length).toBe(1)
966
+ expect(customEvents[0].data.canvasId).toBe('doomed-canvas')
967
+ expect(customEvents[0].data.removed).toBe(true)
968
+ expect(server.invalidatedModules).toContain(RESOLVED_ID)
969
+ })
970
+
971
+ it('cancels deferred unlink on add (atomic write / in-place save)', async () => {
972
+ writeCanvasFile(tmpDir, 'saved-canvas', 'Saved')
973
+ const plugin = createPlugin()
974
+ plugin.load(RESOLVED_ID)
975
+
976
+ const server = createMockServer(tmpDir)
977
+ plugin.configureServer(server)
978
+
979
+ const canvasPath = path.join(tmpDir, 'src', 'canvas', 'saved-canvas.canvas.jsonl')
980
+
981
+ // Simulate atomic write: unlink then add within 1500ms
982
+ emit(server, 'unlink', canvasPath)
983
+ emit(server, 'add', canvasPath)
984
+
985
+ // Should have sent one event immediately (the add cancelling the unlink)
986
+ const customEvents = server.wsSent.filter(m => m.type === 'custom')
987
+ expect(customEvents.length).toBe(1)
988
+ expect(customEvents[0].data.canvasId).toBe('saved-canvas')
989
+ expect(customEvents[0].data.removed).toBeUndefined()
990
+ expect(server.invalidatedModules).toContain(RESOLVED_ID)
991
+
992
+ // Wait past the unlink timer — should NOT get a second event
993
+ await new Promise(resolve => setTimeout(resolve, 1600))
994
+ const allCustom = server.wsSent.filter(m => m.type === 'custom')
995
+ expect(allCustom.length).toBe(1)
996
+ })
997
+
998
+ it('handleHotUpdate returns empty array for canvas files (suppresses full-reload)', () => {
999
+ const plugin = createPlugin()
1000
+ const result = plugin.handleHotUpdate({
1001
+ file: path.join(tmpDir, 'src', 'canvas', 'test.canvas.jsonl'),
1002
+ server: createMockServer(tmpDir),
1003
+ modules: [],
1004
+ })
1005
+ expect(result).toEqual([])
1006
+ })
1007
+
1008
+ it('handleHotUpdate does not send duplicate HMR events', () => {
1009
+ const plugin = createPlugin()
1010
+ const server = createMockServer(tmpDir)
1011
+ plugin.handleHotUpdate({
1012
+ file: path.join(tmpDir, 'src', 'canvas', 'test.canvas.jsonl'),
1013
+ server,
1014
+ modules: [],
1015
+ })
1016
+ // handleHotUpdate should NOT send events (invalidate() handles it)
1017
+ expect(server.wsSent.length).toBe(0)
1018
+ })
1019
+
1020
+ it('generated virtual module includes HMR listener for canvas updates', () => {
1021
+ writeCanvasFile(tmpDir, 'hmr-canvas', 'HMR Test')
1022
+ const plugin = createPlugin()
1023
+ const code = plugin.load(RESOLVED_ID)
1024
+
1025
+ expect(code).toContain('import.meta.hot')
1026
+ expect(code).toContain('storyboard:canvas-file-changed')
1027
+ expect(code).toContain('data.removed')
1028
+ expect(code).toContain('data.metadata')
1029
+ // Should merge into existing entries to preserve build-time fields
1030
+ expect(code).toContain('Object.assign')
1031
+ })
1032
+
1033
+ it('page refresh after canvas add yields updated module with new canvas', () => {
1034
+ const plugin = createPlugin()
1035
+ // First load — no canvases
1036
+ const code1 = plugin.load(RESOLVED_ID)
1037
+ expect(code1).not.toContain('"refresh-canvas"')
1038
+
1039
+ // Simulate adding a canvas and clearing buildResult (what softInvalidate does)
1040
+ writeCanvasFile(tmpDir, 'refresh-canvas', 'After Refresh')
1041
+
1042
+ // Manually clear buildResult by loading a fresh plugin instance with the same root
1043
+ const plugin2 = createPlugin()
1044
+ const code2 = plugin2.load(RESOLVED_ID)
1045
+ expect(code2).toContain('"refresh-canvas"')
1046
+ expect(code2).toContain('After Refresh')
1047
+ })
1048
+
1049
+ // ── Story file discovery ──────────────────────────────────────────
1050
+
1051
+ it('discovers .story.jsx files and generates _storyImport', () => {
1052
+ writeDataFiles(tmpDir)
1053
+ writeFileSync(
1054
+ path.join(tmpDir, 'button-patterns.story.jsx'),
1055
+ 'export function Primary() { return null }',
1056
+ )
1057
+ const plugin = createPlugin()
1058
+ const code = plugin.load(RESOLVED_ID)
1059
+
1060
+ expect(code).toContain('"button-patterns"')
1061
+ expect(code).toContain('_storyModule')
1062
+ expect(code).toContain('_storyImport')
1063
+ expect(code).toContain('.story.jsx')
1064
+ })
1065
+
1066
+ it('discovers .story.tsx files', () => {
1067
+ writeDataFiles(tmpDir)
1068
+ writeFileSync(
1069
+ path.join(tmpDir, 'card.story.tsx'),
1070
+ 'export function Default() { return null }',
1071
+ )
1072
+ const plugin = createPlugin()
1073
+ const code = plugin.load(RESOLVED_ID)
1074
+
1075
+ expect(code).toContain('"card"')
1076
+ expect(code).toContain('card.story.tsx')
1077
+ })
1078
+
1079
+ it('skips _-prefixed story files', () => {
1080
+ writeDataFiles(tmpDir)
1081
+ writeFileSync(
1082
+ path.join(tmpDir, '_draft.story.jsx'),
1083
+ 'export function Draft() { return null }',
1084
+ )
1085
+ const plugin = createPlugin()
1086
+ const code = plugin.load(RESOLVED_ID)
1087
+
1088
+ expect(code).not.toContain('"_draft"')
1089
+ })
1090
+
1091
+ it('throws on duplicate story names', () => {
1092
+ writeDataFiles(tmpDir)
1093
+ mkdirSync(path.join(tmpDir, 'a'), { recursive: true })
1094
+ mkdirSync(path.join(tmpDir, 'b'), { recursive: true })
1095
+ writeFileSync(
1096
+ path.join(tmpDir, 'a', 'dupe.story.jsx'),
1097
+ 'export function A() { return null }',
1098
+ )
1099
+ writeFileSync(
1100
+ path.join(tmpDir, 'b', 'dupe.story.jsx'),
1101
+ 'export function B() { return null }',
1102
+ )
1103
+ const plugin = createPlugin()
1104
+ expect(() => plugin.load(RESOLVED_ID)).toThrow(/Duplicate story "dupe"/)
1105
+ })
1106
+
1107
+ it('includes stories in the init() call and exports', () => {
1108
+ writeDataFiles(tmpDir)
1109
+ writeFileSync(
1110
+ path.join(tmpDir, 'test.story.jsx'),
1111
+ 'export function Test() { return null }',
1112
+ )
1113
+ const plugin = createPlugin()
1114
+ const code = plugin.load(RESOLVED_ID)
1115
+
1116
+ expect(code).toContain('const stories = {')
1117
+ expect(code).toContain('init({ flows, objects, records, prototypes, folders, canvases, stories })')
1118
+ expect(code).toContain('export { flows, scenes, objects, records, prototypes, folders, canvases, canvasAliases, stories }')
1119
+ })
1120
+
1121
+ it('infers /components/ route for stories in src/canvas/', () => {
1122
+ writeDataFiles(tmpDir)
1123
+ mkdirSync(path.join(tmpDir, 'src', 'canvas'), { recursive: true })
1124
+ writeFileSync(
1125
+ path.join(tmpDir, 'src', 'canvas', 'button-patterns.story.jsx'),
1126
+ 'export function Primary() { return null }',
1127
+ )
1128
+ const plugin = createPlugin()
1129
+ const code = plugin.load(RESOLVED_ID)
1130
+
1131
+ expect(code).toContain('"button-patterns"')
1132
+ expect(code).toContain('"/components/button-patterns"')
1133
+ expect(code).toContain('_route')
1134
+ })
1135
+
1136
+ it('infers /components/ route for stories in src/components/', () => {
1137
+ writeDataFiles(tmpDir)
1138
+ mkdirSync(path.join(tmpDir, 'src', 'components'), { recursive: true })
1139
+ writeFileSync(
1140
+ path.join(tmpDir, 'src', 'components', 'text-input.story.jsx'),
1141
+ 'export function Default() { return null }',
1142
+ )
1143
+ const plugin = createPlugin()
1144
+ const code = plugin.load(RESOLVED_ID)
1145
+
1146
+ expect(code).toContain('"text-input"')
1147
+ expect(code).toContain('"/components/text-input"')
1148
+ })
1149
+
1150
+ it('stories outside src/canvas/ or src/components/ have no inferred route', () => {
1151
+ writeDataFiles(tmpDir)
1152
+ writeFileSync(
1153
+ path.join(tmpDir, 'orphan.story.jsx'),
1154
+ 'export function Default() { return null }',
1155
+ )
1156
+ const plugin = createPlugin()
1157
+ const code = plugin.load(RESOLVED_ID)
1158
+
1159
+ expect(code).toContain('"orphan"')
1160
+ // Should not have _route since it's not in a recognized directory
1161
+ expect(code).not.toContain('"/orphan"')
1162
+ })
1163
+ })
1164
+
1165
+ describe('parseDataFile — canvas path-based IDs', () => {
1166
+ it('flat canvas in src/canvas/ gets basename-only ID', () => {
1167
+ const result = parseDataFile('src/canvas/overview.canvas.jsonl')
1168
+ expect(result.name).toBe('overview')
1169
+ expect(result.inferredRoute).toBe('/canvas/overview')
1170
+ expect(result.group).toBeNull()
1171
+ })
1172
+
1173
+ it('canvas inside .folder/ gets path-based ID', () => {
1174
+ const result = parseDataFile('src/canvas/research.folder/interviews.canvas.jsonl')
1175
+ expect(result.name).toBe('research/interviews')
1176
+ expect(result.inferredRoute).toBe('/canvas/research/interviews')
1177
+ expect(result.group).toBe('research')
1178
+ })
1179
+
1180
+ it('duplicate basenames in different folders get distinct IDs', () => {
1181
+ const a = parseDataFile('src/canvas/alpha.folder/overview.canvas.jsonl')
1182
+ const b = parseDataFile('src/canvas/beta.folder/overview.canvas.jsonl')
1183
+ expect(a.name).toBe('alpha/overview')
1184
+ expect(b.name).toBe('beta/overview')
1185
+ expect(a.name).not.toBe(b.name)
1186
+ })
1187
+
1188
+ it('prototype-scoped canvas gets path-based ID', () => {
1189
+ const result = parseDataFile('src/prototypes/Dashboard/plan.canvas.jsonl')
1190
+ expect(result.name).toBe('Dashboard/plan')
1191
+ expect(result.inferredRoute).toBe('/canvas/Dashboard/plan')
1192
+ })
1193
+
1194
+ it('prototype inside .folder/ strips folder from ID', () => {
1195
+ const result = parseDataFile('src/prototypes/main.folder/Dashboard/plan.canvas.jsonl')
1196
+ expect(result.name).toBe('Dashboard/plan')
1197
+ expect(result.inferredRoute).toBe('/canvas/Dashboard/plan')
1198
+ })
1199
+
1200
+ it('skips _-prefixed canvas files', () => {
1201
+ expect(parseDataFile('src/canvas/_draft.canvas.jsonl')).toBeNull()
1202
+ })
1203
+
1204
+ it('skips canvas files in _-prefixed directories', () => {
1205
+ expect(parseDataFile('src/canvas/_hidden/public.canvas.jsonl')).toBeNull()
1206
+ })
1207
+
1208
+ it('canvas outside known directories gets basename-only ID', () => {
1209
+ const result = parseDataFile('random/path/notes.canvas.jsonl')
1210
+ expect(result.name).toBe('notes')
1211
+ expect(result.inferredRoute).toBeNull()
1212
+ })
1213
+
1214
+ it('sets group for grouped canvases', () => {
1215
+ const result = parseDataFile('src/canvas/ux.folder/onboarding.canvas.jsonl')
1216
+ expect(result.group).toBe('ux')
1217
+ })
1218
+
1219
+ it('sets group to null for ungrouped canvases', () => {
1220
+ const result = parseDataFile('src/canvas/standalone.canvas.jsonl')
1221
+ expect(result.group).toBeNull()
1222
+ })
1223
+ })