@dfosco/storyboard-react 4.0.0-beta.3 → 4.0.0-beta.31

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 +7 -4
  2. package/src/canvas/CanvasControls.jsx +51 -2
  3. package/src/canvas/CanvasControls.module.css +31 -0
  4. package/src/canvas/CanvasPage.bridge.test.jsx +95 -10
  5. package/src/canvas/CanvasPage.dragdrop.test.jsx +346 -0
  6. package/src/canvas/CanvasPage.jsx +790 -302
  7. package/src/canvas/CanvasPage.module.css +70 -47
  8. package/src/canvas/CanvasPage.multiselect.test.jsx +13 -11
  9. package/src/canvas/CanvasToolbar.jsx +2 -2
  10. package/src/canvas/ComponentErrorBoundary.jsx +50 -0
  11. package/src/canvas/PageSelector.jsx +102 -0
  12. package/src/canvas/PageSelector.module.css +93 -0
  13. package/src/canvas/PageSelector.test.jsx +104 -0
  14. package/src/canvas/canvasApi.js +22 -8
  15. package/src/canvas/canvasReloadGuard.js +37 -0
  16. package/src/canvas/canvasReloadGuard.test.js +27 -0
  17. package/src/canvas/componentIsolate.jsx +135 -0
  18. package/src/canvas/useCanvas.js +15 -10
  19. package/src/canvas/widgets/CodePenEmbed.jsx +292 -0
  20. package/src/canvas/widgets/CodePenEmbed.module.css +161 -0
  21. package/src/canvas/widgets/ComponentWidget.jsx +82 -9
  22. package/src/canvas/widgets/ComponentWidget.module.css +14 -6
  23. package/src/canvas/widgets/FigmaEmbed.jsx +110 -24
  24. package/src/canvas/widgets/FigmaEmbed.module.css +21 -7
  25. package/src/canvas/widgets/LinkPreview.jsx +247 -18
  26. package/src/canvas/widgets/LinkPreview.module.css +349 -8
  27. package/src/canvas/widgets/LinkPreview.test.jsx +71 -0
  28. package/src/canvas/widgets/MarkdownBlock.jsx +95 -21
  29. package/src/canvas/widgets/MarkdownBlock.module.css +133 -2
  30. package/src/canvas/widgets/MarkdownBlock.test.jsx +39 -0
  31. package/src/canvas/widgets/PrototypeEmbed.jsx +319 -70
  32. package/src/canvas/widgets/PrototypeEmbed.module.css +74 -4
  33. package/src/canvas/widgets/StickyNote.module.css +5 -0
  34. package/src/canvas/widgets/StickyNote.test.jsx +9 -9
  35. package/src/canvas/widgets/StoryWidget.jsx +512 -0
  36. package/src/canvas/widgets/StoryWidget.module.css +211 -0
  37. package/src/canvas/widgets/WidgetChrome.jsx +76 -20
  38. package/src/canvas/widgets/WidgetChrome.module.css +4 -7
  39. package/src/canvas/widgets/WidgetWrapper.module.css +2 -0
  40. package/src/canvas/widgets/codepenUrl.js +75 -0
  41. package/src/canvas/widgets/codepenUrl.test.js +76 -0
  42. package/src/canvas/widgets/embedInteraction.test.jsx +235 -0
  43. package/src/canvas/widgets/embedOverlay.module.css +35 -0
  44. package/src/canvas/widgets/embedTheme.js +56 -0
  45. package/src/canvas/widgets/githubUrl.js +82 -0
  46. package/src/canvas/widgets/githubUrl.test.js +74 -0
  47. package/src/canvas/widgets/iframeDevLogs.js +49 -0
  48. package/src/canvas/widgets/iframeDevLogs.test.jsx +81 -0
  49. package/src/canvas/widgets/index.js +4 -0
  50. package/src/canvas/widgets/pasteRules.js +295 -0
  51. package/src/canvas/widgets/pasteRules.test.js +474 -0
  52. package/src/canvas/widgets/refreshQueue.js +108 -0
  53. package/src/canvas/widgets/snapshotDisplay.test.jsx +259 -0
  54. package/src/canvas/widgets/useSnapshotCapture.js +157 -0
  55. package/src/canvas/widgets/useSnapshotCapture.test.jsx +164 -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 +458 -71
  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?)$/)
@@ -155,14 +223,103 @@ function getLastModified(root, dirPath) {
155
223
  }
156
224
  }
157
225
 
226
+ /**
227
+ * Batch-fetch git metadata (author + lastModified) for multiple files in a
228
+ * single subprocess, avoiding per-file git overhead during startup.
229
+ *
230
+ * Returns a Map<absPath, { gitAuthor: string|null, lastModified: string|null }>
231
+ */
232
+ function batchGitMetadata(root, filePaths) {
233
+ const result = new Map()
234
+ if (filePaths.length === 0) return result
235
+
236
+ // Initialize all entries
237
+ for (const fp of filePaths) {
238
+ result.set(fp, { gitAuthor: null, lastModified: null })
239
+ }
240
+
241
+ try {
242
+ // Batch lastModified: one git log call with all paths
243
+ // git log -1 gives the most recent commit touching any of these paths,
244
+ // but we need per-path data. Use --name-only to correlate.
245
+ // For efficiency, use a single git log with --format and --name-only
246
+ // that outputs one record per commit touching these files.
247
+ const allDirs = [...new Set(filePaths.map(fp => path.dirname(fp)))]
248
+ const dirsArg = allDirs.map(d => `"${d}"`).join(' ')
249
+
250
+ // Get lastModified per directory in one call using git log --format
251
+ // We output "MARKER<sep>dir<sep>date" per commit, then take the latest per dir.
252
+ const logResult = execSync(
253
+ `git log --format="%aI" --name-only -- ${dirsArg}`,
254
+ { cwd: root, encoding: 'utf-8', timeout: 10000, maxBuffer: 1024 * 1024 },
255
+ ).trim()
256
+
257
+ if (logResult) {
258
+ // Parse: alternating date lines and filename lines separated by blank lines
259
+ const blocks = logResult.split('\n\n')
260
+ const dirDates = new Map() // dir → most recent date
261
+ for (const block of blocks) {
262
+ const lines = block.split('\n').filter(Boolean)
263
+ if (lines.length < 2) continue
264
+ const date = lines[0]
265
+ for (let li = 1; li < lines.length; li++) {
266
+ const fileLine = lines[li].trim()
267
+ if (!fileLine) continue
268
+ const dir = path.dirname(path.resolve(root, fileLine))
269
+ if (!dirDates.has(dir)) {
270
+ dirDates.set(dir, date)
271
+ }
272
+ }
273
+ }
274
+ for (const fp of filePaths) {
275
+ const dir = path.dirname(fp)
276
+ const entry = result.get(fp)
277
+ if (dirDates.has(dir) && entry) {
278
+ entry.lastModified = dirDates.get(dir)
279
+ }
280
+ }
281
+ }
282
+ } catch { /* git not available or failed — leave nulls */ }
283
+
284
+ // Batch gitAuthor: use git log for each file's creation author.
285
+ // Unfortunately --follow --diff-filter=A doesn't combine well with multiple
286
+ // paths, so batch them in a single shell invocation using a for loop.
287
+ try {
288
+ const relPaths = filePaths.map(fp => path.relative(root, fp))
289
+ // Build a shell script that outputs "PATH<tab>AUTHOR" per file
290
+ const cmds = relPaths.map(rp =>
291
+ `echo -n "${rp}\\t"; git log --follow --diff-filter=A --format="%aN" -- "${rp}" | tail -1`
292
+ ).join('; ')
293
+ const authorResult = execSync(cmds, {
294
+ cwd: root, encoding: 'utf-8', timeout: 10000, shell: true, maxBuffer: 1024 * 1024,
295
+ }).trim()
296
+
297
+ if (authorResult) {
298
+ for (const line of authorResult.split('\n')) {
299
+ const tabIdx = line.indexOf('\t')
300
+ if (tabIdx < 0) continue
301
+ const relPath = line.slice(0, tabIdx)
302
+ const author = line.slice(tabIdx + 1).trim()
303
+ if (!author) continue
304
+ const absPath2 = path.resolve(root, relPath)
305
+ const entry = result.get(absPath2)
306
+ if (entry) entry.gitAuthor = author
307
+ }
308
+ }
309
+ } catch { /* git not available */ }
310
+
311
+ return result
312
+ }
313
+
158
314
  /**
159
315
  * Scan the repo for all data files, validate uniqueness, return the index.
160
316
  */
161
317
  function buildIndex(root) {
162
- const ignore = ['node_modules/**', 'dist/**', '.git/**']
163
- // Scope to src/ all data files live there (avoids walking .worktrees/, public/, etc.)
164
- const files = globSync(`src/${GLOB_PATTERN}`, { cwd: root, ignore, absolute: false })
165
- const canvasFiles = globSync(`src/${CANVAS_GLOB_PATTERN}`, { cwd: root, ignore, absolute: false })
318
+ const ignore = ['node_modules/**', 'dist/**', '.git/**', '.worktrees/**', 'public/**']
319
+ const files = globSync(GLOB_PATTERN, { cwd: root, ignore, absolute: false })
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 })
166
323
 
167
324
  // Detect nested .folder/ directories (not supported)
168
325
  // Scan directories directly since empty nested folders have no data files
@@ -179,35 +336,58 @@ function buildIndex(root) {
179
336
  }
180
337
  }
181
338
 
182
- const index = { flow: {}, object: {}, record: {}, prototype: {}, folder: {}, canvas: {} }
183
- 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)
184
341
  const protoFolders = {} // prototype name → folder name (for injection)
185
342
  const flowRoutes = {} // flow name → inferred route (for _route injection)
186
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
187
348
 
188
- for (const relPath of [...files, ...canvasFiles]) {
349
+ for (const relPath of [...files, ...canvasFiles, ...canvasMetaFiles, ...storyFiles]) {
189
350
  const parsed = parseDataFile(relPath)
190
351
  if (!parsed) continue
191
352
 
192
- 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}`
193
357
  const absPath = path.resolve(root, relPath)
194
358
 
195
- if (seen[key]) {
359
+ if (seen[dedupKey]) {
196
360
  const hint = parsed.suffix === 'folder'
197
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.'
198
364
  : ' Flows, records, and objects are scoped to their prototype directory.\n' +
199
365
  ' If both files are global (outside src/prototypes/), rename one to avoid the collision.'
200
366
 
201
367
  throw new Error(
202
- `[storyboard-data] Duplicate ${parsed.suffix} "${parsed.name}"\n` +
203
- ` Found at: ${seen[key]}\n` +
368
+ `[storyboard-data] Duplicate ${parsed.suffix} "${parsed.id || parsed.name}"\n` +
369
+ ` Found at: ${seen[dedupKey]}\n` +
204
370
  ` And at: ${absPath}\n` +
205
371
  hint
206
372
  )
207
373
  }
208
374
 
209
- seen[key] = absPath
210
- 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
+ }
211
391
 
212
392
  // Track which folder a prototype belongs to
213
393
  if (parsed.suffix === 'prototype' && parsed.folder) {
@@ -219,13 +399,26 @@ function buildIndex(root) {
219
399
  flowRoutes[parsed.name] = parsed.inferredRoute
220
400
  }
221
401
 
222
- // Track inferred routes for canvases
402
+ // Track inferred routes for canvases (keyed by canonical ID)
223
403
  if (parsed.suffix === 'canvas' && parsed.inferredRoute) {
224
- 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
225
418
  }
226
419
  }
227
420
 
228
- return { index, protoFolders, flowRoutes, canvasRoutes }
421
+ return { index, protoFolders, flowRoutes, canvasRoutes, canvasAliases, canvasGroups, storyRoutes }
229
422
  }
230
423
 
231
424
  /**
@@ -279,7 +472,7 @@ function computeTemplateVars(absPath, root) {
279
472
  */
280
473
  /**
281
474
  * Read storyboard.config.json from the project root (if it exists).
282
- * Returns the parsed config object, or null if not found.
475
+ * Returns the parsed and defaulted config object, or null if not found.
283
476
  */
284
477
  function readConfig(root) {
285
478
  const configPath = path.resolve(root, 'storyboard.config.json')
@@ -289,7 +482,7 @@ function readConfig(root) {
289
482
  const config = parseJsonc(raw, errors)
290
483
  // Treat malformed JSON (e.g. mid-edit partial saves) as missing config
291
484
  if (errors.length > 0) return { config: null, configPath }
292
- return { config, configPath }
485
+ return { config: getConfig(config), configPath }
293
486
  } catch {
294
487
  return { config: null, configPath }
295
488
  }
@@ -333,13 +526,36 @@ function readModesConfig(root) {
333
526
  return fallback
334
527
  }
335
528
 
336
- function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root) {
529
+ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes, canvasAliases, canvasGroups, storyRoutes }, root) {
337
530
  const declarations = []
338
531
  const INDEX_KEYS = ['flow', 'object', 'record', 'prototype', 'folder', 'canvas']
339
532
  const entries = { flow: [], object: [], record: [], prototype: [], folder: [], canvas: [] }
533
+ const storyEntries = [] // handled separately (code modules, not JSON data)
340
534
  const resolvedFlowRoutes = {} // flow name → resolved route (for multi-flow logging)
341
535
  let i = 0
342
536
 
537
+ // Batch-fetch git metadata for all prototype + canvas files in 1-2 subprocesses
538
+ const gitPaths = [
539
+ ...Object.values(index.prototype || {}),
540
+ ...Object.values(index.canvas || {}),
541
+ ]
542
+ const gitMeta = batchGitMetadata(root, gitPaths)
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
+
343
559
  for (const suffix of INDEX_KEYS) {
344
560
  for (const [name, absPath] of Object.entries(index[suffix])) {
345
561
  const varName = `_d${i++}`
@@ -350,18 +566,17 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root)
350
566
 
351
567
  // Auto-fill gitAuthor for prototype metadata from git history
352
568
  if (suffix === 'prototype' && parsed && !parsed.gitAuthor) {
353
- const gitAuthor = getGitAuthor(root, absPath)
354
- if (gitAuthor) {
355
- parsed = { ...parsed, gitAuthor }
569
+ const meta = gitMeta.get(absPath)
570
+ if (meta?.gitAuthor) {
571
+ parsed = { ...parsed, gitAuthor: meta.gitAuthor }
356
572
  }
357
573
  }
358
574
 
359
575
  // Auto-fill lastModified from git history for prototypes
360
576
  if (suffix === 'prototype' && parsed) {
361
- const protoDir = path.dirname(absPath)
362
- const lastModified = getLastModified(root, protoDir)
363
- if (lastModified) {
364
- parsed = { ...parsed, lastModified }
577
+ const meta = gitMeta.get(absPath)
578
+ if (meta?.lastModified) {
579
+ parsed = { ...parsed, lastModified: meta.lastModified }
365
580
  }
366
581
  }
367
582
 
@@ -400,17 +615,24 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root)
400
615
 
401
616
  // Auto-fill gitAuthor for canvas metadata from git history
402
617
  if (suffix === 'canvas' && parsed && !parsed.gitAuthor) {
403
- const gitAuthor = getGitAuthor(root, absPath)
404
- if (gitAuthor) {
405
- parsed = { ...parsed, gitAuthor }
618
+ const meta = gitMeta.get(absPath)
619
+ if (meta?.gitAuthor) {
620
+ parsed = { ...parsed, gitAuthor: meta.gitAuthor }
406
621
  }
407
622
  }
408
623
 
409
- // Inject inferred route and resolve JSX companion for canvases
624
+ // Inject inferred route, group, and resolve JSX companion for canvases
410
625
  if (suffix === 'canvas') {
411
626
  if (canvasRoutes[name]) {
412
627
  parsed = { ...parsed, _route: canvasRoutes[name] }
413
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
+ }
414
636
  // Inject folder association
415
637
  const folderDirMatch = path.relative(root, absPath).replace(/\\/g, '/').match(/(?:^|\/)src\/(?:prototypes|canvas)\/([^/]+)\.folder\//)
416
638
  if (folderDirMatch) {
@@ -462,8 +684,22 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root)
462
684
  }
463
685
  }
464
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
+
465
701
  const imports = [`import { init } from '@dfosco/storyboard-core'`]
466
- const initCalls = [`init({ flows, objects, records, prototypes, folders, canvases })`]
702
+ const initCalls = [`init({ flows, objects, records, prototypes, folders, canvases, stories })`]
467
703
 
468
704
  // Feature flags from storyboard.config.json
469
705
  const { config } = readConfig(root)
@@ -531,22 +767,54 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root)
531
767
  `const prototypes = {\n${entries.prototype.join(',\n')}\n}`,
532
768
  `const folders = {\n${entries.folder.join(',\n')}\n}`,
533
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 || {})}`,
534
774
  '',
535
775
  '// Backward-compatible alias',
536
776
  'const scenes = flows',
537
777
  '',
538
778
  initCalls.join('\n'),
539
779
  '',
540
- `export { flows, scenes, objects, records, prototypes, folders, canvases }`,
541
- `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 }`,
542
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
+ '}',
543
811
  ].join('\n')
544
812
  }
545
813
 
546
814
  /**
547
815
  * Vite plugin for storyboard data discovery.
548
816
  *
549
- * - 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}
550
818
  * - Validates no two files share the same name+suffix (hard build error)
551
819
  * - Generates a virtual module `virtual:storyboard-data-index`
552
820
  * - Watches for file additions/removals in dev mode
@@ -562,6 +830,11 @@ export default function storyboardDataPlugin() {
562
830
  config() {
563
831
  return {
564
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'],
565
838
  exclude: ['@dfosco/storyboard-react'],
566
839
  },
567
840
  }
@@ -582,10 +855,68 @@ export default function storyboardDataPlugin() {
582
855
  },
583
856
 
584
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
+
585
916
  // Watch for data file changes in dev mode
586
917
  const watcher = server.watcher
587
918
  if (!buildResult) buildResult = buildIndex(root)
588
- const knownCanvasNames = new Set(Object.keys(buildResult.index.canvas || {}))
919
+ const knownCanvasIds = new Set(Object.keys(buildResult.index.canvas || {}))
589
920
  const pendingCanvasUnlinks = new Map()
590
921
 
591
922
  const triggerFullReload = () => {
@@ -597,22 +928,50 @@ export default function storyboardDataPlugin() {
597
928
  }
598
929
  }
599
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
+
600
956
  const invalidate = (filePath) => {
601
957
  const normalized = filePath.replace(/\\/g, '/')
602
- // Skip .canvas.jsonl content changes entirely these are mutated
603
- // at runtime by the canvas server API. A full-reload would create
604
- // a feedback loop (save → file change → reload → lose editing state).
605
- // Instead, send a custom HMR event so the active canvas page can refetch
606
- // 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.
607
964
  if (/\.canvas\.jsonl$/.test(normalized)) {
608
965
  const parsed = parseDataFile(filePath)
609
- if (parsed?.suffix === 'canvas' && parsed?.name) {
966
+ if (parsed?.suffix === 'canvas' && parsed?.id) {
967
+ const metadata = readCanvasMetadata(filePath, parsed)
610
968
  server.ws.send({
611
969
  type: 'custom',
612
970
  event: 'storyboard:canvas-file-changed',
613
- data: { name: parsed.name },
971
+ data: { canvasId: parsed.id, name: parsed.id, ...(metadata ? { metadata } : {}) },
614
972
  })
615
973
  }
974
+ softInvalidate()
616
975
  return
617
976
  }
618
977
 
@@ -649,53 +1008,89 @@ export default function storyboardDataPlugin() {
649
1008
  // Treat canvas add/unlink as runtime data updates and never full-reload
650
1009
  // from watcher events. Canvas pages sync from disk via custom WS events.
651
1010
  if (parsed?.suffix === 'canvas') {
652
- const name = parsed.name
1011
+ const canvasId = parsed.id || parsed.name
653
1012
  if (eventType === 'unlink') {
654
1013
  const timer = setTimeout(() => {
655
- pendingCanvasUnlinks.delete(name)
656
- knownCanvasNames.delete(name)
1014
+ pendingCanvasUnlinks.delete(canvasId)
1015
+ knownCanvasIds.delete(canvasId)
657
1016
  server.ws.send({
658
1017
  type: 'custom',
659
1018
  event: 'storyboard:canvas-file-changed',
660
- data: { name },
1019
+ data: { canvasId, name: canvasId, removed: true },
661
1020
  })
1021
+ softInvalidate()
662
1022
  }, 1500)
663
- pendingCanvasUnlinks.set(name, timer)
1023
+ pendingCanvasUnlinks.set(canvasId, timer)
664
1024
  return
665
1025
  }
666
1026
 
667
1027
  if (eventType === 'add') {
668
- const pending = pendingCanvasUnlinks.get(name)
1028
+ const metadata = readCanvasMetadata(filePath, parsed)
1029
+ const pending = pendingCanvasUnlinks.get(canvasId)
669
1030
  if (pending) {
1031
+ // unlink+add pair = in-place save (atomic write), not a real remove
670
1032
  clearTimeout(pending)
671
- pendingCanvasUnlinks.delete(name)
1033
+ pendingCanvasUnlinks.delete(canvasId)
672
1034
  server.ws.send({
673
1035
  type: 'custom',
674
1036
  event: 'storyboard:canvas-file-changed',
675
- data: { name },
1037
+ data: { canvasId, name: canvasId, ...(metadata ? { metadata } : {}) },
676
1038
  })
1039
+ softInvalidate()
677
1040
  return
678
1041
  }
679
1042
 
680
- if (knownCanvasNames.has(name)) {
1043
+ if (knownCanvasIds.has(canvasId)) {
681
1044
  server.ws.send({
682
1045
  type: 'custom',
683
1046
  event: 'storyboard:canvas-file-changed',
684
- data: { name },
1047
+ data: { canvasId, name: canvasId, ...(metadata ? { metadata } : {}) },
685
1048
  })
1049
+ softInvalidate()
686
1050
  return
687
1051
  }
688
1052
 
689
- knownCanvasNames.add(name)
1053
+ knownCanvasIds.add(canvasId)
690
1054
  server.ws.send({
691
1055
  type: 'custom',
692
1056
  event: 'storyboard:canvas-file-changed',
693
- data: { name },
1057
+ data: { canvasId, name: canvasId, ...(metadata ? { metadata } : {}) },
694
1058
  })
1059
+ softInvalidate()
695
1060
  return
696
1061
  }
697
1062
  }
698
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
+
699
1094
  // Non-canvas additions/removals and folder changes update the route/data graph.
700
1095
  triggerFullReload()
701
1096
  }
@@ -726,18 +1121,10 @@ export default function storyboardDataPlugin() {
726
1121
  const normalized = ctx.file.replace(/\\/g, '/')
727
1122
  if (!/\.canvas\.jsonl$/.test(normalized)) return
728
1123
 
729
- const parsed = parseDataFile(ctx.file)
730
- if (parsed?.suffix === 'canvas' && parsed?.name) {
731
- ctx.server.ws.send({
732
- type: 'custom',
733
- event: 'storyboard:canvas-file-changed',
734
- data: { name: parsed.name },
735
- })
736
- }
737
-
738
1124
  // Prevent Vite's default fallback behavior (full page reload) for
739
- // non-module .canvas.jsonl edits. Canvas pages consume these updates
740
- // 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.
741
1128
  return []
742
1129
  },
743
1130
 
@@ -749,4 +1136,4 @@ export default function storyboardDataPlugin() {
749
1136
  }
750
1137
 
751
1138
  // Exported for testing
752
- export { resolveTemplateVars, computeTemplateVars }
1139
+ export { resolveTemplateVars, computeTemplateVars, parseDataFile }