@dfosco/storyboard-react 4.0.0-beta.9 → 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
@@ -4,12 +4,16 @@ import { execSync } from 'node:child_process'
4
4
  import { globSync } from 'glob'
5
5
  import { parse as parseJsonc } from 'jsonc-parser'
6
6
  import { materializeFromText } from '@dfosco/storyboard-core/canvas/materializer'
7
+ import { toCanvasId } from '@dfosco/storyboard-core/canvas/identity'
8
+ import { getConfig } from '@dfosco/storyboard-core/config'
7
9
 
8
10
  const VIRTUAL_MODULE_ID = 'virtual:storyboard-data-index'
9
11
  const RESOLVED_ID = '\0' + VIRTUAL_MODULE_ID
10
12
 
11
13
  const GLOB_PATTERN = '**/*.{flow,scene,object,record,prototype,folder}.{json,jsonc}'
12
14
  const CANVAS_GLOB_PATTERN = '**/*.canvas.jsonl'
15
+ const CANVAS_META_GLOB_PATTERN = '**/*.meta.json'
16
+ const STORY_GLOB_PATTERN = '**/*.story.{jsx,tsx}'
13
17
 
14
18
  /**
15
19
  * Extract the data name and type suffix from a file path.
@@ -32,7 +36,8 @@ function parseDataFile(filePath) {
32
36
  const normalized = filePath.replace(/\\/g, '/')
33
37
  if (normalized.split('/').some(seg => seg.startsWith('_'))) return null
34
38
 
35
- const name = canvasJsonlMatch[1]
39
+ const baseName = canvasJsonlMatch[1]
40
+ let name = baseName
36
41
  let inferredRoute = null
37
42
  const canvasFolderMatch = normalized.match(/(?:^|\/)src\/canvas\/([^/]+)\.folder\//)
38
43
  const canvasFolderName = canvasFolderMatch ? canvasFolderMatch[1] : null
@@ -46,20 +51,83 @@ function parseDataFile(filePath) {
46
51
  .replace(/^.*?src\/canvas\//, '')
47
52
  .replace(/[^/]*\.folder\/?/g, '')
48
53
  .replace(/\/$/, '')
49
- inferredRoute = '/canvas/' + (routeBase ? routeBase + '/' : '') + name
54
+ // Path-based ID: include folder context for uniqueness.
55
+ // .folder dirs contribute their name (sans .folder suffix) to the ID.
56
+ const idBase = (dirPath + '/')
57
+ .replace(/^.*?src\/canvas\//, '')
58
+ .replace(/\.folder\/?/g, '/')
59
+ .replace(/\/+/g, '/')
60
+ .replace(/\/$/, '')
61
+ name = idBase ? `${idBase}/${baseName}` : baseName
62
+ inferredRoute = '/canvas/' + name
50
63
  inferredRoute = inferredRoute.replace(/\/+/g, '/').replace(/\/$/, '') || '/canvas'
51
64
  }
52
65
  const protoCheck = normalized.match(/(?:^|\/)src\/prototypes\//)
53
66
  if (!canvasCheck && protoCheck) {
54
67
  const dirPath = normalized.substring(0, normalized.lastIndexOf('/'))
55
- const routeBase = (dirPath + '/')
68
+ // For prototypes, .folder is purely organizational — strip entirely
69
+ const idBase = (dirPath + '/')
56
70
  .replace(/^.*?src\/prototypes\//, '')
57
71
  .replace(/[^/]*\.folder\/?/g, '')
72
+ .replace(/\/+/g, '/')
58
73
  .replace(/\/$/, '')
59
- inferredRoute = '/canvas/' + (routeBase ? routeBase + '/' : '') + name
74
+ name = idBase ? `${idBase}/${baseName}` : baseName
75
+ inferredRoute = '/canvas/' + name
60
76
  inferredRoute = inferredRoute.replace(/\/+/g, '/').replace(/\/$/, '') || '/canvas'
61
77
  }
62
- return { name, suffix: 'canvas', ext: 'jsonl', folder: canvasFolderName || folderName, inferredRoute }
78
+ // Derive group: canvases sharing a directory form a group
79
+ const slashIdx = name.lastIndexOf('/')
80
+ const group = canvasFolderName || (slashIdx > 0 ? name.substring(0, slashIdx) : null)
81
+ // Extract a relative path for toCanvasId (it expects src/canvas/... or src/prototypes/...)
82
+ const canvasIdInput = normalized.replace(/^.*?(src\/(?:canvas|prototypes)\/)/, '$1')
83
+ return { name, suffix: 'canvas', ext: 'jsonl', folder: canvasFolderName || folderName, inferredRoute, id: toCanvasId(canvasIdInput), group }
84
+ }
85
+
86
+ // Handle canvas .meta.json files
87
+ const metaMatch = base.match(/^(.+)\.meta\.json$/)
88
+ if (metaMatch) {
89
+ const normalized = filePath.replace(/\\/g, '/')
90
+ // Only handle meta files inside src/canvas/ directories
91
+ const canvasCheck = normalized.match(/(?:^|\/)src\/canvas\//)
92
+ if (!canvasCheck) return null
93
+ // Skip _-prefixed
94
+ if (metaMatch[1].startsWith('_')) return null
95
+ if (normalized.split('/').some(seg => seg.startsWith('_'))) return null
96
+ return { name: metaMatch[1], suffix: 'canvas-meta', ext: 'json', inferredRoute: null }
97
+ }
98
+
99
+ // Handle .story.jsx / .story.tsx files
100
+ const storyMatch = base.match(/^(.+)\.story\.(jsx|tsx)$/)
101
+ if (storyMatch) {
102
+ if (storyMatch[1].startsWith('_')) return null
103
+ const normalized = filePath.replace(/\\/g, '/')
104
+ if (normalized.split('/').some(seg => seg.startsWith('_'))) return null
105
+
106
+ const name = storyMatch[1]
107
+ let inferredRoute = null
108
+
109
+ // All stories route under /components/ regardless of directory location
110
+ const canvasCheck = normalized.match(/(?:^|\/)src\/canvas\//)
111
+ const componentsCheck = normalized.match(/(?:^|\/)src\/components\//)
112
+ if (canvasCheck) {
113
+ const dirPath = normalized.substring(0, normalized.lastIndexOf('/'))
114
+ const routeBase = (dirPath + '/')
115
+ .replace(/^.*?src\/canvas\//, '')
116
+ .replace(/[^/]*\.folder\/?/g, '')
117
+ .replace(/\/$/, '')
118
+ inferredRoute = '/components/' + (routeBase ? routeBase + '/' : '') + name
119
+ inferredRoute = inferredRoute.replace(/\/+/g, '/').replace(/\/$/, '') || '/components'
120
+ } else if (componentsCheck) {
121
+ const dirPath = normalized.substring(0, normalized.lastIndexOf('/'))
122
+ const routeBase = (dirPath + '/')
123
+ .replace(/^.*?src\/components\//, '')
124
+ .replace(/[^/]*\.folder\/?/g, '')
125
+ .replace(/\/$/, '')
126
+ inferredRoute = '/components/' + (routeBase ? routeBase + '/' : '') + name
127
+ inferredRoute = inferredRoute.replace(/\/+/g, '/').replace(/\/$/, '') || '/components'
128
+ }
129
+
130
+ return { name, suffix: 'story', ext: storyMatch[2], inferredRoute }
63
131
  }
64
132
 
65
133
  const match = base.match(/^(.+)\.(flow|scene|object|record|prototype|folder)\.(jsonc?)$/)
@@ -250,6 +318,8 @@ function buildIndex(root) {
250
318
  const ignore = ['node_modules/**', 'dist/**', '.git/**', '.worktrees/**', 'public/**']
251
319
  const files = globSync(GLOB_PATTERN, { cwd: root, ignore, absolute: false })
252
320
  const canvasFiles = globSync(CANVAS_GLOB_PATTERN, { cwd: root, ignore, absolute: false })
321
+ const canvasMetaFiles = globSync(CANVAS_META_GLOB_PATTERN, { cwd: root, ignore, absolute: false })
322
+ const storyFiles = globSync(STORY_GLOB_PATTERN, { cwd: root, ignore, absolute: false })
253
323
 
254
324
  // Detect nested .folder/ directories (not supported)
255
325
  // Scan directories directly since empty nested folders have no data files
@@ -266,35 +336,58 @@ function buildIndex(root) {
266
336
  }
267
337
  }
268
338
 
269
- const index = { flow: {}, object: {}, record: {}, prototype: {}, folder: {}, canvas: {} }
270
- const seen = {} // "name.suffix" → absolute path (for duplicate detection)
339
+ const index = { flow: {}, object: {}, record: {}, prototype: {}, folder: {}, canvas: {}, 'canvas-meta': {}, story: {} }
340
+ const seen = {} // "name.suffix" or "id.suffix" → absolute path (for duplicate detection)
271
341
  const protoFolders = {} // prototype name → folder name (for injection)
272
342
  const flowRoutes = {} // flow name → inferred route (for _route injection)
273
343
  const canvasRoutes = {} // canvas name → inferred route
344
+ const canvasAliases = {} // basename → canonical ID (only when unique)
345
+ const canvasNameCount = {} // canvas basename → count (for ambiguity detection)
346
+ const canvasGroups = {} // canvas name → group name (shared folder prefix)
347
+ const storyRoutes = {} // story name → inferred route
274
348
 
275
- for (const relPath of [...files, ...canvasFiles]) {
349
+ for (const relPath of [...files, ...canvasFiles, ...canvasMetaFiles, ...storyFiles]) {
276
350
  const parsed = parseDataFile(relPath)
277
351
  if (!parsed) continue
278
352
 
279
- const key = `${parsed.name}.${parsed.suffix}`
353
+ // Canvas files use path-based IDs for dedup; others use basename
354
+ const dedupKey = parsed.suffix === 'canvas' && parsed.id
355
+ ? `${parsed.id}.${parsed.suffix}`
356
+ : `${parsed.name}.${parsed.suffix}`
280
357
  const absPath = path.resolve(root, relPath)
281
358
 
282
- if (seen[key]) {
359
+ if (seen[dedupKey]) {
283
360
  const hint = parsed.suffix === 'folder'
284
361
  ? ' Folder names must be unique across the project.'
362
+ : parsed.suffix === 'canvas'
363
+ ? ' Canvas IDs must be unique. Move or rename one file to resolve the collision.'
285
364
  : ' Flows, records, and objects are scoped to their prototype directory.\n' +
286
365
  ' If both files are global (outside src/prototypes/), rename one to avoid the collision.'
287
366
 
288
367
  throw new Error(
289
- `[storyboard-data] Duplicate ${parsed.suffix} "${parsed.name}"\n` +
290
- ` Found at: ${seen[key]}\n` +
368
+ `[storyboard-data] Duplicate ${parsed.suffix} "${parsed.id || parsed.name}"\n` +
369
+ ` Found at: ${seen[dedupKey]}\n` +
291
370
  ` And at: ${absPath}\n` +
292
371
  hint
293
372
  )
294
373
  }
295
374
 
296
- seen[key] = absPath
297
- index[parsed.suffix][parsed.name] = absPath
375
+ seen[dedupKey] = absPath
376
+
377
+ // Canvas: index only by canonical ID. Basename aliases go in a separate map
378
+ // so listCanvases() and viewfinder don't show duplicates.
379
+ if (parsed.suffix === 'canvas' && parsed.id) {
380
+ index.canvas[parsed.id] = absPath
381
+ // Track basename for alias resolution (only when unique)
382
+ canvasNameCount[parsed.name] = (canvasNameCount[parsed.name] || 0) + 1
383
+ if (canvasNameCount[parsed.name] === 1) {
384
+ canvasAliases[parsed.name] = parsed.id
385
+ } else {
386
+ delete canvasAliases[parsed.name]
387
+ }
388
+ } else {
389
+ index[parsed.suffix][parsed.name] = absPath
390
+ }
298
391
 
299
392
  // Track which folder a prototype belongs to
300
393
  if (parsed.suffix === 'prototype' && parsed.folder) {
@@ -306,13 +399,26 @@ function buildIndex(root) {
306
399
  flowRoutes[parsed.name] = parsed.inferredRoute
307
400
  }
308
401
 
309
- // Track inferred routes for canvases
402
+ // Track inferred routes for canvases (keyed by canonical ID)
310
403
  if (parsed.suffix === 'canvas' && parsed.inferredRoute) {
311
- canvasRoutes[parsed.name] = parsed.inferredRoute
404
+ const canvasKey = parsed.id || parsed.name
405
+ canvasRoutes[canvasKey] = parsed.inferredRoute
406
+ }
407
+
408
+ // Track canvas groups (canvases sharing a folder prefix)
409
+ // Use canonical ID as key to match the canvas index
410
+ if (parsed.suffix === 'canvas' && parsed.group) {
411
+ const groupKey = parsed.id || parsed.name
412
+ canvasGroups[groupKey] = parsed.group
413
+ }
414
+
415
+ // Track inferred routes for stories
416
+ if (parsed.suffix === 'story' && parsed.inferredRoute) {
417
+ storyRoutes[parsed.name] = parsed.inferredRoute
312
418
  }
313
419
  }
314
420
 
315
- return { index, protoFolders, flowRoutes, canvasRoutes }
421
+ return { index, protoFolders, flowRoutes, canvasRoutes, canvasAliases, canvasGroups, storyRoutes }
316
422
  }
317
423
 
318
424
  /**
@@ -366,7 +472,7 @@ function computeTemplateVars(absPath, root) {
366
472
  */
367
473
  /**
368
474
  * Read storyboard.config.json from the project root (if it exists).
369
- * Returns the parsed config object, or null if not found.
475
+ * Returns the parsed and defaulted config object, or null if not found.
370
476
  */
371
477
  function readConfig(root) {
372
478
  const configPath = path.resolve(root, 'storyboard.config.json')
@@ -376,7 +482,7 @@ function readConfig(root) {
376
482
  const config = parseJsonc(raw, errors)
377
483
  // Treat malformed JSON (e.g. mid-edit partial saves) as missing config
378
484
  if (errors.length > 0) return { config: null, configPath }
379
- return { config, configPath }
485
+ return { config: getConfig(config), configPath }
380
486
  } catch {
381
487
  return { config: null, configPath }
382
488
  }
@@ -420,10 +526,11 @@ function readModesConfig(root) {
420
526
  return fallback
421
527
  }
422
528
 
423
- function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root) {
529
+ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes, canvasAliases, canvasGroups, storyRoutes }, root) {
424
530
  const declarations = []
425
531
  const INDEX_KEYS = ['flow', 'object', 'record', 'prototype', 'folder', 'canvas']
426
532
  const entries = { flow: [], object: [], record: [], prototype: [], folder: [], canvas: [] }
533
+ const storyEntries = [] // handled separately (code modules, not JSON data)
427
534
  const resolvedFlowRoutes = {} // flow name → resolved route (for multi-flow logging)
428
535
  let i = 0
429
536
 
@@ -434,6 +541,21 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root)
434
541
  ]
435
542
  const gitMeta = batchGitMetadata(root, gitPaths)
436
543
 
544
+ // Read canvas-meta files and build a directory-based lookup
545
+ const canvasMetaByDir = {}
546
+ for (const [, absPath] of Object.entries(index['canvas-meta'] || {})) {
547
+ try {
548
+ const raw = fs.readFileSync(absPath, 'utf-8')
549
+ const parsed = parseJsonc(raw)
550
+ if (parsed) {
551
+ // Key by the parent directory path relative to src/canvas/
552
+ const dirPath = path.dirname(absPath).replace(/\\/g, '/')
553
+ const canvasRelDir = dirPath.replace(/^.*?src\/canvas\//, '')
554
+ canvasMetaByDir[canvasRelDir] = parsed
555
+ }
556
+ } catch { /* skip invalid meta files */ }
557
+ }
558
+
437
559
  for (const suffix of INDEX_KEYS) {
438
560
  for (const [name, absPath] of Object.entries(index[suffix])) {
439
561
  const varName = `_d${i++}`
@@ -499,11 +621,18 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root)
499
621
  }
500
622
  }
501
623
 
502
- // Inject inferred route and resolve JSX companion for canvases
624
+ // Inject inferred route, group, and resolve JSX companion for canvases
503
625
  if (suffix === 'canvas') {
504
626
  if (canvasRoutes[name]) {
505
627
  parsed = { ...parsed, _route: canvasRoutes[name] }
506
628
  }
629
+ if (canvasGroups[name]) {
630
+ parsed = { ...parsed, _group: canvasGroups[name] }
631
+ }
632
+ // Inject canvas folder metadata from .meta.json
633
+ if (canvasGroups[name] && canvasMetaByDir[canvasGroups[name]]) {
634
+ parsed = { ...parsed, _canvasMeta: canvasMetaByDir[canvasGroups[name]] }
635
+ }
507
636
  // Inject folder association
508
637
  const folderDirMatch = path.relative(root, absPath).replace(/\\/g, '/').match(/(?:^|\/)src\/(?:prototypes|canvas)\/([^/]+)\.folder\//)
509
638
  if (folderDirMatch) {
@@ -520,13 +649,6 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root)
520
649
  `[storyboard-data] Canvas "${name}" references JSX file "${parsed.jsx}" but it was not found at ${jsxPath}`
521
650
  )
522
651
  }
523
- } else {
524
- // Auto-detect a same-name .canvas.jsx companion
525
- const autoJsx = absPath.replace(/\.canvas\.(jsonl|jsonc?)$/, '.canvas.jsx')
526
- if (fs.existsSync(autoJsx)) {
527
- const relJsx = '/' + path.relative(root, autoJsx).replace(/\\/g, '/')
528
- parsed = { ...parsed, _jsxModule: relJsx }
529
- }
530
652
  }
531
653
  }
532
654
 
@@ -555,8 +677,22 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root)
555
677
  }
556
678
  }
557
679
 
680
+ // Generate story entries (code modules with dynamic imports, not JSON data)
681
+ for (const [name, absPath] of Object.entries(index.story || {})) {
682
+ const varName = `_d${i++}`
683
+ const relModule = '/' + path.relative(root, absPath).replace(/\\/g, '/')
684
+ const storyMeta = { _storyModule: relModule }
685
+ if (storyRoutes[name]) {
686
+ storyMeta._route = storyRoutes[name]
687
+ }
688
+ declarations.push(
689
+ `const ${varName} = Object.assign(${JSON.stringify(storyMeta)}, { _storyImport: () => import(${JSON.stringify(relModule)}) })`
690
+ )
691
+ storyEntries.push(` ${JSON.stringify(name)}: ${varName}`)
692
+ }
693
+
558
694
  const imports = [`import { init } from '@dfosco/storyboard-core'`]
559
- const initCalls = [`init({ flows, objects, records, prototypes, folders, canvases })`]
695
+ const initCalls = [`init({ flows, objects, records, prototypes, folders, canvases, stories })`]
560
696
 
561
697
  // Feature flags from storyboard.config.json
562
698
  const { config } = readConfig(root)
@@ -595,6 +731,12 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root)
595
731
  initCalls.push(`initUIConfig(${JSON.stringify(config.ui)})`)
596
732
  }
597
733
 
734
+ // Customer mode config from storyboard.config.json
735
+ if (config?.customerMode) {
736
+ imports.push(`import { initCustomerModeConfig } from '@dfosco/storyboard-core'`)
737
+ initCalls.push(`initCustomerModeConfig(${JSON.stringify(config.customerMode)})`)
738
+ }
739
+
598
740
  // Log info when multiple flows target the same route
599
741
  const routeGroups = {}
600
742
  for (const [name, { route, isDefault }] of Object.entries(resolvedFlowRoutes)) {
@@ -624,22 +766,54 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root)
624
766
  `const prototypes = {\n${entries.prototype.join(',\n')}\n}`,
625
767
  `const folders = {\n${entries.folder.join(',\n')}\n}`,
626
768
  `const canvases = {\n${entries.canvas.join(',\n')}\n}`,
769
+ `const stories = {\n${storyEntries.join(',\n')}\n}`,
770
+ '',
771
+ `// Legacy basename → canonical ID aliases (only unique basenames)`,
772
+ `const canvasAliases = ${JSON.stringify(canvasAliases || {})}`,
627
773
  '',
628
774
  '// Backward-compatible alias',
629
775
  'const scenes = flows',
630
776
  '',
631
777
  initCalls.join('\n'),
632
778
  '',
633
- `export { flows, scenes, objects, records, prototypes, folders, canvases }`,
634
- `export const index = { flows, scenes, objects, records, prototypes, folders, canvases }`,
779
+ `export { flows, scenes, objects, records, prototypes, folders, canvases, canvasAliases, stories }`,
780
+ `export const index = { flows, scenes, objects, records, prototypes, folders, canvases, canvasAliases, stories }`,
635
781
  `export default index`,
782
+ '',
783
+ '// Live-patch canvas data on HMR events so SPA navigation shows fresh state',
784
+ 'if (import.meta.hot) {',
785
+ ' import.meta.hot.on("storyboard:canvas-file-changed", (data) => {',
786
+ ' if (!data) return',
787
+ ' const id = data.canvasId || data.name',
788
+ ' if (data.removed) {',
789
+ ' delete canvases[id]',
790
+ ' } else if (data.metadata) {',
791
+ ' // Merge into existing entry to preserve build-time fields (_jsxModule, _jsxImport, etc.)',
792
+ ' canvases[id] = canvases[id]',
793
+ ' ? Object.assign({}, canvases[id], data.metadata)',
794
+ ' : data.metadata',
795
+ ' }',
796
+ ' init({ flows, objects, records, prototypes, folders, canvases, stories })',
797
+ ' })',
798
+ ' import.meta.hot.on("storyboard:story-file-changed", (data) => {',
799
+ ' if (!data) return',
800
+ ' if (data.removed) {',
801
+ ' delete stories[data.name]',
802
+ ' } else {',
803
+ ' stories[data.name] = { _storyModule: data._storyModule, _route: data._route,',
804
+ ' _storyImport: () => import(/* @vite-ignore */ data._storyModule) }',
805
+ ' }',
806
+ ' init({ flows, objects, records, prototypes, folders, canvases, stories })',
807
+ ' document.dispatchEvent(new CustomEvent("storyboard:story-index-changed"))',
808
+ ' })',
809
+ '}',
636
810
  ].join('\n')
637
811
  }
638
812
 
639
813
  /**
640
814
  * Vite plugin for storyboard data discovery.
641
815
  *
642
- * - Scans the repo for *.flow.json, *.scene.json (compat), *.object.json, *.record.json, *.canvas.jsonl
816
+ * - Scans the repo for *.flow.json, *.scene.json (compat), *.object.json, *.record.json, *.canvas.jsonl, *.story.{jsx,tsx}
643
817
  * - Validates no two files share the same name+suffix (hard build error)
644
818
  * - Generates a virtual module `virtual:storyboard-data-index`
645
819
  * - Watches for file additions/removals in dev mode
@@ -655,6 +829,11 @@ export default function storyboardDataPlugin() {
655
829
  config() {
656
830
  return {
657
831
  optimizeDeps: {
832
+ // @dfosco/storyboard-react is excluded (virtual module), so Vite
833
+ // can't trace into its deps. Include the remark entry points so
834
+ // Vite pre-bundles the full chain — covers all transitive CJS
835
+ // packages (debug, extend, etc.) without whack-a-mole.
836
+ include: ['react-cmdk', 'remark', 'remark-gfm', 'remark-html', 'use-sync-external-store/shim', 'use-sync-external-store/shim/with-selector'],
658
837
  exclude: ['@dfosco/storyboard-react'],
659
838
  },
660
839
  }
@@ -678,7 +857,7 @@ export default function storyboardDataPlugin() {
678
857
  // ── Component isolate middleware ───────────────────────────────
679
858
  // Serves a minimal HTML shell for iframe-isolated component widgets.
680
859
  // The iframe loads componentIsolate.jsx which reads query params
681
- // (module, export, theme) and renders a single canvas.jsx export.
860
+ // (module, export, theme) and renders a single story export.
682
861
  const isolateEntryPath = new URL('../canvas/componentIsolate.jsx', import.meta.url).pathname
683
862
  server.middlewares.use(async (req, res, next) => {
684
863
  if (!req.url) return next()
@@ -692,7 +871,7 @@ export default function storyboardDataPlugin() {
692
871
  const rawHtml = [
693
872
  '<!DOCTYPE html>',
694
873
  '<html><head>',
695
- '<style>html,body{margin:0;padding:0;width:100%;height:100%}#root{width:100%;height:100%}</style>',
874
+ '<style>html,body{margin:0;padding:0;width:100%;height:100%;background:var(--bgColor-default,transparent)}#root{width:100%;height:100%}</style>',
696
875
  '</head><body>',
697
876
  '<div id="root"></div>',
698
877
  `<script type="module" src="/@fs${isolateEntryPath}"></script>`,
@@ -710,10 +889,33 @@ export default function storyboardDataPlugin() {
710
889
  }
711
890
  })
712
891
 
892
+ // ── Stories list API ──────────────────────────────────────────
893
+ // Serves the list of discovered stories for the CLI and UI story picker.
894
+ server.middlewares.use(async (req, res, next) => {
895
+ if (!req.url) return next()
896
+ let url = req.url
897
+ const baseNoTrail = (server.config.base || '/').replace(/\/$/, '')
898
+ if (baseNoTrail && url.startsWith(baseNoTrail)) {
899
+ url = url.slice(baseNoTrail.length) || '/'
900
+ }
901
+ if (!url.startsWith('/_storyboard/stories/list')) return next()
902
+
903
+ if (!buildResult) buildResult = buildIndex(root)
904
+ const storyEntries = Object.entries(buildResult.index.story || {})
905
+ const storyRoutes = buildResult.storyRoutes || {}
906
+ const stories = storyEntries.map(([name]) => ({
907
+ name,
908
+ route: storyRoutes[name] || null,
909
+ }))
910
+
911
+ res.writeHead(200, { 'Content-Type': 'application/json' })
912
+ res.end(JSON.stringify({ stories }))
913
+ })
914
+
713
915
  // Watch for data file changes in dev mode
714
916
  const watcher = server.watcher
715
917
  if (!buildResult) buildResult = buildIndex(root)
716
- const knownCanvasNames = new Set(Object.keys(buildResult.index.canvas || {}))
918
+ const knownCanvasIds = new Set(Object.keys(buildResult.index.canvas || {}))
717
919
  const pendingCanvasUnlinks = new Map()
718
920
 
719
921
  const triggerFullReload = () => {
@@ -725,22 +927,50 @@ export default function storyboardDataPlugin() {
725
927
  }
726
928
  }
727
929
 
930
+ // Mark the virtual module as stale so the next page load rebuilds it,
931
+ // but do NOT trigger a full-reload (avoids losing canvas editing state).
932
+ const softInvalidate = () => {
933
+ buildResult = null
934
+ const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
935
+ if (mod) server.moduleGraph.invalidateModule(mod)
936
+ }
937
+
938
+ // Read a canvas file and build HMR metadata for the client-side listener.
939
+ const readCanvasMetadata = (filePath, parsed) => {
940
+ try {
941
+ const absPath = path.resolve(root, filePath)
942
+ const raw = fs.readFileSync(absPath, 'utf-8')
943
+ const materialized = materializeFromText(raw)
944
+ const result = { ...materialized }
945
+ // Inject _route and _folder the same way generateModule does
946
+ if (parsed.inferredRoute) result._route = parsed.inferredRoute
947
+ const folderDirMatch = path.relative(root, absPath).replace(/\\/g, '/').match(/(?:^|\/)src\/(?:prototypes|canvas)\/([^/]+)\.folder\//)
948
+ if (folderDirMatch) result._folder = folderDirMatch[1]
949
+ return result
950
+ } catch {
951
+ return null
952
+ }
953
+ }
954
+
728
955
  const invalidate = (filePath) => {
729
956
  const normalized = filePath.replace(/\\/g, '/')
730
- // Skip .canvas.jsonl content changes entirely these are mutated
731
- // at runtime by the canvas server API. A full-reload would create
732
- // a feedback loop (save → file change → reload → lose editing state).
733
- // Instead, send a custom HMR event so the active canvas page can refetch
734
- // file-backed data in place with no navigation or document reload.
957
+ // Canvas .jsonl content changes are mutated at runtime by the canvas
958
+ // server API. A full-reload would create a feedback loop (save →
959
+ // file change → reload → lose editing state). Instead, soft-invalidate
960
+ // the virtual module (so page refresh picks up changes) and send a
961
+ // custom HMR event with updated metadata so the canvas page and
962
+ // viewfinder can react in place.
735
963
  if (/\.canvas\.jsonl$/.test(normalized)) {
736
964
  const parsed = parseDataFile(filePath)
737
- if (parsed?.suffix === 'canvas' && parsed?.name) {
965
+ if (parsed?.suffix === 'canvas' && parsed?.id) {
966
+ const metadata = readCanvasMetadata(filePath, parsed)
738
967
  server.ws.send({
739
968
  type: 'custom',
740
969
  event: 'storyboard:canvas-file-changed',
741
- data: { name: parsed.name },
970
+ data: { canvasId: parsed.id, name: parsed.id, ...(metadata ? { metadata } : {}) },
742
971
  })
743
972
  }
973
+ softInvalidate()
744
974
  return
745
975
  }
746
976
 
@@ -777,53 +1007,89 @@ export default function storyboardDataPlugin() {
777
1007
  // Treat canvas add/unlink as runtime data updates and never full-reload
778
1008
  // from watcher events. Canvas pages sync from disk via custom WS events.
779
1009
  if (parsed?.suffix === 'canvas') {
780
- const name = parsed.name
1010
+ const canvasId = parsed.id || parsed.name
781
1011
  if (eventType === 'unlink') {
782
1012
  const timer = setTimeout(() => {
783
- pendingCanvasUnlinks.delete(name)
784
- knownCanvasNames.delete(name)
1013
+ pendingCanvasUnlinks.delete(canvasId)
1014
+ knownCanvasIds.delete(canvasId)
785
1015
  server.ws.send({
786
1016
  type: 'custom',
787
1017
  event: 'storyboard:canvas-file-changed',
788
- data: { name },
1018
+ data: { canvasId, name: canvasId, removed: true },
789
1019
  })
1020
+ softInvalidate()
790
1021
  }, 1500)
791
- pendingCanvasUnlinks.set(name, timer)
1022
+ pendingCanvasUnlinks.set(canvasId, timer)
792
1023
  return
793
1024
  }
794
1025
 
795
1026
  if (eventType === 'add') {
796
- const pending = pendingCanvasUnlinks.get(name)
1027
+ const metadata = readCanvasMetadata(filePath, parsed)
1028
+ const pending = pendingCanvasUnlinks.get(canvasId)
797
1029
  if (pending) {
1030
+ // unlink+add pair = in-place save (atomic write), not a real remove
798
1031
  clearTimeout(pending)
799
- pendingCanvasUnlinks.delete(name)
1032
+ pendingCanvasUnlinks.delete(canvasId)
800
1033
  server.ws.send({
801
1034
  type: 'custom',
802
1035
  event: 'storyboard:canvas-file-changed',
803
- data: { name },
1036
+ data: { canvasId, name: canvasId, ...(metadata ? { metadata } : {}) },
804
1037
  })
1038
+ softInvalidate()
805
1039
  return
806
1040
  }
807
1041
 
808
- if (knownCanvasNames.has(name)) {
1042
+ if (knownCanvasIds.has(canvasId)) {
809
1043
  server.ws.send({
810
1044
  type: 'custom',
811
1045
  event: 'storyboard:canvas-file-changed',
812
- data: { name },
1046
+ data: { canvasId, name: canvasId, ...(metadata ? { metadata } : {}) },
813
1047
  })
1048
+ softInvalidate()
814
1049
  return
815
1050
  }
816
1051
 
817
- knownCanvasNames.add(name)
1052
+ knownCanvasIds.add(canvasId)
818
1053
  server.ws.send({
819
1054
  type: 'custom',
820
1055
  event: 'storyboard:canvas-file-changed',
821
- data: { name },
1056
+ data: { canvasId, name: canvasId, ...(metadata ? { metadata } : {}) },
822
1057
  })
1058
+ softInvalidate()
823
1059
  return
824
1060
  }
825
1061
  }
826
1062
 
1063
+ // Story add/remove: soft-invalidate + custom HMR event (full-reload
1064
+ // is blocked by the canvas reload guard). The virtual module HMR
1065
+ // handler live-patches `stories` and re-runs init().
1066
+ if (parsed?.suffix === 'story') {
1067
+ softInvalidate()
1068
+ if (!buildResult) buildResult = buildIndex(root)
1069
+ const storyRoutes = buildResult.storyRoutes || {}
1070
+ const storyIndex = buildResult.index.story || {}
1071
+ const name = parsed.name
1072
+ if (eventType === 'unlink') {
1073
+ server.ws.send({
1074
+ type: 'custom',
1075
+ event: 'storyboard:story-file-changed',
1076
+ data: { name, removed: true },
1077
+ })
1078
+ } else if (eventType === 'add' && storyIndex[name]) {
1079
+ const relModule = '/' + path.relative(root, storyIndex[name]).replace(/\\/g, '/')
1080
+ server.ws.send({
1081
+ type: 'custom',
1082
+ event: 'storyboard:story-file-changed',
1083
+ data: {
1084
+ name,
1085
+ _storyModule: relModule,
1086
+ _route: storyRoutes[name] || null,
1087
+ },
1088
+ })
1089
+ }
1090
+ return
1091
+ }
1092
+
827
1093
  // Non-canvas additions/removals and folder changes update the route/data graph.
828
1094
  triggerFullReload()
829
1095
  }
@@ -854,21 +1120,37 @@ export default function storyboardDataPlugin() {
854
1120
  const normalized = ctx.file.replace(/\\/g, '/')
855
1121
  if (!/\.canvas\.jsonl$/.test(normalized)) return
856
1122
 
857
- const parsed = parseDataFile(ctx.file)
858
- if (parsed?.suffix === 'canvas' && parsed?.name) {
859
- ctx.server.ws.send({
860
- type: 'custom',
861
- event: 'storyboard:canvas-file-changed',
862
- data: { name: parsed.name },
863
- })
864
- }
865
-
866
1123
  // Prevent Vite's default fallback behavior (full page reload) for
867
- // non-module .canvas.jsonl edits. Canvas pages consume these updates
868
- // through the custom WS event and in-page refetch.
1124
+ // non-module .canvas.jsonl edits. The watcher 'change' handler
1125
+ // (invalidate) already sends the custom HMR event and soft-invalidates
1126
+ // the virtual module — no duplicate event needed here.
869
1127
  return []
870
1128
  },
871
1129
 
1130
+ // Inject __SB_BRANCHES__ into HTML so the Viewfinder branch selector works.
1131
+ // Reads .worktrees/ports.json to enumerate active worktree dev servers.
1132
+ transformIndexHtml(html, ctx) {
1133
+ // Only inject in dev mode
1134
+ if (!ctx.server) return html
1135
+
1136
+ try {
1137
+ const portsJsonPath = path.resolve(root, '.worktrees', 'ports.json')
1138
+ if (!fs.existsSync(portsJsonPath)) return html
1139
+
1140
+ const ports = JSON.parse(fs.readFileSync(portsJsonPath, 'utf-8'))
1141
+ const branches = Object.entries(ports)
1142
+ .filter(([name]) => name !== 'main')
1143
+ .map(([name, port]) => ({ branch: name, folder: `branch--${name}`, port }))
1144
+
1145
+ if (branches.length === 0) return html
1146
+
1147
+ const script = `<script>window.__SB_BRANCHES__ = ${JSON.stringify(branches)};</script>`
1148
+ return html.replace('</head>', `${script}\n</head>`)
1149
+ } catch {
1150
+ return html
1151
+ }
1152
+ },
1153
+
872
1154
  // Rebuild index on each build start
873
1155
  buildStart() {
874
1156
  buildResult = null
@@ -877,4 +1159,4 @@ export default function storyboardDataPlugin() {
877
1159
  }
878
1160
 
879
1161
  // Exported for testing
880
- export { resolveTemplateVars, computeTemplateVars }
1162
+ export { resolveTemplateVars, computeTemplateVars, parseDataFile }