@dfosco/storyboard-react 2.8.0 → 3.1.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.
@@ -0,0 +1,50 @@
1
+ import { useContext, useMemo, useCallback } from 'react'
2
+ import { StoryboardContext } from '../StoryboardContext.js'
3
+ import { getFlowsForPrototype, resolveFlowRoute } from '@dfosco/storyboard-core'
4
+ import { getFlowMeta } from '@dfosco/storyboard-core'
5
+
6
+ /**
7
+ * List all flows for the current prototype and switch between them.
8
+ *
9
+ * @returns {{
10
+ * flows: Array<{ key: string, name: string, title: string, route: string }>,
11
+ * activeFlow: string,
12
+ * switchFlow: (flowKey: string) => void,
13
+ * prototypeName: string | null
14
+ * }}
15
+ */
16
+ export function useFlows() {
17
+ const context = useContext(StoryboardContext)
18
+ if (context === null) {
19
+ throw new Error('useFlows must be used within a <StoryboardProvider>')
20
+ }
21
+
22
+ const { flowName: activeFlow, prototypeName } = context
23
+
24
+ const flows = useMemo(() => {
25
+ if (!prototypeName) return []
26
+ return getFlowsForPrototype(prototypeName).map(f => {
27
+ const meta = getFlowMeta(f.key)
28
+ return {
29
+ key: f.key,
30
+ name: f.name,
31
+ title: meta?.title || f.name,
32
+ route: resolveFlowRoute(f.key),
33
+ }
34
+ })
35
+ }, [prototypeName])
36
+
37
+ const switchFlow = useCallback((flowKey) => {
38
+ const flow = flows.find(f => f.key === flowKey)
39
+ if (flow) {
40
+ window.location.href = flow.route
41
+ }
42
+ }, [flows])
43
+
44
+ return {
45
+ flows,
46
+ activeFlow,
47
+ switchFlow,
48
+ prototypeName,
49
+ }
50
+ }
@@ -0,0 +1,134 @@
1
+ import { renderHook } from '@testing-library/react'
2
+ import { createElement } from 'react'
3
+ import { init, getFlowsForPrototype } from '@dfosco/storyboard-core'
4
+ import { useFlows } from './useFlows.js'
5
+ import { StoryboardContext } from '../StoryboardContext.js'
6
+
7
+ // Test data with prototype-scoped flows
8
+ const SCOPED_FLOWS = {
9
+ 'default': { meta: { title: 'Default' } },
10
+ 'Signup/empty-form': { meta: { title: 'Empty Form' }, _route: '/Signup' },
11
+ 'Signup/validation-errors': { meta: { title: 'Validation Errors' }, _route: '/Signup' },
12
+ 'Signup/prefilled-review': { meta: { title: 'Prefilled Review' }, _route: '/Signup' },
13
+ 'Signup/error-state': { meta: { title: 'Error State' }, _route: '/Signup' },
14
+ 'Example/basic': { meta: { title: 'Example Data Flow' }, _route: '/Example' },
15
+ }
16
+
17
+ function seedScopedData() {
18
+ init({ flows: SCOPED_FLOWS, objects: {}, records: {} })
19
+ }
20
+
21
+ function createWrapperWithPrototype(flowName = 'default', prototypeName = null) {
22
+ return function Wrapper({ children }) {
23
+ return createElement(
24
+ StoryboardContext.Provider,
25
+ { value: { data: {}, error: null, loading: false, flowName, sceneName: flowName, prototypeName } },
26
+ children,
27
+ )
28
+ }
29
+ }
30
+
31
+ beforeEach(() => {
32
+ seedScopedData()
33
+ })
34
+
35
+ // ── Core utility: getFlowsForPrototype ──
36
+
37
+ describe('getFlowsForPrototype', () => {
38
+ it('returns flows scoped to the given prototype', () => {
39
+ const flows = getFlowsForPrototype('Signup')
40
+ expect(flows).toHaveLength(4)
41
+ expect(flows.map(f => f.name)).toEqual([
42
+ 'empty-form', 'validation-errors', 'prefilled-review', 'error-state',
43
+ ])
44
+ })
45
+
46
+ it('returns the full key with prototype prefix', () => {
47
+ const flows = getFlowsForPrototype('Signup')
48
+ expect(flows[0].key).toBe('Signup/empty-form')
49
+ })
50
+
51
+ it('returns empty array for prototype with no flows', () => {
52
+ const flows = getFlowsForPrototype('NonExistent')
53
+ expect(flows).toEqual([])
54
+ })
55
+
56
+ it('returns empty array when prototypeName is null', () => {
57
+ const flows = getFlowsForPrototype(null)
58
+ expect(flows).toEqual([])
59
+ })
60
+
61
+ it('returns empty array when prototypeName is empty string', () => {
62
+ const flows = getFlowsForPrototype('')
63
+ expect(flows).toEqual([])
64
+ })
65
+
66
+ it('excludes global flows (no prototype prefix)', () => {
67
+ const flows = getFlowsForPrototype('Signup')
68
+ const keys = flows.map(f => f.key)
69
+ expect(keys).not.toContain('default')
70
+ })
71
+
72
+ it('returns single flow for prototype with one flow', () => {
73
+ const flows = getFlowsForPrototype('Example')
74
+ expect(flows).toHaveLength(1)
75
+ expect(flows[0].name).toBe('basic')
76
+ })
77
+ })
78
+
79
+ // ── useFlows hook ──
80
+
81
+ describe('useFlows', () => {
82
+ it('returns flows for the current prototype', () => {
83
+ const { result } = renderHook(() => useFlows(), {
84
+ wrapper: createWrapperWithPrototype('Signup/empty-form', 'Signup'),
85
+ })
86
+ expect(result.current.flows).toHaveLength(4)
87
+ expect(result.current.flows[0].title).toBe('Empty Form')
88
+ })
89
+
90
+ it('returns the active flow key', () => {
91
+ const { result } = renderHook(() => useFlows(), {
92
+ wrapper: createWrapperWithPrototype('Signup/validation-errors', 'Signup'),
93
+ })
94
+ expect(result.current.activeFlow).toBe('Signup/validation-errors')
95
+ })
96
+
97
+ it('returns prototype name', () => {
98
+ const { result } = renderHook(() => useFlows(), {
99
+ wrapper: createWrapperWithPrototype('Signup/empty-form', 'Signup'),
100
+ })
101
+ expect(result.current.prototypeName).toBe('Signup')
102
+ })
103
+
104
+ it('returns empty flows when no prototype', () => {
105
+ const { result } = renderHook(() => useFlows(), {
106
+ wrapper: createWrapperWithPrototype('default', null),
107
+ })
108
+ expect(result.current.flows).toEqual([])
109
+ })
110
+
111
+ it('switchFlow is a function', () => {
112
+ const { result } = renderHook(() => useFlows(), {
113
+ wrapper: createWrapperWithPrototype('Signup/empty-form', 'Signup'),
114
+ })
115
+ expect(typeof result.current.switchFlow).toBe('function')
116
+ })
117
+
118
+ it('flow entries have title from meta', () => {
119
+ const { result } = renderHook(() => useFlows(), {
120
+ wrapper: createWrapperWithPrototype('Signup/empty-form', 'Signup'),
121
+ })
122
+ const titles = result.current.flows.map(f => f.title)
123
+ expect(titles).toContain('Empty Form')
124
+ expect(titles).toContain('Validation Errors')
125
+ expect(titles).toContain('Prefilled Review')
126
+ expect(titles).toContain('Error State')
127
+ })
128
+
129
+ it('throws when used outside StoryboardProvider', () => {
130
+ expect(() => {
131
+ renderHook(() => useFlows())
132
+ }).toThrow('useFlows must be used within a <StoryboardProvider>')
133
+ })
134
+ })
package/src/index.js CHANGED
@@ -16,6 +16,7 @@ export { useSceneData, useSceneLoading } from './hooks/useSceneData.js'
16
16
  export { useOverride } from './hooks/useOverride.js'
17
17
  export { useOverride as useSession } from './hooks/useOverride.js' // deprecated alias
18
18
  export { useFlow, useScene } from './hooks/useScene.js'
19
+ export { useFlows } from './hooks/useFlows.js'
19
20
  export { useRecord, useRecords } from './hooks/useRecord.js'
20
21
  export { useObject } from './hooks/useObject.js'
21
22
  export { useLocalStorage } from './hooks/useLocalStorage.js'
@@ -35,3 +36,7 @@ export { FormContext } from './context/FormContext.js'
35
36
 
36
37
  // Viewfinder dashboard
37
38
  export { default as Viewfinder } from './Viewfinder.jsx'
39
+
40
+ // Canvas
41
+ export { default as CanvasPage } from './canvas/CanvasPage.jsx'
42
+ export { useCanvas } from './canvas/useCanvas.js'
@@ -3,11 +3,13 @@ import path from 'node:path'
3
3
  import { execSync } from 'node:child_process'
4
4
  import { globSync } from 'glob'
5
5
  import { parse as parseJsonc } from 'jsonc-parser'
6
+ import { materializeFromText } from '@dfosco/storyboard-core/canvas/materializer'
6
7
 
7
8
  const VIRTUAL_MODULE_ID = 'virtual:storyboard-data-index'
8
9
  const RESOLVED_ID = '\0' + VIRTUAL_MODULE_ID
9
10
 
10
11
  const GLOB_PATTERN = '**/*.{flow,scene,object,record,prototype,folder}.{json,jsonc}'
12
+ const CANVAS_GLOB_PATTERN = '**/*.canvas.jsonl'
11
13
 
12
14
  /**
13
15
  * Extract the data name and type suffix from a file path.
@@ -22,6 +24,44 @@ const GLOB_PATTERN = '**/*.{flow,scene,object,record,prototype,folder}.{json,jso
22
24
  */
23
25
  function parseDataFile(filePath) {
24
26
  const base = path.basename(filePath)
27
+
28
+ // Handle .canvas.jsonl files
29
+ const canvasJsonlMatch = base.match(/^(.+)\.canvas\.jsonl$/)
30
+ if (canvasJsonlMatch) {
31
+ if (canvasJsonlMatch[1].startsWith('_')) return null
32
+ const normalized = filePath.replace(/\\/g, '/')
33
+ if (normalized.split('/').some(seg => seg.startsWith('_'))) return null
34
+
35
+ const name = canvasJsonlMatch[1]
36
+ let inferredRoute = null
37
+ const canvasFolderMatch = normalized.match(/(?:^|\/)src\/canvas\/([^/]+)\.folder\//)
38
+ const canvasFolderName = canvasFolderMatch ? canvasFolderMatch[1] : null
39
+ const folderDirMatch = normalized.match(/(?:^|\/)src\/prototypes\/([^/]+)\.folder\//)
40
+ const folderName = folderDirMatch ? folderDirMatch[1] : null
41
+
42
+ const canvasCheck = normalized.match(/(?:^|\/)src\/canvas\//)
43
+ if (canvasCheck) {
44
+ const dirPath = normalized.substring(0, normalized.lastIndexOf('/'))
45
+ const routeBase = (dirPath + '/')
46
+ .replace(/^.*?src\/canvas\//, '')
47
+ .replace(/[^/]*\.folder\/?/g, '')
48
+ .replace(/\/$/, '')
49
+ inferredRoute = '/canvas/' + (routeBase ? routeBase + '/' : '') + name
50
+ inferredRoute = inferredRoute.replace(/\/+/g, '/').replace(/\/$/, '') || '/canvas'
51
+ }
52
+ const protoCheck = normalized.match(/(?:^|\/)src\/prototypes\//)
53
+ if (!canvasCheck && protoCheck) {
54
+ const dirPath = normalized.substring(0, normalized.lastIndexOf('/'))
55
+ const routeBase = (dirPath + '/')
56
+ .replace(/^.*?src\/prototypes\//, '')
57
+ .replace(/[^/]*\.folder\/?/g, '')
58
+ .replace(/\/$/, '')
59
+ inferredRoute = '/canvas/' + (routeBase ? routeBase + '/' : '') + name
60
+ inferredRoute = inferredRoute.replace(/\/+/g, '/').replace(/\/$/, '') || '/canvas'
61
+ }
62
+ return { name, suffix: 'canvas', ext: 'jsonl', folder: canvasFolderName || folderName, inferredRoute }
63
+ }
64
+
25
65
  const match = base.match(/^(.+)\.(flow|scene|object|record|prototype|folder)\.(jsonc?)$/)
26
66
  if (!match) return null
27
67
 
@@ -121,6 +161,7 @@ function getLastModified(root, dirPath) {
121
161
  function buildIndex(root) {
122
162
  const ignore = ['node_modules/**', 'dist/**', '.git/**']
123
163
  const files = globSync(GLOB_PATTERN, { cwd: root, ignore, absolute: false })
164
+ const canvasFiles = globSync(CANVAS_GLOB_PATTERN, { cwd: root, ignore, absolute: false })
124
165
 
125
166
  // Detect nested .folder/ directories (not supported)
126
167
  // Scan directories directly since empty nested folders have no data files
@@ -137,12 +178,13 @@ function buildIndex(root) {
137
178
  }
138
179
  }
139
180
 
140
- const index = { flow: {}, object: {}, record: {}, prototype: {}, folder: {} }
181
+ const index = { flow: {}, object: {}, record: {}, prototype: {}, folder: {}, canvas: {} }
141
182
  const seen = {} // "name.suffix" → absolute path (for duplicate detection)
142
183
  const protoFolders = {} // prototype name → folder name (for injection)
143
184
  const flowRoutes = {} // flow name → inferred route (for _route injection)
185
+ const canvasRoutes = {} // canvas name → inferred route
144
186
 
145
- for (const relPath of files) {
187
+ for (const relPath of [...files, ...canvasFiles]) {
146
188
  const parsed = parseDataFile(relPath)
147
189
  if (!parsed) continue
148
190
 
@@ -175,9 +217,14 @@ function buildIndex(root) {
175
217
  if (parsed.suffix === 'flow' && parsed.inferredRoute) {
176
218
  flowRoutes[parsed.name] = parsed.inferredRoute
177
219
  }
220
+
221
+ // Track inferred routes for canvases
222
+ if (parsed.suffix === 'canvas' && parsed.inferredRoute) {
223
+ canvasRoutes[parsed.name] = parsed.inferredRoute
224
+ }
178
225
  }
179
226
 
180
- return { index, protoFolders, flowRoutes }
227
+ return { index, protoFolders, flowRoutes, canvasRoutes }
181
228
  }
182
229
 
183
230
  /**
@@ -248,25 +295,26 @@ function readConfig(root) {
248
295
  }
249
296
 
250
297
  /**
251
- * Read modes.config.json from @dfosco/storyboard-core.
252
- * Returns the full config object { modes, tools }.
298
+ * Read core-ui.config.json from @dfosco/storyboard-core.
299
+ * Returns the full config object with modes array.
253
300
  * Falls back to hardcoded defaults if not found.
254
301
  */
255
302
  function readModesConfig(root) {
256
303
  const fallback = {
257
304
  modes: [
258
- { name: 'prototype', label: 'Navigate' },
259
- { name: 'inspect', label: 'Develop' },
260
- { name: 'present', label: 'Collaborate' },
261
- { name: 'plan', label: 'Canvas' },
305
+ { name: 'prototype', label: 'Navigate', hue: '#2a2a2a' },
306
+ { name: 'inspect', label: 'Develop', hue: '#7655a4' },
307
+ { name: 'present', label: 'Collaborate', hue: '#2a9d8f' },
308
+ { name: 'plan', label: 'Canvas', hue: '#4a7fad' },
262
309
  ],
263
- tools: {},
264
310
  }
265
311
 
266
312
  // Try local workspace path first (monorepo), then node_modules
267
313
  const candidates = [
268
- path.resolve(root, 'packages/core/modes.config.json'),
269
- path.resolve(root, 'node_modules/@dfosco/storyboard-core/modes.config.json'),
314
+ path.resolve(root, 'packages/core/core-ui.config.json'),
315
+ path.resolve(root, 'packages/core/configs/modes.config.json'),
316
+ path.resolve(root, 'node_modules/@dfosco/storyboard-core/core-ui.config.json'),
317
+ path.resolve(root, 'node_modules/@dfosco/storyboard-core/configs/modes.config.json'),
270
318
  ]
271
319
 
272
320
  for (const filePath of candidates) {
@@ -274,7 +322,7 @@ function readModesConfig(root) {
274
322
  const raw = fs.readFileSync(filePath, 'utf-8')
275
323
  const parsed = JSON.parse(raw)
276
324
  if (Array.isArray(parsed.modes) && parsed.modes.length > 0) {
277
- return { modes: parsed.modes, tools: parsed.tools ?? {} }
325
+ return { modes: parsed.modes }
278
326
  }
279
327
  } catch {
280
328
  // try next candidate
@@ -284,10 +332,10 @@ function readModesConfig(root) {
284
332
  return fallback
285
333
  }
286
334
 
287
- function generateModule({ index, protoFolders, flowRoutes }, root) {
335
+ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root) {
288
336
  const declarations = []
289
- const INDEX_KEYS = ['flow', 'object', 'record', 'prototype', 'folder']
290
- const entries = { flow: [], object: [], record: [], prototype: [], folder: [] }
337
+ const INDEX_KEYS = ['flow', 'object', 'record', 'prototype', 'folder', 'canvas']
338
+ const entries = { flow: [], object: [], record: [], prototype: [], folder: [], canvas: [] }
291
339
  const resolvedFlowRoutes = {} // flow name → resolved route (for multi-flow logging)
292
340
  let i = 0
293
341
 
@@ -295,7 +343,9 @@ function generateModule({ index, protoFolders, flowRoutes }, root) {
295
343
  for (const [name, absPath] of Object.entries(index[suffix])) {
296
344
  const varName = `_d${i++}`
297
345
  const raw = fs.readFileSync(absPath, 'utf-8')
298
- let parsed = parseJsonc(raw)
346
+ let parsed = suffix === 'canvas'
347
+ ? materializeFromText(raw)
348
+ : parseJsonc(raw)
299
349
 
300
350
  // Auto-fill gitAuthor for prototype metadata from git history
301
351
  if (suffix === 'prototype' && parsed && !parsed.gitAuthor) {
@@ -332,6 +382,37 @@ function generateModule({ index, protoFolders, flowRoutes }, root) {
332
382
  }
333
383
  }
334
384
 
385
+ // Inject inferred route and resolve JSX companion for canvases
386
+ if (suffix === 'canvas') {
387
+ if (canvasRoutes[name]) {
388
+ parsed = { ...parsed, _route: canvasRoutes[name] }
389
+ }
390
+ // Inject folder association
391
+ const folderDirMatch = path.relative(root, absPath).replace(/\\/g, '/').match(/(?:^|\/)src\/(?:prototypes|canvas)\/([^/]+)\.folder\//)
392
+ if (folderDirMatch) {
393
+ parsed = { ...parsed, _folder: folderDirMatch[1] }
394
+ }
395
+ // Resolve JSX companion file path
396
+ if (parsed?.jsx) {
397
+ const jsxPath = path.resolve(path.dirname(absPath), parsed.jsx)
398
+ if (fs.existsSync(jsxPath)) {
399
+ const relJsx = '/' + path.relative(root, jsxPath).replace(/\\/g, '/')
400
+ parsed = { ...parsed, _jsxModule: relJsx }
401
+ } else {
402
+ console.warn(
403
+ `[storyboard-data] Canvas "${name}" references JSX file "${parsed.jsx}" but it was not found at ${jsxPath}`
404
+ )
405
+ }
406
+ } else {
407
+ // Auto-detect a same-name .canvas.jsx companion
408
+ const autoJsx = absPath.replace(/\.canvas\.(jsonl|jsonc?)$/, '.canvas.jsx')
409
+ if (fs.existsSync(autoJsx)) {
410
+ const relJsx = '/' + path.relative(root, autoJsx).replace(/\\/g, '/')
411
+ parsed = { ...parsed, _jsxModule: relJsx }
412
+ }
413
+ }
414
+ }
415
+
335
416
  // Resolve template variables (${currentDir}, ${currentProto}, ${currentProtoDir})
336
417
  const templateVars = computeTemplateVars(absPath, root)
337
418
  if (!templateVars.currentProto && raw.includes('${currentProto}')) {
@@ -354,7 +435,7 @@ function generateModule({ index, protoFolders, flowRoutes }, root) {
354
435
  }
355
436
 
356
437
  const imports = [`import { init } from '@dfosco/storyboard-core'`]
357
- const initCalls = [`init({ flows, objects, records, prototypes, folders })`]
438
+ const initCalls = [`init({ flows, objects, records, prototypes, folders, canvases })`]
358
439
 
359
440
  // Feature flags from storyboard.config.json
360
441
  const { config } = readConfig(root)
@@ -383,15 +464,16 @@ function generateModule({ index, protoFolders, flowRoutes }, root) {
383
464
  initCalls.push(`registerMode(${JSON.stringify(m.name)}, { label: ${JSON.stringify(m.label)} })`)
384
465
  }
385
466
 
386
- // Seed tool registry from modes.config.json
387
- if (Object.keys(modesConfig.tools).length > 0) {
388
- initCalls.push(`initTools(${JSON.stringify(modesConfig.tools)})`)
389
- }
390
-
391
467
  initCalls.push(`syncModeClasses()`)
392
468
  }
393
469
  }
394
470
 
471
+ // UI config from storyboard.config.json (menu visibility overrides)
472
+ if (config?.ui) {
473
+ imports.push(`import { initUIConfig } from '@dfosco/storyboard-core'`)
474
+ initCalls.push(`initUIConfig(${JSON.stringify(config.ui)})`)
475
+ }
476
+
395
477
  // Log info when multiple flows target the same route
396
478
  const routeGroups = {}
397
479
  for (const [name, { route, isDefault }] of Object.entries(resolvedFlowRoutes)) {
@@ -422,14 +504,15 @@ function generateModule({ index, protoFolders, flowRoutes }, root) {
422
504
  `const records = {\n${entries.record.join(',\n')}\n}`,
423
505
  `const prototypes = {\n${entries.prototype.join(',\n')}\n}`,
424
506
  `const folders = {\n${entries.folder.join(',\n')}\n}`,
507
+ `const canvases = {\n${entries.canvas.join(',\n')}\n}`,
425
508
  '',
426
509
  '// Backward-compatible alias',
427
510
  'const scenes = flows',
428
511
  '',
429
512
  initCalls.join('\n'),
430
513
  '',
431
- `export { flows, scenes, objects, records, prototypes, folders }`,
432
- `export const index = { flows, scenes, objects, records, prototypes, folders }`,
514
+ `export { flows, scenes, objects, records, prototypes, folders, canvases }`,
515
+ `export const index = { flows, scenes, objects, records, prototypes, folders, canvases }`,
433
516
  `export default index`,
434
517
  ].join('\n')
435
518
  }
@@ -437,7 +520,7 @@ function generateModule({ index, protoFolders, flowRoutes }, root) {
437
520
  /**
438
521
  * Vite plugin for storyboard data discovery.
439
522
  *
440
- * - Scans the repo for *.flow.json, *.scene.json (compat), *.object.json, *.record.json
523
+ * - Scans the repo for *.flow.json, *.scene.json (compat), *.object.json, *.record.json, *.canvas.jsonl
441
524
  * - Validates no two files share the same name+suffix (hard build error)
442
525
  * - Generates a virtual module `virtual:storyboard-data-index`
443
526
  * - Watches for file additions/removals in dev mode
@@ -477,9 +560,15 @@ export default function storyboardDataPlugin() {
477
560
  const watcher = server.watcher
478
561
 
479
562
  const invalidate = (filePath) => {
563
+ const normalized = filePath.replace(/\\/g, '/')
564
+ // Skip .canvas.jsonl content changes entirely — these are mutated
565
+ // at runtime by the canvas server API. A full-reload would create
566
+ // a feedback loop (save → file change → reload → lose editing state).
567
+ if (/\.canvas\.jsonl$/.test(normalized)) return
568
+
480
569
  const parsed = parseDataFile(filePath)
481
570
  // Also invalidate when files are added/removed inside .folder/ directories
482
- const inFolder = filePath.replace(/\\/g, '/').includes('.folder/')
571
+ const inFolder = normalized.includes('.folder/')
483
572
  if (!parsed && !inFolder) return
484
573
  // Rebuild index and invalidate virtual module
485
574
  buildResult = null
@@ -490,6 +579,19 @@ export default function storyboardDataPlugin() {
490
579
  }
491
580
  }
492
581
 
582
+ const invalidateOnAddRemove = (filePath) => {
583
+ const parsed = parseDataFile(filePath)
584
+ const inFolder = filePath.replace(/\\/g, '/').includes('.folder/')
585
+ if (!parsed && !inFolder) return
586
+ // Canvas additions/removals DO need a reload (new routes)
587
+ buildResult = null
588
+ const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
589
+ if (mod) {
590
+ server.moduleGraph.invalidateModule(mod)
591
+ server.ws.send({ type: 'full-reload' })
592
+ }
593
+ }
594
+
493
595
  // Watch storyboard.config.json for changes
494
596
  const { configPath } = readConfig(root)
495
597
  watcher.add(configPath)
@@ -504,8 +606,8 @@ export default function storyboardDataPlugin() {
504
606
  }
505
607
  }
506
608
 
507
- watcher.on('add', invalidate)
508
- watcher.on('unlink', invalidate)
609
+ watcher.on('add', invalidateOnAddRemove)
610
+ watcher.on('unlink', invalidateOnAddRemove)
509
611
  watcher.on('change', (filePath) => {
510
612
  invalidate(filePath)
511
613
  invalidateConfig(filePath)
@@ -69,13 +69,13 @@ describe('storyboardDataPlugin', () => {
69
69
  const code = plugin.load(RESOLVED_ID)
70
70
 
71
71
  expect(code).toContain("import { init } from '@dfosco/storyboard-core'")
72
- expect(code).toContain('init({ flows, objects, records, prototypes, folders })')
72
+ expect(code).toContain('init({ flows, objects, records, prototypes, folders, canvases })')
73
73
  expect(code).toContain('"Test"')
74
74
  expect(code).toContain('"Jane"')
75
75
  expect(code).toContain('"First"')
76
76
  // Backward-compat alias
77
77
  expect(code).toContain('const scenes = flows')
78
- expect(code).toContain('export { flows, scenes, objects, records, prototypes, folders }')
78
+ expect(code).toContain('export { flows, scenes, objects, records, prototypes, folders, canvases }')
79
79
  })
80
80
 
81
81
  it('load returns null for other IDs', () => {
@@ -161,7 +161,7 @@ describe('storyboardDataPlugin', () => {
161
161
 
162
162
  // .scene.json files should be normalized to the flows category
163
163
  expect(code).toContain('"Legacy Scene"')
164
- expect(code).toContain('init({ flows, objects, records, prototypes, folders })')
164
+ expect(code).toContain('init({ flows, objects, records, prototypes, folders, canvases })')
165
165
  })
166
166
 
167
167
  it('buildStart resets the index cache', () => {