@dfosco/storyboard-react 4.0.0-beta.4 → 4.0.0-beta.41

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 (63) hide show
  1. package/package.json +9 -4
  2. package/src/Icon.jsx +179 -0
  3. package/src/Viewfinder.jsx +1030 -57
  4. package/src/Viewfinder.module.css +1524 -155
  5. package/src/canvas/CanvasControls.jsx +51 -2
  6. package/src/canvas/CanvasControls.module.css +31 -0
  7. package/src/canvas/CanvasPage.bridge.test.jsx +95 -10
  8. package/src/canvas/CanvasPage.dragdrop.test.jsx +346 -0
  9. package/src/canvas/CanvasPage.jsx +843 -301
  10. package/src/canvas/CanvasPage.module.css +73 -50
  11. package/src/canvas/CanvasPage.multiselect.test.jsx +13 -11
  12. package/src/canvas/CanvasToolbar.jsx +2 -2
  13. package/src/canvas/ComponentErrorBoundary.jsx +50 -0
  14. package/src/canvas/PageSelector.jsx +198 -0
  15. package/src/canvas/PageSelector.module.css +158 -0
  16. package/src/canvas/PageSelector.test.jsx +104 -0
  17. package/src/canvas/canvasApi.js +22 -8
  18. package/src/canvas/canvasReloadGuard.js +37 -0
  19. package/src/canvas/canvasReloadGuard.test.js +27 -0
  20. package/src/canvas/componentIsolate.jsx +135 -0
  21. package/src/canvas/useCanvas.js +15 -10
  22. package/src/canvas/widgets/CodePenEmbed.jsx +292 -0
  23. package/src/canvas/widgets/CodePenEmbed.module.css +161 -0
  24. package/src/canvas/widgets/ComponentWidget.jsx +82 -9
  25. package/src/canvas/widgets/ComponentWidget.module.css +14 -6
  26. package/src/canvas/widgets/FigmaEmbed.jsx +110 -24
  27. package/src/canvas/widgets/FigmaEmbed.module.css +21 -7
  28. package/src/canvas/widgets/LinkPreview.jsx +297 -11
  29. package/src/canvas/widgets/LinkPreview.module.css +386 -18
  30. package/src/canvas/widgets/LinkPreview.test.jsx +193 -0
  31. package/src/canvas/widgets/MarkdownBlock.jsx +95 -21
  32. package/src/canvas/widgets/MarkdownBlock.module.css +133 -2
  33. package/src/canvas/widgets/MarkdownBlock.test.jsx +39 -0
  34. package/src/canvas/widgets/PrototypeEmbed.jsx +95 -144
  35. package/src/canvas/widgets/PrototypeEmbed.module.css +74 -4
  36. package/src/canvas/widgets/StickyNote.module.css +5 -0
  37. package/src/canvas/widgets/StickyNote.test.jsx +9 -9
  38. package/src/canvas/widgets/StoryWidget.jsx +276 -0
  39. package/src/canvas/widgets/StoryWidget.module.css +211 -0
  40. package/src/canvas/widgets/WidgetChrome.jsx +76 -20
  41. package/src/canvas/widgets/WidgetChrome.module.css +4 -7
  42. package/src/canvas/widgets/WidgetWrapper.module.css +2 -0
  43. package/src/canvas/widgets/codepenUrl.js +75 -0
  44. package/src/canvas/widgets/codepenUrl.test.js +76 -0
  45. package/src/canvas/widgets/embedInteraction.test.jsx +235 -0
  46. package/src/canvas/widgets/embedOverlay.module.css +35 -0
  47. package/src/canvas/widgets/embedTheme.js +56 -0
  48. package/src/canvas/widgets/githubUrl.js +82 -0
  49. package/src/canvas/widgets/githubUrl.test.js +74 -0
  50. package/src/canvas/widgets/iframeDevLogs.js +49 -0
  51. package/src/canvas/widgets/iframeDevLogs.test.jsx +81 -0
  52. package/src/canvas/widgets/index.js +4 -0
  53. package/src/canvas/widgets/pasteRules.js +295 -0
  54. package/src/canvas/widgets/pasteRules.test.js +474 -0
  55. package/src/canvas/widgets/snapshotDisplay.test.jsx +259 -0
  56. package/src/canvas/widgets/widgetConfig.js +16 -5
  57. package/src/canvas/widgets/widgetConfig.test.js +34 -12
  58. package/src/context.jsx +141 -16
  59. package/src/hooks/useSceneData.js +4 -2
  60. package/src/story/StoryPage.jsx +117 -0
  61. package/src/story/StoryPage.module.css +18 -0
  62. package/src/vite/data-plugin.js +375 -57
  63. 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) {
@@ -555,8 +684,22 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root)
555
684
  }
556
685
  }
557
686
 
687
+ // Generate story entries (code modules with dynamic imports, not JSON data)
688
+ for (const [name, absPath] of Object.entries(index.story || {})) {
689
+ const varName = `_d${i++}`
690
+ const relModule = '/' + path.relative(root, absPath).replace(/\\/g, '/')
691
+ const storyMeta = { _storyModule: relModule }
692
+ if (storyRoutes[name]) {
693
+ storyMeta._route = storyRoutes[name]
694
+ }
695
+ declarations.push(
696
+ `const ${varName} = Object.assign(${JSON.stringify(storyMeta)}, { _storyImport: () => import(${JSON.stringify(relModule)}) })`
697
+ )
698
+ storyEntries.push(` ${JSON.stringify(name)}: ${varName}`)
699
+ }
700
+
558
701
  const imports = [`import { init } from '@dfosco/storyboard-core'`]
559
- const initCalls = [`init({ flows, objects, records, prototypes, folders, canvases })`]
702
+ const initCalls = [`init({ flows, objects, records, prototypes, folders, canvases, stories })`]
560
703
 
561
704
  // Feature flags from storyboard.config.json
562
705
  const { config } = readConfig(root)
@@ -624,22 +767,54 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root)
624
767
  `const prototypes = {\n${entries.prototype.join(',\n')}\n}`,
625
768
  `const folders = {\n${entries.folder.join(',\n')}\n}`,
626
769
  `const canvases = {\n${entries.canvas.join(',\n')}\n}`,
770
+ `const stories = {\n${storyEntries.join(',\n')}\n}`,
771
+ '',
772
+ `// Legacy basename → canonical ID aliases (only unique basenames)`,
773
+ `const canvasAliases = ${JSON.stringify(canvasAliases || {})}`,
627
774
  '',
628
775
  '// Backward-compatible alias',
629
776
  'const scenes = flows',
630
777
  '',
631
778
  initCalls.join('\n'),
632
779
  '',
633
- `export { flows, scenes, objects, records, prototypes, folders, canvases }`,
634
- `export const index = { flows, scenes, objects, records, prototypes, folders, canvases }`,
780
+ `export { flows, scenes, objects, records, prototypes, folders, canvases, canvasAliases, stories }`,
781
+ `export const index = { flows, scenes, objects, records, prototypes, folders, canvases, canvasAliases, stories }`,
635
782
  `export default index`,
783
+ '',
784
+ '// Live-patch canvas data on HMR events so SPA navigation shows fresh state',
785
+ 'if (import.meta.hot) {',
786
+ ' import.meta.hot.on("storyboard:canvas-file-changed", (data) => {',
787
+ ' if (!data) return',
788
+ ' const id = data.canvasId || data.name',
789
+ ' if (data.removed) {',
790
+ ' delete canvases[id]',
791
+ ' } else if (data.metadata) {',
792
+ ' // Merge into existing entry to preserve build-time fields (_jsxModule, _jsxImport, etc.)',
793
+ ' canvases[id] = canvases[id]',
794
+ ' ? Object.assign({}, canvases[id], data.metadata)',
795
+ ' : data.metadata',
796
+ ' }',
797
+ ' init({ flows, objects, records, prototypes, folders, canvases, stories })',
798
+ ' })',
799
+ ' import.meta.hot.on("storyboard:story-file-changed", (data) => {',
800
+ ' if (!data) return',
801
+ ' if (data.removed) {',
802
+ ' delete stories[data.name]',
803
+ ' } else {',
804
+ ' stories[data.name] = { _storyModule: data._storyModule, _route: data._route,',
805
+ ' _storyImport: () => import(/* @vite-ignore */ data._storyModule) }',
806
+ ' }',
807
+ ' init({ flows, objects, records, prototypes, folders, canvases, stories })',
808
+ ' document.dispatchEvent(new CustomEvent("storyboard:story-index-changed"))',
809
+ ' })',
810
+ '}',
636
811
  ].join('\n')
637
812
  }
638
813
 
639
814
  /**
640
815
  * Vite plugin for storyboard data discovery.
641
816
  *
642
- * - Scans the repo for *.flow.json, *.scene.json (compat), *.object.json, *.record.json, *.canvas.jsonl
817
+ * - Scans the repo for *.flow.json, *.scene.json (compat), *.object.json, *.record.json, *.canvas.jsonl, *.story.{jsx,tsx}
643
818
  * - Validates no two files share the same name+suffix (hard build error)
644
819
  * - Generates a virtual module `virtual:storyboard-data-index`
645
820
  * - Watches for file additions/removals in dev mode
@@ -655,6 +830,11 @@ export default function storyboardDataPlugin() {
655
830
  config() {
656
831
  return {
657
832
  optimizeDeps: {
833
+ // @dfosco/storyboard-react is excluded (virtual module), so Vite
834
+ // can't trace into its deps. Include the remark entry points so
835
+ // Vite pre-bundles the full chain — covers all transitive CJS
836
+ // packages (debug, extend, etc.) without whack-a-mole.
837
+ include: ['remark', 'remark-gfm', 'remark-html', 'use-sync-external-store/shim', 'use-sync-external-store/shim/with-selector'],
658
838
  exclude: ['@dfosco/storyboard-react'],
659
839
  },
660
840
  }
@@ -675,10 +855,68 @@ export default function storyboardDataPlugin() {
675
855
  },
676
856
 
677
857
  configureServer(server) {
858
+ // ── Component isolate middleware ───────────────────────────────
859
+ // Serves a minimal HTML shell for iframe-isolated component widgets.
860
+ // The iframe loads componentIsolate.jsx which reads query params
861
+ // (module, export, theme) and renders a single canvas.jsx export.
862
+ const isolateEntryPath = new URL('../canvas/componentIsolate.jsx', import.meta.url).pathname
863
+ server.middlewares.use(async (req, res, next) => {
864
+ if (!req.url) return next()
865
+ let url = req.url
866
+ const baseNoTrail = (server.config.base || '/').replace(/\/$/, '')
867
+ if (baseNoTrail && url.startsWith(baseNoTrail)) {
868
+ url = url.slice(baseNoTrail.length) || '/'
869
+ }
870
+ if (!url.startsWith('/_storyboard/canvas/isolate')) return next()
871
+
872
+ const rawHtml = [
873
+ '<!DOCTYPE html>',
874
+ '<html><head>',
875
+ '<style>html,body{margin:0;padding:0;width:100%;height:100%;background:var(--bgColor-default,transparent)}#root{width:100%;height:100%}</style>',
876
+ '</head><body>',
877
+ '<div id="root"></div>',
878
+ `<script type="module" src="/@fs${isolateEntryPath}"></script>`,
879
+ '</body></html>',
880
+ ].join('\n')
881
+
882
+ try {
883
+ const html = await server.transformIndexHtml(req.url, rawHtml)
884
+ res.writeHead(200, { 'Content-Type': 'text/html' })
885
+ res.end(html)
886
+ } catch (err) {
887
+ console.error('[storyboard] Component isolate HTML transform failed:', err)
888
+ res.writeHead(500, { 'Content-Type': 'text/plain' })
889
+ res.end('Component isolate failed')
890
+ }
891
+ })
892
+
893
+ // ── Stories list API ──────────────────────────────────────────
894
+ // Serves the list of discovered stories for the CLI and UI story picker.
895
+ server.middlewares.use(async (req, res, next) => {
896
+ if (!req.url) return next()
897
+ let url = req.url
898
+ const baseNoTrail = (server.config.base || '/').replace(/\/$/, '')
899
+ if (baseNoTrail && url.startsWith(baseNoTrail)) {
900
+ url = url.slice(baseNoTrail.length) || '/'
901
+ }
902
+ if (!url.startsWith('/_storyboard/stories/list')) return next()
903
+
904
+ if (!buildResult) buildResult = buildIndex(root)
905
+ const storyEntries = Object.entries(buildResult.index.story || {})
906
+ const storyRoutes = buildResult.storyRoutes || {}
907
+ const stories = storyEntries.map(([name]) => ({
908
+ name,
909
+ route: storyRoutes[name] || null,
910
+ }))
911
+
912
+ res.writeHead(200, { 'Content-Type': 'application/json' })
913
+ res.end(JSON.stringify({ stories }))
914
+ })
915
+
678
916
  // Watch for data file changes in dev mode
679
917
  const watcher = server.watcher
680
918
  if (!buildResult) buildResult = buildIndex(root)
681
- const knownCanvasNames = new Set(Object.keys(buildResult.index.canvas || {}))
919
+ const knownCanvasIds = new Set(Object.keys(buildResult.index.canvas || {}))
682
920
  const pendingCanvasUnlinks = new Map()
683
921
 
684
922
  const triggerFullReload = () => {
@@ -690,22 +928,50 @@ export default function storyboardDataPlugin() {
690
928
  }
691
929
  }
692
930
 
931
+ // Mark the virtual module as stale so the next page load rebuilds it,
932
+ // but do NOT trigger a full-reload (avoids losing canvas editing state).
933
+ const softInvalidate = () => {
934
+ buildResult = null
935
+ const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
936
+ if (mod) server.moduleGraph.invalidateModule(mod)
937
+ }
938
+
939
+ // Read a canvas file and build HMR metadata for the client-side listener.
940
+ const readCanvasMetadata = (filePath, parsed) => {
941
+ try {
942
+ const absPath = path.resolve(root, filePath)
943
+ const raw = fs.readFileSync(absPath, 'utf-8')
944
+ const materialized = materializeFromText(raw)
945
+ const result = { ...materialized }
946
+ // Inject _route and _folder the same way generateModule does
947
+ if (parsed.inferredRoute) result._route = parsed.inferredRoute
948
+ const folderDirMatch = path.relative(root, absPath).replace(/\\/g, '/').match(/(?:^|\/)src\/(?:prototypes|canvas)\/([^/]+)\.folder\//)
949
+ if (folderDirMatch) result._folder = folderDirMatch[1]
950
+ return result
951
+ } catch {
952
+ return null
953
+ }
954
+ }
955
+
693
956
  const invalidate = (filePath) => {
694
957
  const normalized = filePath.replace(/\\/g, '/')
695
- // Skip .canvas.jsonl content changes entirely these are mutated
696
- // at runtime by the canvas server API. A full-reload would create
697
- // a feedback loop (save → file change → reload → lose editing state).
698
- // Instead, send a custom HMR event so the active canvas page can refetch
699
- // file-backed data in place with no navigation or document reload.
958
+ // Canvas .jsonl content changes are mutated at runtime by the canvas
959
+ // server API. A full-reload would create a feedback loop (save →
960
+ // file change → reload → lose editing state). Instead, soft-invalidate
961
+ // the virtual module (so page refresh picks up changes) and send a
962
+ // custom HMR event with updated metadata so the canvas page and
963
+ // viewfinder can react in place.
700
964
  if (/\.canvas\.jsonl$/.test(normalized)) {
701
965
  const parsed = parseDataFile(filePath)
702
- if (parsed?.suffix === 'canvas' && parsed?.name) {
966
+ if (parsed?.suffix === 'canvas' && parsed?.id) {
967
+ const metadata = readCanvasMetadata(filePath, parsed)
703
968
  server.ws.send({
704
969
  type: 'custom',
705
970
  event: 'storyboard:canvas-file-changed',
706
- data: { name: parsed.name },
971
+ data: { canvasId: parsed.id, name: parsed.id, ...(metadata ? { metadata } : {}) },
707
972
  })
708
973
  }
974
+ softInvalidate()
709
975
  return
710
976
  }
711
977
 
@@ -742,53 +1008,89 @@ export default function storyboardDataPlugin() {
742
1008
  // Treat canvas add/unlink as runtime data updates and never full-reload
743
1009
  // from watcher events. Canvas pages sync from disk via custom WS events.
744
1010
  if (parsed?.suffix === 'canvas') {
745
- const name = parsed.name
1011
+ const canvasId = parsed.id || parsed.name
746
1012
  if (eventType === 'unlink') {
747
1013
  const timer = setTimeout(() => {
748
- pendingCanvasUnlinks.delete(name)
749
- knownCanvasNames.delete(name)
1014
+ pendingCanvasUnlinks.delete(canvasId)
1015
+ knownCanvasIds.delete(canvasId)
750
1016
  server.ws.send({
751
1017
  type: 'custom',
752
1018
  event: 'storyboard:canvas-file-changed',
753
- data: { name },
1019
+ data: { canvasId, name: canvasId, removed: true },
754
1020
  })
1021
+ softInvalidate()
755
1022
  }, 1500)
756
- pendingCanvasUnlinks.set(name, timer)
1023
+ pendingCanvasUnlinks.set(canvasId, timer)
757
1024
  return
758
1025
  }
759
1026
 
760
1027
  if (eventType === 'add') {
761
- const pending = pendingCanvasUnlinks.get(name)
1028
+ const metadata = readCanvasMetadata(filePath, parsed)
1029
+ const pending = pendingCanvasUnlinks.get(canvasId)
762
1030
  if (pending) {
1031
+ // unlink+add pair = in-place save (atomic write), not a real remove
763
1032
  clearTimeout(pending)
764
- pendingCanvasUnlinks.delete(name)
1033
+ pendingCanvasUnlinks.delete(canvasId)
765
1034
  server.ws.send({
766
1035
  type: 'custom',
767
1036
  event: 'storyboard:canvas-file-changed',
768
- data: { name },
1037
+ data: { canvasId, name: canvasId, ...(metadata ? { metadata } : {}) },
769
1038
  })
1039
+ softInvalidate()
770
1040
  return
771
1041
  }
772
1042
 
773
- if (knownCanvasNames.has(name)) {
1043
+ if (knownCanvasIds.has(canvasId)) {
774
1044
  server.ws.send({
775
1045
  type: 'custom',
776
1046
  event: 'storyboard:canvas-file-changed',
777
- data: { name },
1047
+ data: { canvasId, name: canvasId, ...(metadata ? { metadata } : {}) },
778
1048
  })
1049
+ softInvalidate()
779
1050
  return
780
1051
  }
781
1052
 
782
- knownCanvasNames.add(name)
1053
+ knownCanvasIds.add(canvasId)
783
1054
  server.ws.send({
784
1055
  type: 'custom',
785
1056
  event: 'storyboard:canvas-file-changed',
786
- data: { name },
1057
+ data: { canvasId, name: canvasId, ...(metadata ? { metadata } : {}) },
787
1058
  })
1059
+ softInvalidate()
788
1060
  return
789
1061
  }
790
1062
  }
791
1063
 
1064
+ // Story add/remove: soft-invalidate + custom HMR event (full-reload
1065
+ // is blocked by the canvas reload guard). The virtual module HMR
1066
+ // handler live-patches `stories` and re-runs init().
1067
+ if (parsed?.suffix === 'story') {
1068
+ softInvalidate()
1069
+ if (!buildResult) buildResult = buildIndex(root)
1070
+ const storyRoutes = buildResult.storyRoutes || {}
1071
+ const storyIndex = buildResult.index.story || {}
1072
+ const name = parsed.name
1073
+ if (eventType === 'unlink') {
1074
+ server.ws.send({
1075
+ type: 'custom',
1076
+ event: 'storyboard:story-file-changed',
1077
+ data: { name, removed: true },
1078
+ })
1079
+ } else if (eventType === 'add' && storyIndex[name]) {
1080
+ const relModule = '/' + path.relative(root, storyIndex[name]).replace(/\\/g, '/')
1081
+ server.ws.send({
1082
+ type: 'custom',
1083
+ event: 'storyboard:story-file-changed',
1084
+ data: {
1085
+ name,
1086
+ _storyModule: relModule,
1087
+ _route: storyRoutes[name] || null,
1088
+ },
1089
+ })
1090
+ }
1091
+ return
1092
+ }
1093
+
792
1094
  // Non-canvas additions/removals and folder changes update the route/data graph.
793
1095
  triggerFullReload()
794
1096
  }
@@ -819,21 +1121,37 @@ export default function storyboardDataPlugin() {
819
1121
  const normalized = ctx.file.replace(/\\/g, '/')
820
1122
  if (!/\.canvas\.jsonl$/.test(normalized)) return
821
1123
 
822
- const parsed = parseDataFile(ctx.file)
823
- if (parsed?.suffix === 'canvas' && parsed?.name) {
824
- ctx.server.ws.send({
825
- type: 'custom',
826
- event: 'storyboard:canvas-file-changed',
827
- data: { name: parsed.name },
828
- })
829
- }
830
-
831
1124
  // Prevent Vite's default fallback behavior (full page reload) for
832
- // non-module .canvas.jsonl edits. Canvas pages consume these updates
833
- // through the custom WS event and in-page refetch.
1125
+ // non-module .canvas.jsonl edits. The watcher 'change' handler
1126
+ // (invalidate) already sends the custom HMR event and soft-invalidates
1127
+ // the virtual module — no duplicate event needed here.
834
1128
  return []
835
1129
  },
836
1130
 
1131
+ // Inject __SB_BRANCHES__ into HTML so the Viewfinder branch selector works.
1132
+ // Reads .worktrees/ports.json to enumerate active worktree dev servers.
1133
+ transformIndexHtml(html, ctx) {
1134
+ // Only inject in dev mode
1135
+ if (!ctx.server) return html
1136
+
1137
+ try {
1138
+ const portsJsonPath = path.resolve(root, '.worktrees', 'ports.json')
1139
+ if (!fs.existsSync(portsJsonPath)) return html
1140
+
1141
+ const ports = JSON.parse(fs.readFileSync(portsJsonPath, 'utf-8'))
1142
+ const branches = Object.entries(ports)
1143
+ .filter(([name]) => name !== 'main')
1144
+ .map(([name, port]) => ({ branch: name, folder: `branch--${name}`, port }))
1145
+
1146
+ if (branches.length === 0) return html
1147
+
1148
+ const script = `<script>window.__SB_BRANCHES__ = ${JSON.stringify(branches)};</script>`
1149
+ return html.replace('</head>', `${script}\n</head>`)
1150
+ } catch {
1151
+ return html
1152
+ }
1153
+ },
1154
+
837
1155
  // Rebuild index on each build start
838
1156
  buildStart() {
839
1157
  buildResult = null
@@ -842,4 +1160,4 @@ export default function storyboardDataPlugin() {
842
1160
  }
843
1161
 
844
1162
  // Exported for testing
845
- export { resolveTemplateVars, computeTemplateVars }
1163
+ export { resolveTemplateVars, computeTemplateVars, parseDataFile }