@dfosco/storyboard-react 4.0.0-beta.8 → 4.0.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 (75) hide show
  1. package/package.json +6 -3
  2. package/src/AuthModal/AuthModal.jsx +134 -0
  3. package/src/AuthModal/AuthModal.module.css +221 -0
  4. package/src/BranchBar/BranchBar.jsx +56 -0
  5. package/src/BranchBar/BranchBar.module.css +230 -0
  6. package/src/BranchBar/useBranches.js +79 -0
  7. package/src/CommandPalette/CommandPalette.jsx +936 -0
  8. package/src/CommandPalette/CreateDialog.jsx +219 -0
  9. package/src/CommandPalette/command-palette.css +111 -0
  10. package/src/Icon.jsx +180 -0
  11. package/src/Viewfinder.jsx +1104 -57
  12. package/src/Viewfinder.module.css +1107 -149
  13. package/src/canvas/CanvasControls.jsx +51 -2
  14. package/src/canvas/CanvasControls.module.css +31 -0
  15. package/src/canvas/CanvasPage.bridge.test.jsx +142 -19
  16. package/src/canvas/CanvasPage.dragdrop.test.jsx +346 -0
  17. package/src/canvas/CanvasPage.jsx +807 -251
  18. package/src/canvas/CanvasPage.module.css +98 -50
  19. package/src/canvas/CanvasPage.multiselect.test.jsx +13 -11
  20. package/src/canvas/CanvasToolbar.jsx +2 -2
  21. package/src/canvas/MarqueeOverlay.jsx +20 -0
  22. package/src/canvas/PageSelector.jsx +239 -0
  23. package/src/canvas/PageSelector.module.css +165 -0
  24. package/src/canvas/PageSelector.test.jsx +104 -0
  25. package/src/canvas/canvasApi.js +22 -8
  26. package/src/canvas/canvasTheme.js +96 -52
  27. package/src/canvas/componentIsolate.jsx +33 -7
  28. package/src/canvas/useCanvas.js +9 -8
  29. package/src/canvas/useCanvas.test.js +4 -4
  30. package/src/canvas/useMarqueeSelect.js +187 -0
  31. package/src/canvas/useMarqueeSelect.test.js +78 -0
  32. package/src/canvas/widgets/CodePenEmbed.jsx +292 -0
  33. package/src/canvas/widgets/CodePenEmbed.module.css +161 -0
  34. package/src/canvas/widgets/ComponentWidget.jsx +42 -10
  35. package/src/canvas/widgets/ComponentWidget.module.css +6 -5
  36. package/src/canvas/widgets/FigmaEmbed.jsx +110 -24
  37. package/src/canvas/widgets/FigmaEmbed.module.css +21 -7
  38. package/src/canvas/widgets/LinkPreview.jsx +297 -11
  39. package/src/canvas/widgets/LinkPreview.module.css +386 -18
  40. package/src/canvas/widgets/LinkPreview.test.jsx +193 -0
  41. package/src/canvas/widgets/MarkdownBlock.jsx +86 -5
  42. package/src/canvas/widgets/MarkdownBlock.module.css +64 -15
  43. package/src/canvas/widgets/PrototypeEmbed.jsx +96 -145
  44. package/src/canvas/widgets/PrototypeEmbed.module.css +74 -4
  45. package/src/canvas/widgets/StickyNote.module.css +5 -0
  46. package/src/canvas/widgets/StickyNote.test.jsx +9 -9
  47. package/src/canvas/widgets/StoryWidget.jsx +277 -0
  48. package/src/canvas/widgets/StoryWidget.module.css +211 -0
  49. package/src/canvas/widgets/WidgetChrome.jsx +76 -20
  50. package/src/canvas/widgets/WidgetChrome.module.css +2 -6
  51. package/src/canvas/widgets/WidgetWrapper.module.css +2 -0
  52. package/src/canvas/widgets/codepenUrl.js +75 -0
  53. package/src/canvas/widgets/codepenUrl.test.js +76 -0
  54. package/src/canvas/widgets/embedInteraction.test.jsx +235 -0
  55. package/src/canvas/widgets/embedOverlay.module.css +35 -0
  56. package/src/canvas/widgets/embedTheme.js +138 -39
  57. package/src/canvas/widgets/githubUrl.js +82 -0
  58. package/src/canvas/widgets/githubUrl.test.js +74 -0
  59. package/src/canvas/widgets/iframeDevLogs.js +49 -0
  60. package/src/canvas/widgets/iframeDevLogs.test.jsx +81 -0
  61. package/src/canvas/widgets/index.js +4 -0
  62. package/src/canvas/widgets/pasteRules.js +295 -0
  63. package/src/canvas/widgets/pasteRules.test.js +474 -0
  64. package/src/canvas/widgets/snapshotDisplay.test.jsx +259 -0
  65. package/src/canvas/widgets/widgetConfig.js +16 -5
  66. package/src/canvas/widgets/widgetConfig.test.js +34 -12
  67. package/src/context.jsx +145 -16
  68. package/src/hooks/useSceneData.js +4 -2
  69. package/src/hooks/useThemeState.js +61 -0
  70. package/src/hooks/useThemeState.test.js +66 -0
  71. package/src/index.js +10 -0
  72. package/src/story/StoryPage.jsx +117 -0
  73. package/src/story/StoryPage.module.css +18 -0
  74. package/src/vite/data-plugin.js +348 -66
  75. package/src/vite/data-plugin.test.js +405 -5
@@ -1,7 +1,7 @@
1
- import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'
1
+ import { mkdtempSync, writeFileSync, mkdirSync, rmSync, readFileSync } from 'node:fs'
2
2
  import { tmpdir } from 'node:os'
3
3
  import path from 'node:path'
4
- import storyboardDataPlugin, { resolveTemplateVars, computeTemplateVars } from './data-plugin.js'
4
+ import storyboardDataPlugin, { resolveTemplateVars, computeTemplateVars, parseDataFile } from './data-plugin.js'
5
5
 
6
6
  const RESOLVED_ID = '\0virtual:storyboard-data-index'
7
7
 
@@ -53,6 +53,14 @@ describe('storyboardDataPlugin', () => {
53
53
  expect(config.optimizeDeps.exclude).toContain('@dfosco/storyboard-react')
54
54
  })
55
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
+
56
64
  it("resolveId returns resolved ID for 'virtual:storyboard-data-index'", () => {
57
65
  const plugin = createPlugin()
58
66
  expect(plugin.resolveId('virtual:storyboard-data-index')).toBe(RESOLVED_ID)
@@ -69,13 +77,13 @@ describe('storyboardDataPlugin', () => {
69
77
  const code = plugin.load(RESOLVED_ID)
70
78
 
71
79
  expect(code).toContain("import { init } from '@dfosco/storyboard-core'")
72
- expect(code).toContain('init({ flows, objects, records, prototypes, folders, canvases })')
80
+ expect(code).toContain('init({ flows, objects, records, prototypes, folders, canvases, stories })')
73
81
  expect(code).toContain('"Test"')
74
82
  expect(code).toContain('"Jane"')
75
83
  expect(code).toContain('"First"')
76
84
  // Backward-compat alias
77
85
  expect(code).toContain('const scenes = flows')
78
- expect(code).toContain('export { flows, scenes, objects, records, prototypes, folders, canvases }')
86
+ expect(code).toContain('export { flows, scenes, objects, records, prototypes, folders, canvases, canvasAliases, stories }')
79
87
  })
80
88
 
81
89
  it('load returns null for other IDs', () => {
@@ -161,7 +169,7 @@ describe('storyboardDataPlugin', () => {
161
169
 
162
170
  // .scene.json files should be normalized to the flows category
163
171
  expect(code).toContain('"Legacy Scene"')
164
- expect(code).toContain('init({ flows, objects, records, prototypes, folders, canvases })')
172
+ expect(code).toContain('init({ flows, objects, records, prototypes, folders, canvases, stories })')
165
173
  })
166
174
 
167
175
  it('buildStart resets the index cache', () => {
@@ -821,3 +829,395 @@ describe('template variable integration', () => {
821
829
  warnSpy.mockRestore()
822
830
  })
823
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
+ })