@dfosco/storyboard-react 4.0.0-beta.2 → 4.0.0-beta.21

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 (44) hide show
  1. package/package.json +7 -4
  2. package/src/canvas/CanvasControls.jsx +51 -2
  3. package/src/canvas/CanvasControls.module.css +31 -0
  4. package/src/canvas/CanvasPage.dragdrop.test.jsx +346 -0
  5. package/src/canvas/CanvasPage.jsx +512 -235
  6. package/src/canvas/CanvasPage.module.css +9 -47
  7. package/src/canvas/ComponentErrorBoundary.jsx +50 -0
  8. package/src/canvas/PageSelector.jsx +102 -0
  9. package/src/canvas/PageSelector.module.css +93 -0
  10. package/src/canvas/PageSelector.test.jsx +104 -0
  11. package/src/canvas/canvasApi.js +4 -0
  12. package/src/canvas/canvasReloadGuard.js +37 -0
  13. package/src/canvas/canvasReloadGuard.test.js +27 -0
  14. package/src/canvas/componentIsolate.jsx +135 -0
  15. package/src/canvas/useCanvas.js +6 -2
  16. package/src/canvas/widgets/ComponentWidget.jsx +67 -9
  17. package/src/canvas/widgets/ComponentWidget.module.css +9 -6
  18. package/src/canvas/widgets/FigmaEmbed.jsx +26 -4
  19. package/src/canvas/widgets/FigmaEmbed.module.css +0 -7
  20. package/src/canvas/widgets/MarkdownBlock.jsx +94 -21
  21. package/src/canvas/widgets/MarkdownBlock.module.css +110 -2
  22. package/src/canvas/widgets/MarkdownBlock.test.jsx +39 -0
  23. package/src/canvas/widgets/PrototypeEmbed.jsx +196 -40
  24. package/src/canvas/widgets/PrototypeEmbed.module.css +30 -3
  25. package/src/canvas/widgets/StickyNote.module.css +5 -0
  26. package/src/canvas/widgets/StickyNote.test.jsx +9 -9
  27. package/src/canvas/widgets/StoryWidget.jsx +471 -0
  28. package/src/canvas/widgets/StoryWidget.module.css +200 -0
  29. package/src/canvas/widgets/WidgetChrome.jsx +54 -18
  30. package/src/canvas/widgets/WidgetChrome.module.css +4 -7
  31. package/src/canvas/widgets/WidgetWrapper.module.css +2 -0
  32. package/src/canvas/widgets/embedInteraction.test.jsx +155 -0
  33. package/src/canvas/widgets/embedOverlay.module.css +35 -0
  34. package/src/canvas/widgets/index.js +2 -0
  35. package/src/canvas/widgets/pasteRules.js +295 -0
  36. package/src/canvas/widgets/pasteRules.test.js +474 -0
  37. package/src/canvas/widgets/widgetConfig.js +16 -5
  38. package/src/canvas/widgets/widgetConfig.test.js +31 -9
  39. package/src/context.jsx +138 -13
  40. package/src/hooks/useSceneData.js +4 -2
  41. package/src/story/StoryPage.jsx +152 -0
  42. package/src/story/StoryPage.module.css +73 -0
  43. package/src/vite/data-plugin.js +441 -58
  44. 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,81 @@ 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
+ return { name, suffix: 'canvas', ext: 'jsonl', folder: canvasFolderName || folderName, inferredRoute, id: toCanvasId(filePath), group }
82
+ }
83
+
84
+ // Handle canvas .meta.json files
85
+ const metaMatch = base.match(/^(.+)\.meta\.json$/)
86
+ if (metaMatch) {
87
+ const normalized = filePath.replace(/\\/g, '/')
88
+ // Only handle meta files inside src/canvas/ directories
89
+ const canvasCheck = normalized.match(/(?:^|\/)src\/canvas\//)
90
+ if (!canvasCheck) return null
91
+ // Skip _-prefixed
92
+ if (metaMatch[1].startsWith('_')) return null
93
+ if (normalized.split('/').some(seg => seg.startsWith('_'))) return null
94
+ return { name: metaMatch[1], suffix: 'canvas-meta', ext: 'json', inferredRoute: null }
95
+ }
96
+
97
+ // Handle .story.jsx / .story.tsx files
98
+ const storyMatch = base.match(/^(.+)\.story\.(jsx|tsx)$/)
99
+ if (storyMatch) {
100
+ if (storyMatch[1].startsWith('_')) return null
101
+ const normalized = filePath.replace(/\\/g, '/')
102
+ if (normalized.split('/').some(seg => seg.startsWith('_'))) return null
103
+
104
+ const name = storyMatch[1]
105
+ let inferredRoute = null
106
+
107
+ // All stories route under /components/ regardless of directory location
108
+ const canvasCheck = normalized.match(/(?:^|\/)src\/canvas\//)
109
+ const componentsCheck = normalized.match(/(?:^|\/)src\/components\//)
110
+ if (canvasCheck) {
111
+ const dirPath = normalized.substring(0, normalized.lastIndexOf('/'))
112
+ const routeBase = (dirPath + '/')
113
+ .replace(/^.*?src\/canvas\//, '')
114
+ .replace(/[^/]*\.folder\/?/g, '')
115
+ .replace(/\/$/, '')
116
+ inferredRoute = '/components/' + (routeBase ? routeBase + '/' : '') + name
117
+ inferredRoute = inferredRoute.replace(/\/+/g, '/').replace(/\/$/, '') || '/components'
118
+ } else if (componentsCheck) {
119
+ const dirPath = normalized.substring(0, normalized.lastIndexOf('/'))
120
+ const routeBase = (dirPath + '/')
121
+ .replace(/^.*?src\/components\//, '')
122
+ .replace(/[^/]*\.folder\/?/g, '')
123
+ .replace(/\/$/, '')
124
+ inferredRoute = '/components/' + (routeBase ? routeBase + '/' : '') + name
125
+ inferredRoute = inferredRoute.replace(/\/+/g, '/').replace(/\/$/, '') || '/components'
126
+ }
127
+
128
+ return { name, suffix: 'story', ext: storyMatch[2], inferredRoute }
63
129
  }
64
130
 
65
131
  const match = base.match(/^(.+)\.(flow|scene|object|record|prototype|folder)\.(jsonc?)$/)
@@ -155,13 +221,103 @@ function getLastModified(root, dirPath) {
155
221
  }
156
222
  }
157
223
 
224
+ /**
225
+ * Batch-fetch git metadata (author + lastModified) for multiple files in a
226
+ * single subprocess, avoiding per-file git overhead during startup.
227
+ *
228
+ * Returns a Map<absPath, { gitAuthor: string|null, lastModified: string|null }>
229
+ */
230
+ function batchGitMetadata(root, filePaths) {
231
+ const result = new Map()
232
+ if (filePaths.length === 0) return result
233
+
234
+ // Initialize all entries
235
+ for (const fp of filePaths) {
236
+ result.set(fp, { gitAuthor: null, lastModified: null })
237
+ }
238
+
239
+ try {
240
+ // Batch lastModified: one git log call with all paths
241
+ // git log -1 gives the most recent commit touching any of these paths,
242
+ // but we need per-path data. Use --name-only to correlate.
243
+ // For efficiency, use a single git log with --format and --name-only
244
+ // that outputs one record per commit touching these files.
245
+ const allDirs = [...new Set(filePaths.map(fp => path.dirname(fp)))]
246
+ const dirsArg = allDirs.map(d => `"${d}"`).join(' ')
247
+
248
+ // Get lastModified per directory in one call using git log --format
249
+ // We output "MARKER<sep>dir<sep>date" per commit, then take the latest per dir.
250
+ const logResult = execSync(
251
+ `git log --format="%aI" --name-only -- ${dirsArg}`,
252
+ { cwd: root, encoding: 'utf-8', timeout: 10000, maxBuffer: 1024 * 1024 },
253
+ ).trim()
254
+
255
+ if (logResult) {
256
+ // Parse: alternating date lines and filename lines separated by blank lines
257
+ const blocks = logResult.split('\n\n')
258
+ const dirDates = new Map() // dir → most recent date
259
+ for (const block of blocks) {
260
+ const lines = block.split('\n').filter(Boolean)
261
+ if (lines.length < 2) continue
262
+ const date = lines[0]
263
+ for (let li = 1; li < lines.length; li++) {
264
+ const fileLine = lines[li].trim()
265
+ if (!fileLine) continue
266
+ const dir = path.dirname(path.resolve(root, fileLine))
267
+ if (!dirDates.has(dir)) {
268
+ dirDates.set(dir, date)
269
+ }
270
+ }
271
+ }
272
+ for (const fp of filePaths) {
273
+ const dir = path.dirname(fp)
274
+ const entry = result.get(fp)
275
+ if (dirDates.has(dir) && entry) {
276
+ entry.lastModified = dirDates.get(dir)
277
+ }
278
+ }
279
+ }
280
+ } catch { /* git not available or failed — leave nulls */ }
281
+
282
+ // Batch gitAuthor: use git log for each file's creation author.
283
+ // Unfortunately --follow --diff-filter=A doesn't combine well with multiple
284
+ // paths, so batch them in a single shell invocation using a for loop.
285
+ try {
286
+ const relPaths = filePaths.map(fp => path.relative(root, fp))
287
+ // Build a shell script that outputs "PATH<tab>AUTHOR" per file
288
+ const cmds = relPaths.map(rp =>
289
+ `echo -n "${rp}\\t"; git log --follow --diff-filter=A --format="%aN" -- "${rp}" | tail -1`
290
+ ).join('; ')
291
+ const authorResult = execSync(cmds, {
292
+ cwd: root, encoding: 'utf-8', timeout: 10000, shell: true, maxBuffer: 1024 * 1024,
293
+ }).trim()
294
+
295
+ if (authorResult) {
296
+ for (const line of authorResult.split('\n')) {
297
+ const tabIdx = line.indexOf('\t')
298
+ if (tabIdx < 0) continue
299
+ const relPath = line.slice(0, tabIdx)
300
+ const author = line.slice(tabIdx + 1).trim()
301
+ if (!author) continue
302
+ const absPath2 = path.resolve(root, relPath)
303
+ const entry = result.get(absPath2)
304
+ if (entry) entry.gitAuthor = author
305
+ }
306
+ }
307
+ } catch { /* git not available */ }
308
+
309
+ return result
310
+ }
311
+
158
312
  /**
159
313
  * Scan the repo for all data files, validate uniqueness, return the index.
160
314
  */
161
315
  function buildIndex(root) {
162
- const ignore = ['node_modules/**', 'dist/**', '.git/**']
316
+ const ignore = ['node_modules/**', 'dist/**', '.git/**', '.worktrees/**', 'public/**']
163
317
  const files = globSync(GLOB_PATTERN, { cwd: root, ignore, absolute: false })
164
318
  const canvasFiles = globSync(CANVAS_GLOB_PATTERN, { cwd: root, ignore, absolute: false })
319
+ const canvasMetaFiles = globSync(CANVAS_META_GLOB_PATTERN, { cwd: root, ignore, absolute: false })
320
+ const storyFiles = globSync(STORY_GLOB_PATTERN, { cwd: root, ignore, absolute: false })
165
321
 
166
322
  // Detect nested .folder/ directories (not supported)
167
323
  // Scan directories directly since empty nested folders have no data files
@@ -178,35 +334,58 @@ function buildIndex(root) {
178
334
  }
179
335
  }
180
336
 
181
- const index = { flow: {}, object: {}, record: {}, prototype: {}, folder: {}, canvas: {} }
182
- const seen = {} // "name.suffix" → absolute path (for duplicate detection)
337
+ const index = { flow: {}, object: {}, record: {}, prototype: {}, folder: {}, canvas: {}, 'canvas-meta': {}, story: {} }
338
+ const seen = {} // "name.suffix" or "id.suffix" → absolute path (for duplicate detection)
183
339
  const protoFolders = {} // prototype name → folder name (for injection)
184
340
  const flowRoutes = {} // flow name → inferred route (for _route injection)
185
341
  const canvasRoutes = {} // canvas name → inferred route
342
+ const canvasAliases = {} // basename → canonical ID (only when unique)
343
+ const canvasNameCount = {} // canvas basename → count (for ambiguity detection)
344
+ const canvasGroups = {} // canvas name → group name (shared folder prefix)
345
+ const storyRoutes = {} // story name → inferred route
186
346
 
187
- for (const relPath of [...files, ...canvasFiles]) {
347
+ for (const relPath of [...files, ...canvasFiles, ...canvasMetaFiles, ...storyFiles]) {
188
348
  const parsed = parseDataFile(relPath)
189
349
  if (!parsed) continue
190
350
 
191
- const key = `${parsed.name}.${parsed.suffix}`
351
+ // Canvas files use path-based IDs for dedup; others use basename
352
+ const dedupKey = parsed.suffix === 'canvas' && parsed.id
353
+ ? `${parsed.id}.${parsed.suffix}`
354
+ : `${parsed.name}.${parsed.suffix}`
192
355
  const absPath = path.resolve(root, relPath)
193
356
 
194
- if (seen[key]) {
357
+ if (seen[dedupKey]) {
195
358
  const hint = parsed.suffix === 'folder'
196
359
  ? ' Folder names must be unique across the project.'
360
+ : parsed.suffix === 'canvas'
361
+ ? ' Canvas IDs must be unique. Move or rename one file to resolve the collision.'
197
362
  : ' Flows, records, and objects are scoped to their prototype directory.\n' +
198
363
  ' If both files are global (outside src/prototypes/), rename one to avoid the collision.'
199
364
 
200
365
  throw new Error(
201
- `[storyboard-data] Duplicate ${parsed.suffix} "${parsed.name}"\n` +
202
- ` Found at: ${seen[key]}\n` +
366
+ `[storyboard-data] Duplicate ${parsed.suffix} "${parsed.id || parsed.name}"\n` +
367
+ ` Found at: ${seen[dedupKey]}\n` +
203
368
  ` And at: ${absPath}\n` +
204
369
  hint
205
370
  )
206
371
  }
207
372
 
208
- seen[key] = absPath
209
- index[parsed.suffix][parsed.name] = absPath
373
+ seen[dedupKey] = absPath
374
+
375
+ // Canvas: index only by canonical ID. Basename aliases go in a separate map
376
+ // so listCanvases() and viewfinder don't show duplicates.
377
+ if (parsed.suffix === 'canvas' && parsed.id) {
378
+ index.canvas[parsed.id] = absPath
379
+ // Track basename for alias resolution (only when unique)
380
+ canvasNameCount[parsed.name] = (canvasNameCount[parsed.name] || 0) + 1
381
+ if (canvasNameCount[parsed.name] === 1) {
382
+ canvasAliases[parsed.name] = parsed.id
383
+ } else {
384
+ delete canvasAliases[parsed.name]
385
+ }
386
+ } else {
387
+ index[parsed.suffix][parsed.name] = absPath
388
+ }
210
389
 
211
390
  // Track which folder a prototype belongs to
212
391
  if (parsed.suffix === 'prototype' && parsed.folder) {
@@ -218,13 +397,24 @@ function buildIndex(root) {
218
397
  flowRoutes[parsed.name] = parsed.inferredRoute
219
398
  }
220
399
 
221
- // Track inferred routes for canvases
400
+ // Track inferred routes for canvases (keyed by canonical ID)
222
401
  if (parsed.suffix === 'canvas' && parsed.inferredRoute) {
223
- canvasRoutes[parsed.name] = parsed.inferredRoute
402
+ const canvasKey = parsed.id || parsed.name
403
+ canvasRoutes[canvasKey] = parsed.inferredRoute
404
+ }
405
+
406
+ // Track canvas groups (canvases sharing a folder prefix)
407
+ if (parsed.suffix === 'canvas' && parsed.group) {
408
+ canvasGroups[parsed.name] = parsed.group
409
+ }
410
+
411
+ // Track inferred routes for stories
412
+ if (parsed.suffix === 'story' && parsed.inferredRoute) {
413
+ storyRoutes[parsed.name] = parsed.inferredRoute
224
414
  }
225
415
  }
226
416
 
227
- return { index, protoFolders, flowRoutes, canvasRoutes }
417
+ return { index, protoFolders, flowRoutes, canvasRoutes, canvasAliases, canvasGroups, storyRoutes }
228
418
  }
229
419
 
230
420
  /**
@@ -278,7 +468,7 @@ function computeTemplateVars(absPath, root) {
278
468
  */
279
469
  /**
280
470
  * Read storyboard.config.json from the project root (if it exists).
281
- * Returns the parsed config object, or null if not found.
471
+ * Returns the parsed and defaulted config object, or null if not found.
282
472
  */
283
473
  function readConfig(root) {
284
474
  const configPath = path.resolve(root, 'storyboard.config.json')
@@ -288,7 +478,7 @@ function readConfig(root) {
288
478
  const config = parseJsonc(raw, errors)
289
479
  // Treat malformed JSON (e.g. mid-edit partial saves) as missing config
290
480
  if (errors.length > 0) return { config: null, configPath }
291
- return { config, configPath }
481
+ return { config: getConfig(config), configPath }
292
482
  } catch {
293
483
  return { config: null, configPath }
294
484
  }
@@ -332,13 +522,36 @@ function readModesConfig(root) {
332
522
  return fallback
333
523
  }
334
524
 
335
- function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root) {
525
+ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes, canvasAliases, canvasGroups, storyRoutes }, root) {
336
526
  const declarations = []
337
527
  const INDEX_KEYS = ['flow', 'object', 'record', 'prototype', 'folder', 'canvas']
338
528
  const entries = { flow: [], object: [], record: [], prototype: [], folder: [], canvas: [] }
529
+ const storyEntries = [] // handled separately (code modules, not JSON data)
339
530
  const resolvedFlowRoutes = {} // flow name → resolved route (for multi-flow logging)
340
531
  let i = 0
341
532
 
533
+ // Batch-fetch git metadata for all prototype + canvas files in 1-2 subprocesses
534
+ const gitPaths = [
535
+ ...Object.values(index.prototype || {}),
536
+ ...Object.values(index.canvas || {}),
537
+ ]
538
+ const gitMeta = batchGitMetadata(root, gitPaths)
539
+
540
+ // Read canvas-meta files and build a directory-based lookup
541
+ const canvasMetaByDir = {}
542
+ for (const [, absPath] of Object.entries(index['canvas-meta'] || {})) {
543
+ try {
544
+ const raw = fs.readFileSync(absPath, 'utf-8')
545
+ const parsed = parseJsonc(raw)
546
+ if (parsed) {
547
+ // Key by the parent directory path relative to src/canvas/
548
+ const dirPath = path.dirname(absPath).replace(/\\/g, '/')
549
+ const canvasRelDir = dirPath.replace(/^.*?src\/canvas\//, '')
550
+ canvasMetaByDir[canvasRelDir] = parsed
551
+ }
552
+ } catch { /* skip invalid meta files */ }
553
+ }
554
+
342
555
  for (const suffix of INDEX_KEYS) {
343
556
  for (const [name, absPath] of Object.entries(index[suffix])) {
344
557
  const varName = `_d${i++}`
@@ -349,18 +562,17 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root)
349
562
 
350
563
  // Auto-fill gitAuthor for prototype metadata from git history
351
564
  if (suffix === 'prototype' && parsed && !parsed.gitAuthor) {
352
- const gitAuthor = getGitAuthor(root, absPath)
353
- if (gitAuthor) {
354
- parsed = { ...parsed, gitAuthor }
565
+ const meta = gitMeta.get(absPath)
566
+ if (meta?.gitAuthor) {
567
+ parsed = { ...parsed, gitAuthor: meta.gitAuthor }
355
568
  }
356
569
  }
357
570
 
358
571
  // Auto-fill lastModified from git history for prototypes
359
572
  if (suffix === 'prototype' && parsed) {
360
- const protoDir = path.dirname(absPath)
361
- const lastModified = getLastModified(root, protoDir)
362
- if (lastModified) {
363
- parsed = { ...parsed, lastModified }
573
+ const meta = gitMeta.get(absPath)
574
+ if (meta?.lastModified) {
575
+ parsed = { ...parsed, lastModified: meta.lastModified }
364
576
  }
365
577
  }
366
578
 
@@ -399,17 +611,24 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root)
399
611
 
400
612
  // Auto-fill gitAuthor for canvas metadata from git history
401
613
  if (suffix === 'canvas' && parsed && !parsed.gitAuthor) {
402
- const gitAuthor = getGitAuthor(root, absPath)
403
- if (gitAuthor) {
404
- parsed = { ...parsed, gitAuthor }
614
+ const meta = gitMeta.get(absPath)
615
+ if (meta?.gitAuthor) {
616
+ parsed = { ...parsed, gitAuthor: meta.gitAuthor }
405
617
  }
406
618
  }
407
619
 
408
- // Inject inferred route and resolve JSX companion for canvases
620
+ // Inject inferred route, group, and resolve JSX companion for canvases
409
621
  if (suffix === 'canvas') {
410
622
  if (canvasRoutes[name]) {
411
623
  parsed = { ...parsed, _route: canvasRoutes[name] }
412
624
  }
625
+ if (canvasGroups[name]) {
626
+ parsed = { ...parsed, _group: canvasGroups[name] }
627
+ }
628
+ // Inject canvas folder metadata from .meta.json
629
+ if (canvasGroups[name] && canvasMetaByDir[canvasGroups[name]]) {
630
+ parsed = { ...parsed, _canvasMeta: canvasMetaByDir[canvasGroups[name]] }
631
+ }
413
632
  // Inject folder association
414
633
  const folderDirMatch = path.relative(root, absPath).replace(/\\/g, '/').match(/(?:^|\/)src\/(?:prototypes|canvas)\/([^/]+)\.folder\//)
415
634
  if (folderDirMatch) {
@@ -461,8 +680,22 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root)
461
680
  }
462
681
  }
463
682
 
683
+ // Generate story entries (code modules with dynamic imports, not JSON data)
684
+ for (const [name, absPath] of Object.entries(index.story || {})) {
685
+ const varName = `_d${i++}`
686
+ const relModule = '/' + path.relative(root, absPath).replace(/\\/g, '/')
687
+ const storyMeta = { _storyModule: relModule }
688
+ if (storyRoutes[name]) {
689
+ storyMeta._route = storyRoutes[name]
690
+ }
691
+ declarations.push(
692
+ `const ${varName} = Object.assign(${JSON.stringify(storyMeta)}, { _storyImport: () => import(${JSON.stringify(relModule)}) })`
693
+ )
694
+ storyEntries.push(` ${JSON.stringify(name)}: ${varName}`)
695
+ }
696
+
464
697
  const imports = [`import { init } from '@dfosco/storyboard-core'`]
465
- const initCalls = [`init({ flows, objects, records, prototypes, folders, canvases })`]
698
+ const initCalls = [`init({ flows, objects, records, prototypes, folders, canvases, stories })`]
466
699
 
467
700
  // Feature flags from storyboard.config.json
468
701
  const { config } = readConfig(root)
@@ -530,22 +763,53 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root)
530
763
  `const prototypes = {\n${entries.prototype.join(',\n')}\n}`,
531
764
  `const folders = {\n${entries.folder.join(',\n')}\n}`,
532
765
  `const canvases = {\n${entries.canvas.join(',\n')}\n}`,
766
+ `const stories = {\n${storyEntries.join(',\n')}\n}`,
767
+ '',
768
+ `// Legacy basename → canonical ID aliases (only unique basenames)`,
769
+ `const canvasAliases = ${JSON.stringify(canvasAliases || {})}`,
533
770
  '',
534
771
  '// Backward-compatible alias',
535
772
  'const scenes = flows',
536
773
  '',
537
774
  initCalls.join('\n'),
538
775
  '',
539
- `export { flows, scenes, objects, records, prototypes, folders, canvases }`,
540
- `export const index = { flows, scenes, objects, records, prototypes, folders, canvases }`,
776
+ `export { flows, scenes, objects, records, prototypes, folders, canvases, canvasAliases, stories }`,
777
+ `export const index = { flows, scenes, objects, records, prototypes, folders, canvases, canvasAliases, stories }`,
541
778
  `export default index`,
779
+ '',
780
+ '// Live-patch canvas data on HMR events so SPA navigation shows fresh state',
781
+ 'if (import.meta.hot) {',
782
+ ' import.meta.hot.on("storyboard:canvas-file-changed", (data) => {',
783
+ ' if (!data) return',
784
+ ' if (data.removed) {',
785
+ ' delete canvases[data.name]',
786
+ ' } else if (data.metadata) {',
787
+ ' // Merge into existing entry to preserve build-time fields (_jsxModule, _jsxImport, etc.)',
788
+ ' canvases[data.name] = canvases[data.name]',
789
+ ' ? Object.assign({}, canvases[data.name], data.metadata)',
790
+ ' : data.metadata',
791
+ ' }',
792
+ ' init({ flows, objects, records, prototypes, folders, canvases, stories })',
793
+ ' })',
794
+ ' import.meta.hot.on("storyboard:story-file-changed", (data) => {',
795
+ ' if (!data) return',
796
+ ' if (data.removed) {',
797
+ ' delete stories[data.name]',
798
+ ' } else {',
799
+ ' stories[data.name] = { _storyModule: data._storyModule, _route: data._route,',
800
+ ' _storyImport: () => import(/* @vite-ignore */ data._storyModule) }',
801
+ ' }',
802
+ ' init({ flows, objects, records, prototypes, folders, canvases, stories })',
803
+ ' document.dispatchEvent(new CustomEvent("storyboard:story-index-changed"))',
804
+ ' })',
805
+ '}',
542
806
  ].join('\n')
543
807
  }
544
808
 
545
809
  /**
546
810
  * Vite plugin for storyboard data discovery.
547
811
  *
548
- * - Scans the repo for *.flow.json, *.scene.json (compat), *.object.json, *.record.json, *.canvas.jsonl
812
+ * - Scans the repo for *.flow.json, *.scene.json (compat), *.object.json, *.record.json, *.canvas.jsonl, *.story.{jsx,tsx}
549
813
  * - Validates no two files share the same name+suffix (hard build error)
550
814
  * - Generates a virtual module `virtual:storyboard-data-index`
551
815
  * - Watches for file additions/removals in dev mode
@@ -561,6 +825,11 @@ export default function storyboardDataPlugin() {
561
825
  config() {
562
826
  return {
563
827
  optimizeDeps: {
828
+ // @dfosco/storyboard-react is excluded (virtual module), so Vite
829
+ // can't trace into its deps. Include the remark entry points so
830
+ // Vite pre-bundles the full chain — covers all transitive CJS
831
+ // packages (debug, extend, etc.) without whack-a-mole.
832
+ include: ['remark', 'remark-gfm', 'remark-html'],
564
833
  exclude: ['@dfosco/storyboard-react'],
565
834
  },
566
835
  }
@@ -581,6 +850,64 @@ export default function storyboardDataPlugin() {
581
850
  },
582
851
 
583
852
  configureServer(server) {
853
+ // ── Component isolate middleware ───────────────────────────────
854
+ // Serves a minimal HTML shell for iframe-isolated component widgets.
855
+ // The iframe loads componentIsolate.jsx which reads query params
856
+ // (module, export, theme) and renders a single canvas.jsx export.
857
+ const isolateEntryPath = new URL('../canvas/componentIsolate.jsx', import.meta.url).pathname
858
+ server.middlewares.use(async (req, res, next) => {
859
+ if (!req.url) return next()
860
+ let url = req.url
861
+ const baseNoTrail = (server.config.base || '/').replace(/\/$/, '')
862
+ if (baseNoTrail && url.startsWith(baseNoTrail)) {
863
+ url = url.slice(baseNoTrail.length) || '/'
864
+ }
865
+ if (!url.startsWith('/_storyboard/canvas/isolate')) return next()
866
+
867
+ const rawHtml = [
868
+ '<!DOCTYPE html>',
869
+ '<html><head>',
870
+ '<style>html,body{margin:0;padding:0;width:100%;height:100%;background:var(--bgColor-default,transparent)}#root{width:100%;height:100%}</style>',
871
+ '</head><body>',
872
+ '<div id="root"></div>',
873
+ `<script type="module" src="/@fs${isolateEntryPath}"></script>`,
874
+ '</body></html>',
875
+ ].join('\n')
876
+
877
+ try {
878
+ const html = await server.transformIndexHtml(req.url, rawHtml)
879
+ res.writeHead(200, { 'Content-Type': 'text/html' })
880
+ res.end(html)
881
+ } catch (err) {
882
+ console.error('[storyboard] Component isolate HTML transform failed:', err)
883
+ res.writeHead(500, { 'Content-Type': 'text/plain' })
884
+ res.end('Component isolate failed')
885
+ }
886
+ })
887
+
888
+ // ── Stories list API ──────────────────────────────────────────
889
+ // Serves the list of discovered stories for the CLI and UI story picker.
890
+ server.middlewares.use(async (req, res, next) => {
891
+ if (!req.url) return next()
892
+ let url = req.url
893
+ const baseNoTrail = (server.config.base || '/').replace(/\/$/, '')
894
+ if (baseNoTrail && url.startsWith(baseNoTrail)) {
895
+ url = url.slice(baseNoTrail.length) || '/'
896
+ }
897
+ if (!url.startsWith('/_storyboard/stories/list')) return next()
898
+
899
+ if (!buildResult) buildResult = buildIndex(root)
900
+ const storyEntries = Object.entries(buildResult.index.story || {})
901
+ const storyRoutes = buildResult.storyRoutes || {}
902
+ const stories = storyEntries.map(([name]) => ({
903
+ name,
904
+ route: storyRoutes[name] || null,
905
+ }))
906
+
907
+ res.writeHead(200, { 'Content-Type': 'application/json' })
908
+ res.end(JSON.stringify({ stories }))
909
+ })
910
+
584
911
  // Watch for data file changes in dev mode
585
912
  const watcher = server.watcher
586
913
  if (!buildResult) buildResult = buildIndex(root)
@@ -596,22 +923,50 @@ export default function storyboardDataPlugin() {
596
923
  }
597
924
  }
598
925
 
926
+ // Mark the virtual module as stale so the next page load rebuilds it,
927
+ // but do NOT trigger a full-reload (avoids losing canvas editing state).
928
+ const softInvalidate = () => {
929
+ buildResult = null
930
+ const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
931
+ if (mod) server.moduleGraph.invalidateModule(mod)
932
+ }
933
+
934
+ // Read a canvas file and build HMR metadata for the client-side listener.
935
+ const readCanvasMetadata = (filePath, parsed) => {
936
+ try {
937
+ const absPath = path.resolve(root, filePath)
938
+ const raw = fs.readFileSync(absPath, 'utf-8')
939
+ const materialized = materializeFromText(raw)
940
+ const result = { ...materialized }
941
+ // Inject _route and _folder the same way generateModule does
942
+ if (parsed.inferredRoute) result._route = parsed.inferredRoute
943
+ const folderDirMatch = path.relative(root, absPath).replace(/\\/g, '/').match(/(?:^|\/)src\/(?:prototypes|canvas)\/([^/]+)\.folder\//)
944
+ if (folderDirMatch) result._folder = folderDirMatch[1]
945
+ return result
946
+ } catch {
947
+ return null
948
+ }
949
+ }
950
+
599
951
  const invalidate = (filePath) => {
600
952
  const normalized = filePath.replace(/\\/g, '/')
601
- // Skip .canvas.jsonl content changes entirely these are mutated
602
- // at runtime by the canvas server API. A full-reload would create
603
- // a feedback loop (save → file change → reload → lose editing state).
604
- // Instead, send a custom HMR event so the active canvas page can refetch
605
- // file-backed data in place with no navigation or document reload.
953
+ // Canvas .jsonl content changes are mutated at runtime by the canvas
954
+ // server API. A full-reload would create a feedback loop (save →
955
+ // file change → reload → lose editing state). Instead, soft-invalidate
956
+ // the virtual module (so page refresh picks up changes) and send a
957
+ // custom HMR event with updated metadata so the canvas page and
958
+ // viewfinder can react in place.
606
959
  if (/\.canvas\.jsonl$/.test(normalized)) {
607
960
  const parsed = parseDataFile(filePath)
608
961
  if (parsed?.suffix === 'canvas' && parsed?.name) {
962
+ const metadata = readCanvasMetadata(filePath, parsed)
609
963
  server.ws.send({
610
964
  type: 'custom',
611
965
  event: 'storyboard:canvas-file-changed',
612
- data: { name: parsed.name },
966
+ data: { name: parsed.name, ...(metadata ? { metadata } : {}) },
613
967
  })
614
968
  }
969
+ softInvalidate()
615
970
  return
616
971
  }
617
972
 
@@ -656,23 +1011,27 @@ export default function storyboardDataPlugin() {
656
1011
  server.ws.send({
657
1012
  type: 'custom',
658
1013
  event: 'storyboard:canvas-file-changed',
659
- data: { name },
1014
+ data: { name, removed: true },
660
1015
  })
1016
+ softInvalidate()
661
1017
  }, 1500)
662
1018
  pendingCanvasUnlinks.set(name, timer)
663
1019
  return
664
1020
  }
665
1021
 
666
1022
  if (eventType === 'add') {
1023
+ const metadata = readCanvasMetadata(filePath, parsed)
667
1024
  const pending = pendingCanvasUnlinks.get(name)
668
1025
  if (pending) {
1026
+ // unlink+add pair = in-place save (atomic write), not a real remove
669
1027
  clearTimeout(pending)
670
1028
  pendingCanvasUnlinks.delete(name)
671
1029
  server.ws.send({
672
1030
  type: 'custom',
673
1031
  event: 'storyboard:canvas-file-changed',
674
- data: { name },
1032
+ data: { name, ...(metadata ? { metadata } : {}) },
675
1033
  })
1034
+ softInvalidate()
676
1035
  return
677
1036
  }
678
1037
 
@@ -680,8 +1039,9 @@ export default function storyboardDataPlugin() {
680
1039
  server.ws.send({
681
1040
  type: 'custom',
682
1041
  event: 'storyboard:canvas-file-changed',
683
- data: { name },
1042
+ data: { name, ...(metadata ? { metadata } : {}) },
684
1043
  })
1044
+ softInvalidate()
685
1045
  return
686
1046
  }
687
1047
 
@@ -689,12 +1049,43 @@ export default function storyboardDataPlugin() {
689
1049
  server.ws.send({
690
1050
  type: 'custom',
691
1051
  event: 'storyboard:canvas-file-changed',
692
- data: { name },
1052
+ data: { name, ...(metadata ? { metadata } : {}) },
693
1053
  })
1054
+ softInvalidate()
694
1055
  return
695
1056
  }
696
1057
  }
697
1058
 
1059
+ // Story add/remove: soft-invalidate + custom HMR event (full-reload
1060
+ // is blocked by the canvas reload guard). The virtual module HMR
1061
+ // handler live-patches `stories` and re-runs init().
1062
+ if (parsed?.suffix === 'story') {
1063
+ softInvalidate()
1064
+ if (!buildResult) buildResult = buildIndex(root)
1065
+ const storyRoutes = buildResult.storyRoutes || {}
1066
+ const storyIndex = buildResult.index.story || {}
1067
+ const name = parsed.name
1068
+ if (eventType === 'unlink') {
1069
+ server.ws.send({
1070
+ type: 'custom',
1071
+ event: 'storyboard:story-file-changed',
1072
+ data: { name, removed: true },
1073
+ })
1074
+ } else if (eventType === 'add' && storyIndex[name]) {
1075
+ const relModule = '/' + path.relative(root, storyIndex[name]).replace(/\\/g, '/')
1076
+ server.ws.send({
1077
+ type: 'custom',
1078
+ event: 'storyboard:story-file-changed',
1079
+ data: {
1080
+ name,
1081
+ _storyModule: relModule,
1082
+ _route: storyRoutes[name] || null,
1083
+ },
1084
+ })
1085
+ }
1086
+ return
1087
+ }
1088
+
698
1089
  // Non-canvas additions/removals and folder changes update the route/data graph.
699
1090
  triggerFullReload()
700
1091
  }
@@ -725,18 +1116,10 @@ export default function storyboardDataPlugin() {
725
1116
  const normalized = ctx.file.replace(/\\/g, '/')
726
1117
  if (!/\.canvas\.jsonl$/.test(normalized)) return
727
1118
 
728
- const parsed = parseDataFile(ctx.file)
729
- if (parsed?.suffix === 'canvas' && parsed?.name) {
730
- ctx.server.ws.send({
731
- type: 'custom',
732
- event: 'storyboard:canvas-file-changed',
733
- data: { name: parsed.name },
734
- })
735
- }
736
-
737
1119
  // Prevent Vite's default fallback behavior (full page reload) for
738
- // non-module .canvas.jsonl edits. Canvas pages consume these updates
739
- // through the custom WS event and in-page refetch.
1120
+ // non-module .canvas.jsonl edits. The watcher 'change' handler
1121
+ // (invalidate) already sends the custom HMR event and soft-invalidates
1122
+ // the virtual module — no duplicate event needed here.
740
1123
  return []
741
1124
  },
742
1125
 
@@ -748,4 +1131,4 @@ export default function storyboardDataPlugin() {
748
1131
  }
749
1132
 
750
1133
  // Exported for testing
751
- export { resolveTemplateVars, computeTemplateVars }
1134
+ export { resolveTemplateVars, computeTemplateVars, parseDataFile }