@dfosco/storyboard-react 4.2.0-beta.4 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/package.json +10 -11
  2. package/src/AuthModal/AuthModal.jsx +6 -8
  3. package/src/BranchBar/BranchBar.jsx +20 -6
  4. package/src/BranchBar/BranchBar.module.css +13 -4
  5. package/src/BranchBar/useBranches.js +20 -6
  6. package/src/BranchBar/useBranches.test.js +68 -0
  7. package/src/CommandPalette/CommandPalette.jsx +480 -187
  8. package/src/CommandPalette/command-palette.css +142 -78
  9. package/src/Icon.jsx +157 -58
  10. package/src/Viewfinder.jsx +562 -207
  11. package/src/Viewfinder.module.css +434 -93
  12. package/src/Workspace.jsx +7 -0
  13. package/src/canvas/CanvasPage.bridge.test.jsx +14 -6
  14. package/src/canvas/CanvasPage.dragdrop.test.jsx +11 -7
  15. package/src/canvas/CanvasPage.jsx +739 -219
  16. package/src/canvas/CanvasPage.module.css +13 -15
  17. package/src/canvas/CanvasPage.multiselect.test.jsx +17 -6
  18. package/src/canvas/ConnectorLayer.jsx +121 -165
  19. package/src/canvas/ConnectorLayer.module.css +69 -0
  20. package/src/canvas/PageSelector.test.jsx +15 -6
  21. package/src/canvas/canvasApi.js +68 -2
  22. package/src/canvas/canvasReloadGuard.test.js +1 -1
  23. package/src/canvas/connectorGeometry.js +132 -0
  24. package/src/canvas/hotPoolDevLogs.js +25 -0
  25. package/src/canvas/useCanvas.js +1 -1
  26. package/src/canvas/useMarqueeSelect.js +30 -4
  27. package/src/canvas/widgets/CodePenEmbed.jsx +1 -0
  28. package/src/canvas/widgets/ComponentSetWidget.jsx +199 -0
  29. package/src/canvas/widgets/ComponentSetWidget.module.css +89 -0
  30. package/src/canvas/widgets/ComponentWidget.jsx +1 -0
  31. package/src/canvas/widgets/CropOverlay.jsx +219 -0
  32. package/src/canvas/widgets/CropOverlay.module.css +118 -0
  33. package/src/canvas/widgets/ExpandedPane.jsx +474 -0
  34. package/src/canvas/widgets/ExpandedPane.module.css +179 -0
  35. package/src/canvas/widgets/ExpandedPane.test.jsx +240 -0
  36. package/src/canvas/widgets/ExpandedPaneTopBar.jsx +111 -0
  37. package/src/canvas/widgets/ExpandedPaneTopBar.module.css +59 -0
  38. package/src/canvas/widgets/ExpandedPaneTopBar.test.jsx +45 -0
  39. package/src/canvas/widgets/FigmaEmbed.jsx +62 -47
  40. package/src/canvas/widgets/FigmaEmbed.module.css +61 -0
  41. package/src/canvas/widgets/ImageWidget.jsx +130 -9
  42. package/src/canvas/widgets/ImageWidget.module.css +30 -0
  43. package/src/canvas/widgets/LinkPreview.jsx +113 -5
  44. package/src/canvas/widgets/LinkPreview.module.css +127 -0
  45. package/src/canvas/widgets/MarkdownBlock.jsx +167 -17
  46. package/src/canvas/widgets/MarkdownBlock.module.css +148 -0
  47. package/src/canvas/widgets/PromptWidget.jsx +414 -0
  48. package/src/canvas/widgets/PromptWidget.module.css +273 -0
  49. package/src/canvas/widgets/PrototypeEmbed.jsx +77 -39
  50. package/src/canvas/widgets/PrototypeEmbed.module.css +117 -0
  51. package/src/canvas/widgets/PrototypeEmbed.test.jsx +2 -2
  52. package/src/canvas/widgets/ResizeHandle.jsx +17 -6
  53. package/src/canvas/widgets/StoryWidget.jsx +73 -15
  54. package/src/canvas/widgets/TerminalReadWidget.jsx +146 -0
  55. package/src/canvas/widgets/TerminalReadWidget.module.css +94 -0
  56. package/src/canvas/widgets/TerminalWidget.jsx +445 -67
  57. package/src/canvas/widgets/TerminalWidget.module.css +271 -8
  58. package/src/canvas/widgets/TilesWidget.jsx +300 -0
  59. package/src/canvas/widgets/TilesWidget.module.css +133 -0
  60. package/src/canvas/widgets/WidgetChrome.jsx +74 -153
  61. package/src/canvas/widgets/WidgetChrome.module.css +30 -1
  62. package/src/canvas/widgets/embedInteraction.test.jsx +24 -26
  63. package/src/canvas/widgets/expandUtils.js +560 -0
  64. package/src/canvas/widgets/expandUtils.test.js +155 -0
  65. package/src/canvas/widgets/index.js +9 -0
  66. package/src/canvas/widgets/snapshotDisplay.test.jsx +23 -71
  67. package/src/canvas/widgets/tilePool.js +23 -0
  68. package/src/canvas/widgets/tiles/diagonal-bl.png +0 -0
  69. package/src/canvas/widgets/tiles/diagonal-br.png +0 -0
  70. package/src/canvas/widgets/tiles/diagonal-tl.png +0 -0
  71. package/src/canvas/widgets/tiles/leaf.png +0 -0
  72. package/src/canvas/widgets/tiles/quarter-tl.png +0 -0
  73. package/src/canvas/widgets/tiles/quarter-tr.png +0 -0
  74. package/src/canvas/widgets/tiles/solid-a.png +0 -0
  75. package/src/canvas/widgets/tiles/solid-b.png +0 -0
  76. package/src/canvas/widgets/widgetConfig.js +55 -4
  77. package/src/canvas/widgets/widgetIcons.jsx +190 -0
  78. package/src/canvas/widgets/widgetProps.js +1 -0
  79. package/src/context.jsx +48 -20
  80. package/src/hooks/useConfig.js +14 -0
  81. package/src/hooks/usePrototypeReloadGuard.js +64 -0
  82. package/src/hooks/useSceneData.js +1 -0
  83. package/src/hooks/useThemeState.test.js +1 -1
  84. package/src/index.js +8 -2
  85. package/src/story/ComponentSetPage.jsx +186 -0
  86. package/src/story/ComponentSetPage.module.css +121 -0
  87. package/src/story/StoryPage.jsx +32 -2
  88. package/src/vite/data-plugin.js +363 -67
  89. package/src/vite/data-plugin.test.js +1 -1
@@ -5,7 +5,9 @@ import { globSync } from 'glob'
5
5
  import { parse as parseJsonc } from 'jsonc-parser'
6
6
  import { materializeFromText } from '@dfosco/storyboard-core/canvas/materializer'
7
7
  import { toCanvasId } from '@dfosco/storyboard-core/canvas/identity'
8
+ import { isCanvasWriteInFlight } from '@dfosco/storyboard-core/canvas/writeGuard'
8
9
  import { getConfig } from '@dfosco/storyboard-core/config'
10
+ import { list as listRunningServers } from '@dfosco/storyboard-core/worktree/serverRegistry'
9
11
 
10
12
  const VIRTUAL_MODULE_ID = 'virtual:storyboard-data-index'
11
13
  const RESOLVED_ID = '\0' + VIRTUAL_MODULE_ID
@@ -47,10 +49,6 @@ function parseDataFile(filePath) {
47
49
  const canvasCheck = normalized.match(/(?:^|\/)src\/canvas\//)
48
50
  if (canvasCheck) {
49
51
  const dirPath = normalized.substring(0, normalized.lastIndexOf('/'))
50
- const routeBase = (dirPath + '/')
51
- .replace(/^.*?src\/canvas\//, '')
52
- .replace(/[^/]*\.folder\/?/g, '')
53
- .replace(/\/$/, '')
54
52
  // Path-based ID: include folder context for uniqueness.
55
53
  // .folder dirs contribute their name (sans .folder suffix) to the ID.
56
54
  const idBase = (dirPath + '/')
@@ -190,39 +188,6 @@ function parseDataFile(filePath) {
190
188
  return { name, suffix, ext: match[3], inferredRoute }
191
189
  }
192
190
 
193
- /**
194
- * Look up the git author who first created a file.
195
- * Used to auto-fill the author field in .prototype.json when missing.
196
- */
197
- function getGitAuthor(root, filePath) {
198
- try {
199
- const result = execSync(
200
- `git log --follow --diff-filter=A --format="%aN" -- "${filePath}"`,
201
- { cwd: root, encoding: 'utf-8', timeout: 5000 },
202
- ).trim()
203
- const lines = result.split('\n').filter(Boolean)
204
- return lines.length > 0 ? lines[lines.length - 1] : null
205
- } catch {
206
- return null
207
- }
208
- }
209
-
210
- /**
211
- * Look up the most recent commit date for any file in a directory.
212
- * Returns an ISO 8601 timestamp, or null if unavailable.
213
- */
214
- function getLastModified(root, dirPath) {
215
- try {
216
- const result = execSync(
217
- `git log -1 --format="%aI" -- "${dirPath}"`,
218
- { cwd: root, encoding: 'utf-8', timeout: 5000 },
219
- ).trim()
220
- return result || null
221
- } catch {
222
- return null
223
- }
224
- }
225
-
226
191
  /**
227
192
  * Batch-fetch git metadata (author + lastModified) for multiple files in a
228
193
  * single subprocess, avoiding per-file git overhead during startup.
@@ -526,6 +491,194 @@ function readModesConfig(root) {
526
491
  return fallback
527
492
  }
528
493
 
494
+ /**
495
+ * Read a JSON/JSONC file, returning null on failure.
496
+ */
497
+ function readJsonFile(filePath) {
498
+ try {
499
+ const raw = fs.readFileSync(filePath, 'utf-8')
500
+ const errors = []
501
+ const parsed = parseJsonc(raw, errors)
502
+ return errors.length === 0 ? parsed : null
503
+ } catch {
504
+ return null
505
+ }
506
+ }
507
+
508
+ /**
509
+ * Find a core config file from either the monorepo workspace or node_modules.
510
+ */
511
+ function readCoreConfigFile(root, filename) {
512
+ const candidates = [
513
+ path.resolve(root, `packages/core/${filename}`),
514
+ path.resolve(root, `node_modules/@dfosco/storyboard-core/${filename}`),
515
+ ]
516
+ for (const p of candidates) {
517
+ const parsed = readJsonFile(p)
518
+ if (parsed) return parsed
519
+ }
520
+ return null
521
+ }
522
+
523
+ /**
524
+ * Deep-merge helper (same as loader.js deepMerge but available at build time).
525
+ * Arrays are replaced, not concatenated. Objects are recursively merged.
526
+ */
527
+ function deepMergeBuild(target, source) {
528
+ if (!source || typeof source !== 'object') return target
529
+ if (!target || typeof target !== 'object') return source
530
+ const result = { ...target }
531
+ for (const key of Object.keys(source)) {
532
+ const sv = source[key]
533
+ const tv = target[key]
534
+ if (sv && typeof sv === 'object' && !Array.isArray(sv) && tv && typeof tv === 'object' && !Array.isArray(tv)) {
535
+ result[key] = deepMergeBuild(tv, sv)
536
+ } else if (Array.isArray(sv) && Array.isArray(tv) && sv.length > 0 && tv.length > 0 && sv[0]?.id && tv[0]?.id) {
537
+ // Id-based array merge: override matching entries by id, keep the rest, append new ones
538
+ const targetMap = new Map(tv.map(item => [item.id, item]))
539
+ for (const item of sv) {
540
+ targetMap.set(item.id, item.id && targetMap.has(item.id)
541
+ ? deepMergeBuild(targetMap.get(item.id), item)
542
+ : item)
543
+ }
544
+ result[key] = [...targetMap.values()]
545
+ } else {
546
+ result[key] = sv
547
+ }
548
+ }
549
+ return result
550
+ }
551
+
552
+ /**
553
+ * Build the unified config object by reading and merging all config sources.
554
+ *
555
+ * Priority (lowest → highest):
556
+ * configSchema defaults → core domain configs → storyboard.config.json → user domain configs
557
+ *
558
+ * Domain-specific config files (toolbar.config.json, commandpalette.config.json, etc.)
559
+ * always win over storyboard.config.json — specificity beats generality.
560
+ * Deep merge is used at every layer: objects are recursively merged (keys append),
561
+ * arrays and scalars are replaced.
562
+ *
563
+ * Returns { unified, warnings } where warnings is an array of overlap messages.
564
+ */
565
+ function buildUnifiedConfig(root) {
566
+ const warnings = []
567
+
568
+ // 1. Read core defaults (lowest priority domain configs)
569
+ const coreToolbar = readCoreConfigFile(root, 'toolbar.config.json') || {}
570
+ const coreCommandPalette = readCoreConfigFile(root, 'commandpalette.config.json') || {}
571
+ const corePaste = readCoreConfigFile(root, 'paste.config.json') || {}
572
+ const coreWidgets = readCoreConfigFile(root, 'widgets.config.json') || {}
573
+
574
+ // 2. Read storyboard.config.json (middle priority)
575
+ // Use the schema-defaulted config for most things, but also read
576
+ // the raw file to know which keys were explicitly set by the user.
577
+ const { config: sbConfig } = readConfig(root)
578
+ const rawSbConfig = readJsonFile(path.resolve(root, 'storyboard.config.json')) || {}
579
+
580
+ // 3. Apply storyboard.config.json overrides on top of core domain configs.
581
+ // Only merge when the user explicitly defined the key in storyboard.config.json
582
+ // (not from configSchema defaults, which would overwrite core config with empty arrays).
583
+ const afterSbToolbar = rawSbConfig.toolbar
584
+ ? deepMergeBuild(coreToolbar, sbConfig.toolbar)
585
+ : coreToolbar
586
+ const afterSbCommandPalette = rawSbConfig.commandPalette
587
+ ? deepMergeBuild(coreCommandPalette, sbConfig.commandPalette)
588
+ : coreCommandPalette
589
+ const afterSbPaste = rawSbConfig.paste
590
+ ? deepMergeBuild(corePaste, sbConfig.paste || {})
591
+ : corePaste
592
+ const afterSbWidgets = rawSbConfig.widgets
593
+ ? deepMergeBuild(coreWidgets, sbConfig.widgets || {})
594
+ : coreWidgets
595
+
596
+ // 4. Read user domain config files (highest priority)
597
+ const userFiles = [
598
+ { domain: 'widgets', filename: 'widgets.config.json' },
599
+ { domain: 'paste', filename: 'paste.config.json' },
600
+ { domain: 'toolbar', filename: 'toolbar.config.json' },
601
+ { domain: 'commandPalette', filename: 'commandpalette.config.json' },
602
+ ]
603
+
604
+ const userConfigs = {}
605
+ for (const { domain, filename } of userFiles) {
606
+ const filePath = path.resolve(root, filename)
607
+ const parsed = readJsonFile(filePath)
608
+ if (parsed) userConfigs[domain] = { data: parsed, filename }
609
+ }
610
+
611
+ // 5. Apply user domain configs on top of everything (highest priority)
612
+ const finalToolbar = userConfigs.toolbar
613
+ ? deepMergeBuild(afterSbToolbar, userConfigs.toolbar.data)
614
+ : afterSbToolbar
615
+ const finalCommandPalette = userConfigs.commandPalette
616
+ ? deepMergeBuild(afterSbCommandPalette, userConfigs.commandPalette.data)
617
+ : afterSbCommandPalette
618
+ const finalPaste = userConfigs.paste
619
+ ? deepMergeBuild(afterSbPaste, userConfigs.paste.data)
620
+ : afterSbPaste
621
+ const finalWidgets = userConfigs.widgets
622
+ ? deepMergeBuild(afterSbWidgets, userConfigs.widgets.data)
623
+ : afterSbWidgets
624
+
625
+ // 6. Detect overlaps between storyboard.config.json and user domain configs
626
+ const domainOverlapChecks = [
627
+ { sbKey: 'toolbar', domain: 'toolbar', label: 'toolbar.config.json' },
628
+ { sbKey: 'commandPalette', domain: 'commandPalette', label: 'commandpalette.config.json' },
629
+ { sbKey: 'paste', domain: 'paste', label: 'paste.config.json' },
630
+ { sbKey: 'widgets', domain: 'widgets', label: 'widgets.config.json' },
631
+ ]
632
+ for (const { sbKey, domain, label } of domainOverlapChecks) {
633
+ if (rawSbConfig[sbKey] && userConfigs[domain]) {
634
+ const overlaps = findOverlappingKeys(rawSbConfig[sbKey], userConfigs[domain].data)
635
+ for (const key of overlaps) {
636
+ warnings.push(`Config overlap: "${key}" is defined in both storyboard.config.json.${sbKey} and ${label} — ${label} wins.`)
637
+ }
638
+ }
639
+ }
640
+
641
+ // 7. Build the unified config object
642
+ coreCPSections: coreCommandPalette?.sections?.length,
643
+ afterSbCPSections: afterSbCommandPalette?.sections?.length,
644
+ finalCPSections: finalCommandPalette?.sections?.length,
645
+ rawSbHasCP: !!rawSbConfig.commandPalette,
646
+ userHasCP: !!userConfigs.commandPalette,
647
+ })
648
+ const unified = {
649
+ toolbar: finalToolbar,
650
+ commandPalette: finalCommandPalette,
651
+ paste: finalPaste,
652
+ widgets: finalWidgets,
653
+ featureFlags: sbConfig?.featureFlags || {},
654
+ modes: sbConfig?.modes || {},
655
+ ui: sbConfig?.ui || {},
656
+ canvas: sbConfig?.canvas || {},
657
+ comments: sbConfig?.comments || {},
658
+ customerMode: sbConfig?.customerMode || {},
659
+ plugins: sbConfig?.plugins || {},
660
+ repository: sbConfig?.repository || {},
661
+ workshop: sbConfig?.workshop || {},
662
+ }
663
+
664
+ return { unified, warnings }
665
+ }
666
+
667
+ /**
668
+ * Find top-level keys that exist in both objects (overlap detection).
669
+ */
670
+ function findOverlappingKeys(a, b, prefix = '') {
671
+ const overlaps = []
672
+ if (!a || !b || typeof a !== 'object' || typeof b !== 'object') return overlaps
673
+ for (const key of Object.keys(a)) {
674
+ if (key in b) {
675
+ const path = prefix ? `${prefix}.${key}` : key
676
+ overlaps.push(path)
677
+ }
678
+ }
679
+ return overlaps
680
+ }
681
+
529
682
  function generateModule({ index, protoFolders, flowRoutes, canvasRoutes, canvasAliases, canvasGroups, storyRoutes }, root) {
530
683
  const declarations = []
531
684
  const INDEX_KEYS = ['flow', 'object', 'record', 'prototype', 'folder', 'canvas']
@@ -585,18 +738,28 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes, canvasA
585
738
  parsed = { ...parsed, folder: protoFolders[name] }
586
739
  }
587
740
 
588
- // Load toolbar.config.json from prototype directory if present
741
+ // Load prototype-level config overrides from the prototype directory.
742
+ // Any config file placed alongside the .prototype.json becomes an override
743
+ // for that domain when the prototype is active.
589
744
  if (suffix === 'prototype') {
590
745
  const protoDir = path.dirname(absPath)
591
- const toolbarConfigPath = path.join(protoDir, 'toolbar.config.json')
592
- if (fs.existsSync(toolbarConfigPath)) {
593
- try {
594
- const toolbarRaw = fs.readFileSync(toolbarConfigPath, 'utf-8')
595
- const toolbarConfig = parseJsonc(toolbarRaw)
596
- if (toolbarConfig) {
597
- parsed = { ...parsed, toolbarConfig }
598
- }
599
- } catch { /* skip invalid toolbar config */ }
746
+ const protoConfigFiles = [
747
+ { filename: 'toolbar.config.json', key: 'toolbarConfig' },
748
+ { filename: 'commandpalette.config.json', key: 'commandPaletteConfig' },
749
+ { filename: 'widgets.config.json', key: 'widgetsConfig' },
750
+ { filename: 'paste.config.json', key: 'pasteConfig' },
751
+ ]
752
+ for (const { filename, key } of protoConfigFiles) {
753
+ const cfgPath = path.join(protoDir, filename)
754
+ if (fs.existsSync(cfgPath)) {
755
+ try {
756
+ const raw = fs.readFileSync(cfgPath, 'utf-8')
757
+ const cfg = parseJsonc(raw)
758
+ if (cfg) {
759
+ parsed = { ...parsed, [key]: cfg }
760
+ }
761
+ } catch { /* skip invalid config */ }
762
+ }
600
763
  }
601
764
  }
602
765
 
@@ -694,6 +857,14 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes, canvasA
694
857
  const imports = [`import { init } from '@dfosco/storyboard-core'`]
695
858
  const initCalls = [`init({ flows, objects, records, prototypes, folders, canvases, stories })`]
696
859
 
860
+ // Build unified config from all sources
861
+ const { unified: unifiedConfig, warnings: configWarnings } = buildUnifiedConfig(root)
862
+ for (const w of configWarnings) {
863
+ console.warn(`[storyboard] ⚠ ${w}`)
864
+ }
865
+ imports.push(`import { initConfig } from '@dfosco/storyboard-core'`)
866
+ initCalls.push(`initConfig(${JSON.stringify(unifiedConfig)})`)
867
+
697
868
  // Feature flags from storyboard.config.json
698
869
  const { config } = readConfig(root)
699
870
  if (config?.featureFlags && Object.keys(config.featureFlags).length > 0) {
@@ -737,6 +908,20 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes, canvasA
737
908
  initCalls.push(`initCustomerModeConfig(${JSON.stringify(config.customerMode)})`)
738
909
  }
739
910
 
911
+ // Client toolbar overrides from root toolbar.config.json
912
+ const clientToolbarPath = path.resolve(root, 'toolbar.config.json')
913
+ try {
914
+ if (fs.existsSync(clientToolbarPath)) {
915
+ const raw = fs.readFileSync(clientToolbarPath, 'utf-8')
916
+ const errors = []
917
+ const parsed = parseJsonc(raw, errors)
918
+ if (parsed && errors.length === 0) {
919
+ imports.push(`import { setClientToolbarOverrides } from '@dfosco/storyboard-core'`)
920
+ initCalls.push(`setClientToolbarOverrides(${JSON.stringify(parsed)})`)
921
+ }
922
+ }
923
+ } catch { /* skip if unreadable */ }
924
+
740
925
  // Log info when multiple flows target the same route
741
926
  const routeGroups = {}
742
927
  for (const [name, { route, isDefault }] of Object.entries(resolvedFlowRoutes)) {
@@ -833,7 +1018,7 @@ export default function storyboardDataPlugin() {
833
1018
  // can't trace into its deps. Include the remark entry points so
834
1019
  // Vite pre-bundles the full chain — covers all transitive CJS
835
1020
  // packages (debug, extend, etc.) without whack-a-mole.
836
- include: ['react-cmdk', 'remark', 'remark-gfm', 'remark-html', 'use-sync-external-store/shim', 'use-sync-external-store/shim/with-selector'],
1021
+ include: ['cmdk', 'remark', 'remark-gfm', 'remark-html', 'use-sync-external-store/shim', 'use-sync-external-store/shim/with-selector'],
837
1022
  exclude: ['@dfosco/storyboard-react'],
838
1023
  },
839
1024
  }
@@ -961,21 +1146,40 @@ export default function storyboardDataPlugin() {
961
1146
  // custom HMR event with updated metadata so the canvas page and
962
1147
  // viewfinder can react in place.
963
1148
  if (/\.canvas\.jsonl$/.test(normalized)) {
964
- const parsed = parseDataFile(filePath)
965
- if (parsed?.suffix === 'canvas' && parsed?.id) {
966
- const metadata = readCanvasMetadata(filePath, parsed)
967
- server.ws.send({
968
- type: 'custom',
969
- event: 'storyboard:canvas-file-changed',
970
- data: { canvasId: parsed.id, name: parsed.id, ...(metadata ? { metadata } : {}) },
971
- })
1149
+ // If this file change was caused by the canvas server API, it has
1150
+ // already pushed an HMR event via pushCanvasUpdate(). Skip the
1151
+ // duplicate watcher-triggered event to prevent stale-data rollbacks.
1152
+ const absPath = path.resolve(root, filePath)
1153
+ if (!isCanvasWriteInFlight(absPath)) {
1154
+ const parsed = parseDataFile(filePath)
1155
+ if (parsed?.suffix === 'canvas' && parsed?.id) {
1156
+ const metadata = readCanvasMetadata(filePath, parsed)
1157
+ server.ws.send({
1158
+ type: 'custom',
1159
+ event: 'storyboard:canvas-file-changed',
1160
+ data: { canvasId: parsed.id, name: parsed.id, ...(metadata ? { metadata } : {}) },
1161
+ })
1162
+ }
972
1163
  }
973
1164
  softInvalidate()
974
1165
  return
975
1166
  }
976
1167
 
977
- // Invalidate when toolbar.config.json inside a prototype changes
978
- if (normalized.endsWith('/toolbar.config.json') && normalized.includes('/prototypes/')) {
1168
+ // Invalidate when any config file inside a prototype changes
1169
+ const protoConfigPattern = /\/(toolbar|commandpalette|widgets|paste)\.config\.json$/
1170
+ if (protoConfigPattern.test(normalized) && normalized.includes('/prototypes/')) {
1171
+ buildResult = null
1172
+ const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
1173
+ if (mod) {
1174
+ server.moduleGraph.invalidateModule(mod)
1175
+ server.ws.send({ type: 'full-reload' })
1176
+ }
1177
+ return
1178
+ }
1179
+
1180
+ // Invalidate when root toolbar.config.json changes
1181
+ if (normalized === path.resolve(root, 'toolbar.config.json').split(path.sep).join('/') ||
1182
+ normalized === path.resolve(root, 'toolbar.config.json')) {
979
1183
  buildResult = null
980
1184
  const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
981
1185
  if (mod) {
@@ -989,6 +1193,20 @@ export default function storyboardDataPlugin() {
989
1193
  // Also invalidate when files are added/removed inside .folder/ directories
990
1194
  const inFolder = normalized.includes('.folder/')
991
1195
  if (!parsed && !inFolder) return
1196
+ // Source files inside .folder/ dirs (jsx, css, etc.) are handled by
1197
+ // Vite's built-in HMR / React Fast Refresh — don't full-reload for them.
1198
+ if (!parsed && inFolder) return
1199
+
1200
+ // Story file content changes are handled by Vite's built-in HMR
1201
+ // (React Fast Refresh). Only soft-invalidate the virtual module so
1202
+ // the next page load picks up updated metadata — don't full-reload,
1203
+ // which would destroy canvas state and cause embedded iframes to
1204
+ // reload unnecessarily.
1205
+ if (parsed?.suffix === 'story') {
1206
+ softInvalidate()
1207
+ return
1208
+ }
1209
+
992
1210
  // Rebuild index and invalidate virtual module
993
1211
  buildResult = null
994
1212
  const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
@@ -1002,6 +1220,9 @@ export default function storyboardDataPlugin() {
1002
1220
  const parsed = parseDataFile(filePath)
1003
1221
  const inFolder = filePath.replace(/\\/g, '/').includes('.folder/')
1004
1222
  if (!parsed && !inFolder) return
1223
+ // Source files (jsx, css, etc.) inside .folder/ dirs are handled by
1224
+ // Vite's built-in HMR — don't trigger a full-reload for them.
1225
+ if (!parsed && inFolder) return
1005
1226
 
1006
1227
  // Canvas writers/editors can emit unlink+add for an in-place save.
1007
1228
  // Treat canvas add/unlink as runtime data updates and never full-reload
@@ -1097,8 +1318,20 @@ export default function storyboardDataPlugin() {
1097
1318
  // Watch storyboard.config.json for changes
1098
1319
  const { configPath } = readConfig(root)
1099
1320
  watcher.add(configPath)
1321
+
1322
+ // Watch all root domain config files for changes
1323
+ const domainConfigFiles = [
1324
+ 'toolbar.config.json',
1325
+ 'commandpalette.config.json',
1326
+ 'paste.config.json',
1327
+ 'widgets.config.json',
1328
+ ].map(f => path.resolve(root, f))
1329
+ const watchedConfigPaths = new Set([configPath, ...domainConfigFiles])
1330
+ for (const p of domainConfigFiles) watcher.add(p)
1331
+
1100
1332
  const invalidateConfig = (filePath) => {
1101
- if (path.resolve(filePath) === configPath) {
1333
+ const resolved = path.resolve(filePath)
1334
+ if (watchedConfigPaths.has(resolved)) {
1102
1335
  buildResult = null
1103
1336
  const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
1104
1337
  if (mod) {
@@ -1128,19 +1361,16 @@ export default function storyboardDataPlugin() {
1128
1361
  },
1129
1362
 
1130
1363
  // Inject __SB_BRANCHES__ into HTML so the Viewfinder branch selector works.
1131
- // Reads .worktrees/ports.json to enumerate active worktree dev servers.
1364
+ // Uses server registry (live running processes) instead of stale ports.json.
1132
1365
  transformIndexHtml(html, ctx) {
1133
1366
  // Only inject in dev mode
1134
1367
  if (!ctx.server) return html
1135
1368
 
1136
1369
  try {
1137
- const portsJsonPath = path.resolve(root, '.worktrees', 'ports.json')
1138
- if (!fs.existsSync(portsJsonPath)) return html
1139
-
1140
- const ports = JSON.parse(fs.readFileSync(portsJsonPath, 'utf-8'))
1141
- const branches = Object.entries(ports)
1142
- .filter(([name]) => name !== 'main')
1143
- .map(([name, port]) => ({ branch: name, folder: `branch--${name}`, port }))
1370
+ const servers = listRunningServers()
1371
+ const branches = servers
1372
+ .filter(srv => srv.worktree !== 'main')
1373
+ .map(srv => ({ branch: srv.worktree, folder: `branch--${srv.worktree}`, port: srv.port }))
1144
1374
 
1145
1375
  if (branches.length === 0) return html
1146
1376
 
@@ -1158,5 +1388,71 @@ export default function storyboardDataPlugin() {
1158
1388
  }
1159
1389
  }
1160
1390
 
1391
+ /**
1392
+ * Vite plugin that copies terminal snapshots into the build output
1393
+ * so TerminalReadWidget can fetch them as static files in production.
1394
+ *
1395
+ * Sources (in priority order):
1396
+ * 1. assets/.storyboard-public/terminal-snapshots/<widgetId>.snapshot.json (new, flat)
1397
+ * 2. assets/.storyboard-public/terminal-snapshots/<widgetId>.snapshot.txt (human-readable companion)
1398
+ * 3. .storyboard/terminal-snapshots/<canvasDir>/<widgetId>.json (legacy, nested)
1399
+ *
1400
+ * All are emitted to `_storyboard/terminal-snapshots/` in the build.
1401
+ * Tilde-prefixed files (~) are excluded (private).
1402
+ */
1403
+ export function terminalSnapshotPlugin() {
1404
+ return {
1405
+ name: 'storyboard-terminal-snapshots',
1406
+
1407
+ generateBundle() {
1408
+ const emittedIds = new Set()
1409
+
1410
+ // 1. New public snapshots (flat structure) — .json and .txt
1411
+ const publicDir = path.resolve('assets/.storyboard-public/terminal-snapshots')
1412
+ if (fs.existsSync(publicDir)) {
1413
+ for (const file of fs.readdirSync(publicDir)) {
1414
+ if (file.startsWith('~') || file.startsWith('.')) continue
1415
+ const isJson = file.endsWith('.snapshot.json')
1416
+ const isTxt = file.endsWith('.snapshot.txt')
1417
+ if (!isJson && !isTxt) continue
1418
+ if (isJson) {
1419
+ const widgetId = file.replace(/\.snapshot\.json$/, '')
1420
+ if (widgetId) emittedIds.add(widgetId)
1421
+ }
1422
+ this.emitFile({
1423
+ type: 'asset',
1424
+ fileName: `_storyboard/terminal-snapshots/${file}`,
1425
+ source: fs.readFileSync(path.join(publicDir, file), 'utf-8'),
1426
+ })
1427
+ }
1428
+ }
1429
+
1430
+ // 2. Legacy snapshots (nested by canvas dir) — skip if already emitted
1431
+ const legacyDir = path.resolve('.storyboard/terminal-snapshots')
1432
+ if (fs.existsSync(legacyDir)) {
1433
+ const walk = (dir) => {
1434
+ const entries = fs.readdirSync(dir, { withFileTypes: true })
1435
+ for (const entry of entries) {
1436
+ const full = path.join(dir, entry.name)
1437
+ if (entry.isDirectory()) {
1438
+ walk(full)
1439
+ } else if (entry.name.endsWith('.json') && !entry.name.startsWith('~')) {
1440
+ const widgetId = entry.name.replace(/\.json$/, '')
1441
+ if (emittedIds.has(widgetId)) continue // new format takes priority
1442
+ const rel = path.relative(legacyDir, full).replace(/\\/g, '/')
1443
+ this.emitFile({
1444
+ type: 'asset',
1445
+ fileName: `_storyboard/terminal-snapshots/${rel}`,
1446
+ source: fs.readFileSync(full, 'utf-8'),
1447
+ })
1448
+ }
1449
+ }
1450
+ }
1451
+ walk(legacyDir)
1452
+ }
1453
+ },
1454
+ }
1455
+ }
1456
+
1161
1457
  // Exported for testing
1162
1458
  export { resolveTemplateVars, computeTemplateVars, parseDataFile }
@@ -1,4 +1,4 @@
1
- import { mkdtempSync, writeFileSync, mkdirSync, rmSync, readFileSync } from 'node:fs'
1
+ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'
2
2
  import { tmpdir } from 'node:os'
3
3
  import path from 'node:path'
4
4
  import storyboardDataPlugin, { resolveTemplateVars, computeTemplateVars, parseDataFile } from './data-plugin.js'