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

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 +407 -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,188 @@ 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
+ const unified = {
643
+ toolbar: finalToolbar,
644
+ commandPalette: finalCommandPalette,
645
+ paste: finalPaste,
646
+ widgets: finalWidgets,
647
+ featureFlags: sbConfig?.featureFlags || {},
648
+ modes: sbConfig?.modes || {},
649
+ ui: sbConfig?.ui || {},
650
+ canvas: sbConfig?.canvas || {},
651
+ comments: sbConfig?.comments || {},
652
+ customerMode: sbConfig?.customerMode || {},
653
+ plugins: sbConfig?.plugins || {},
654
+ repository: sbConfig?.repository || {},
655
+ workshop: sbConfig?.workshop || {},
656
+ }
657
+
658
+ return { unified, warnings }
659
+ }
660
+
661
+ /**
662
+ * Find top-level keys that exist in both objects (overlap detection).
663
+ */
664
+ function findOverlappingKeys(a, b, prefix = '') {
665
+ const overlaps = []
666
+ if (!a || !b || typeof a !== 'object' || typeof b !== 'object') return overlaps
667
+ for (const key of Object.keys(a)) {
668
+ if (key in b) {
669
+ const path = prefix ? `${prefix}.${key}` : key
670
+ overlaps.push(path)
671
+ }
672
+ }
673
+ return overlaps
674
+ }
675
+
529
676
  function generateModule({ index, protoFolders, flowRoutes, canvasRoutes, canvasAliases, canvasGroups, storyRoutes }, root) {
530
677
  const declarations = []
531
678
  const INDEX_KEYS = ['flow', 'object', 'record', 'prototype', 'folder', 'canvas']
@@ -585,18 +732,28 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes, canvasA
585
732
  parsed = { ...parsed, folder: protoFolders[name] }
586
733
  }
587
734
 
588
- // Load toolbar.config.json from prototype directory if present
735
+ // Load prototype-level config overrides from the prototype directory.
736
+ // Any config file placed alongside the .prototype.json becomes an override
737
+ // for that domain when the prototype is active.
589
738
  if (suffix === 'prototype') {
590
739
  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 */ }
740
+ const protoConfigFiles = [
741
+ { filename: 'toolbar.config.json', key: 'toolbarConfig' },
742
+ { filename: 'commandpalette.config.json', key: 'commandPaletteConfig' },
743
+ { filename: 'widgets.config.json', key: 'widgetsConfig' },
744
+ { filename: 'paste.config.json', key: 'pasteConfig' },
745
+ ]
746
+ for (const { filename, key } of protoConfigFiles) {
747
+ const cfgPath = path.join(protoDir, filename)
748
+ if (fs.existsSync(cfgPath)) {
749
+ try {
750
+ const raw = fs.readFileSync(cfgPath, 'utf-8')
751
+ const cfg = parseJsonc(raw)
752
+ if (cfg) {
753
+ parsed = { ...parsed, [key]: cfg }
754
+ }
755
+ } catch { /* skip invalid config */ }
756
+ }
600
757
  }
601
758
  }
602
759
 
@@ -694,6 +851,14 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes, canvasA
694
851
  const imports = [`import { init } from '@dfosco/storyboard-core'`]
695
852
  const initCalls = [`init({ flows, objects, records, prototypes, folders, canvases, stories })`]
696
853
 
854
+ // Build unified config from all sources
855
+ const { unified: unifiedConfig, warnings: configWarnings } = buildUnifiedConfig(root)
856
+ for (const w of configWarnings) {
857
+ console.warn(`[storyboard] ⚠ ${w}`)
858
+ }
859
+ imports.push(`import { initConfig } from '@dfosco/storyboard-core'`)
860
+ initCalls.push(`initConfig(${JSON.stringify(unifiedConfig)})`)
861
+
697
862
  // Feature flags from storyboard.config.json
698
863
  const { config } = readConfig(root)
699
864
  if (config?.featureFlags && Object.keys(config.featureFlags).length > 0) {
@@ -737,6 +902,20 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes, canvasA
737
902
  initCalls.push(`initCustomerModeConfig(${JSON.stringify(config.customerMode)})`)
738
903
  }
739
904
 
905
+ // Client toolbar overrides from root toolbar.config.json
906
+ const clientToolbarPath = path.resolve(root, 'toolbar.config.json')
907
+ try {
908
+ if (fs.existsSync(clientToolbarPath)) {
909
+ const raw = fs.readFileSync(clientToolbarPath, 'utf-8')
910
+ const errors = []
911
+ const parsed = parseJsonc(raw, errors)
912
+ if (parsed && errors.length === 0) {
913
+ imports.push(`import { setClientToolbarOverrides } from '@dfosco/storyboard-core'`)
914
+ initCalls.push(`setClientToolbarOverrides(${JSON.stringify(parsed)})`)
915
+ }
916
+ }
917
+ } catch { /* skip if unreadable */ }
918
+
740
919
  // Log info when multiple flows target the same route
741
920
  const routeGroups = {}
742
921
  for (const [name, { route, isDefault }] of Object.entries(resolvedFlowRoutes)) {
@@ -833,7 +1012,7 @@ export default function storyboardDataPlugin() {
833
1012
  // can't trace into its deps. Include the remark entry points so
834
1013
  // Vite pre-bundles the full chain — covers all transitive CJS
835
1014
  // 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'],
1015
+ include: ['cmdk', 'remark', 'remark-gfm', 'remark-html', 'use-sync-external-store/shim', 'use-sync-external-store/shim/with-selector'],
837
1016
  exclude: ['@dfosco/storyboard-react'],
838
1017
  },
839
1018
  }
@@ -961,21 +1140,40 @@ export default function storyboardDataPlugin() {
961
1140
  // custom HMR event with updated metadata so the canvas page and
962
1141
  // viewfinder can react in place.
963
1142
  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
- })
1143
+ // If this file change was caused by the canvas server API, it has
1144
+ // already pushed an HMR event via pushCanvasUpdate(). Skip the
1145
+ // duplicate watcher-triggered event to prevent stale-data rollbacks.
1146
+ const absPath = path.resolve(root, filePath)
1147
+ if (!isCanvasWriteInFlight(absPath)) {
1148
+ const parsed = parseDataFile(filePath)
1149
+ if (parsed?.suffix === 'canvas' && parsed?.id) {
1150
+ const metadata = readCanvasMetadata(filePath, parsed)
1151
+ server.ws.send({
1152
+ type: 'custom',
1153
+ event: 'storyboard:canvas-file-changed',
1154
+ data: { canvasId: parsed.id, name: parsed.id, ...(metadata ? { metadata } : {}) },
1155
+ })
1156
+ }
972
1157
  }
973
1158
  softInvalidate()
974
1159
  return
975
1160
  }
976
1161
 
977
- // Invalidate when toolbar.config.json inside a prototype changes
978
- if (normalized.endsWith('/toolbar.config.json') && normalized.includes('/prototypes/')) {
1162
+ // Invalidate when any config file inside a prototype changes
1163
+ const protoConfigPattern = /\/(toolbar|commandpalette|widgets|paste)\.config\.json$/
1164
+ if (protoConfigPattern.test(normalized) && normalized.includes('/prototypes/')) {
1165
+ buildResult = null
1166
+ const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
1167
+ if (mod) {
1168
+ server.moduleGraph.invalidateModule(mod)
1169
+ server.ws.send({ type: 'full-reload' })
1170
+ }
1171
+ return
1172
+ }
1173
+
1174
+ // Invalidate when root toolbar.config.json changes
1175
+ if (normalized === path.resolve(root, 'toolbar.config.json').split(path.sep).join('/') ||
1176
+ normalized === path.resolve(root, 'toolbar.config.json')) {
979
1177
  buildResult = null
980
1178
  const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
981
1179
  if (mod) {
@@ -989,6 +1187,20 @@ export default function storyboardDataPlugin() {
989
1187
  // Also invalidate when files are added/removed inside .folder/ directories
990
1188
  const inFolder = normalized.includes('.folder/')
991
1189
  if (!parsed && !inFolder) return
1190
+ // Source files inside .folder/ dirs (jsx, css, etc.) are handled by
1191
+ // Vite's built-in HMR / React Fast Refresh — don't full-reload for them.
1192
+ if (!parsed && inFolder) return
1193
+
1194
+ // Story file content changes are handled by Vite's built-in HMR
1195
+ // (React Fast Refresh). Only soft-invalidate the virtual module so
1196
+ // the next page load picks up updated metadata — don't full-reload,
1197
+ // which would destroy canvas state and cause embedded iframes to
1198
+ // reload unnecessarily.
1199
+ if (parsed?.suffix === 'story') {
1200
+ softInvalidate()
1201
+ return
1202
+ }
1203
+
992
1204
  // Rebuild index and invalidate virtual module
993
1205
  buildResult = null
994
1206
  const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
@@ -1002,6 +1214,9 @@ export default function storyboardDataPlugin() {
1002
1214
  const parsed = parseDataFile(filePath)
1003
1215
  const inFolder = filePath.replace(/\\/g, '/').includes('.folder/')
1004
1216
  if (!parsed && !inFolder) return
1217
+ // Source files (jsx, css, etc.) inside .folder/ dirs are handled by
1218
+ // Vite's built-in HMR — don't trigger a full-reload for them.
1219
+ if (!parsed && inFolder) return
1005
1220
 
1006
1221
  // Canvas writers/editors can emit unlink+add for an in-place save.
1007
1222
  // Treat canvas add/unlink as runtime data updates and never full-reload
@@ -1097,8 +1312,20 @@ export default function storyboardDataPlugin() {
1097
1312
  // Watch storyboard.config.json for changes
1098
1313
  const { configPath } = readConfig(root)
1099
1314
  watcher.add(configPath)
1315
+
1316
+ // Watch all root domain config files for changes
1317
+ const domainConfigFiles = [
1318
+ 'toolbar.config.json',
1319
+ 'commandpalette.config.json',
1320
+ 'paste.config.json',
1321
+ 'widgets.config.json',
1322
+ ].map(f => path.resolve(root, f))
1323
+ const watchedConfigPaths = new Set([configPath, ...domainConfigFiles])
1324
+ for (const p of domainConfigFiles) watcher.add(p)
1325
+
1100
1326
  const invalidateConfig = (filePath) => {
1101
- if (path.resolve(filePath) === configPath) {
1327
+ const resolved = path.resolve(filePath)
1328
+ if (watchedConfigPaths.has(resolved)) {
1102
1329
  buildResult = null
1103
1330
  const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
1104
1331
  if (mod) {
@@ -1128,19 +1355,16 @@ export default function storyboardDataPlugin() {
1128
1355
  },
1129
1356
 
1130
1357
  // Inject __SB_BRANCHES__ into HTML so the Viewfinder branch selector works.
1131
- // Reads .worktrees/ports.json to enumerate active worktree dev servers.
1358
+ // Uses server registry (live running processes) instead of stale ports.json.
1132
1359
  transformIndexHtml(html, ctx) {
1133
1360
  // Only inject in dev mode
1134
1361
  if (!ctx.server) return html
1135
1362
 
1136
1363
  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 }))
1364
+ const servers = listRunningServers()
1365
+ const branches = servers
1366
+ .filter(srv => srv.worktree !== 'main')
1367
+ .map(srv => ({ branch: srv.worktree, folder: `branch--${srv.worktree}`, port: srv.port }))
1144
1368
 
1145
1369
  if (branches.length === 0) return html
1146
1370
 
@@ -1155,6 +1379,122 @@ export default function storyboardDataPlugin() {
1155
1379
  buildStart() {
1156
1380
  buildResult = null
1157
1381
  },
1382
+
1383
+ // Emit terminal snapshots into the build so TerminalReadWidget can
1384
+ // fetch them as static files in production (no dev-server API).
1385
+ generateBundle() {
1386
+ const emittedIds = new Set()
1387
+
1388
+ // 1. New public snapshots (flat structure) — .json and .txt
1389
+ const publicDir = path.resolve('assets/.storyboard-public/terminal-snapshots')
1390
+ if (fs.existsSync(publicDir)) {
1391
+ for (const file of fs.readdirSync(publicDir)) {
1392
+ if (file.startsWith('~') || file.startsWith('.')) continue
1393
+ const isJson = file.endsWith('.snapshot.json')
1394
+ const isTxt = file.endsWith('.snapshot.txt')
1395
+ if (!isJson && !isTxt) continue
1396
+ if (isJson) {
1397
+ const widgetId = file.replace(/\.snapshot\.json$/, '')
1398
+ if (widgetId) emittedIds.add(widgetId)
1399
+ }
1400
+ this.emitFile({
1401
+ type: 'asset',
1402
+ fileName: `_storyboard/terminal-snapshots/${file}`,
1403
+ source: fs.readFileSync(path.join(publicDir, file), 'utf-8'),
1404
+ })
1405
+ }
1406
+ }
1407
+
1408
+ // 2. Legacy snapshots (nested by canvas dir) — skip if already emitted
1409
+ const legacyDir = path.resolve('.storyboard/terminal-snapshots')
1410
+ if (fs.existsSync(legacyDir)) {
1411
+ const walk = (dir) => {
1412
+ const entries = fs.readdirSync(dir, { withFileTypes: true })
1413
+ for (const entry of entries) {
1414
+ const full = path.join(dir, entry.name)
1415
+ if (entry.isDirectory()) {
1416
+ walk(full)
1417
+ } else if (entry.name.endsWith('.json') && !entry.name.startsWith('~')) {
1418
+ const widgetId = entry.name.replace(/\.json$/, '')
1419
+ if (emittedIds.has(widgetId)) continue
1420
+ const rel = path.relative(legacyDir, full).replace(/\\/g, '/')
1421
+ this.emitFile({
1422
+ type: 'asset',
1423
+ fileName: `_storyboard/terminal-snapshots/${rel}`,
1424
+ source: fs.readFileSync(full, 'utf-8'),
1425
+ })
1426
+ }
1427
+ }
1428
+ }
1429
+ walk(legacyDir)
1430
+ }
1431
+ },
1432
+ }
1433
+ }
1434
+
1435
+ /**
1436
+ * Vite plugin that copies terminal snapshots into the build output
1437
+ * so TerminalReadWidget can fetch them as static files in production.
1438
+ *
1439
+ * Sources (in priority order):
1440
+ * 1. assets/.storyboard-public/terminal-snapshots/<widgetId>.snapshot.json (new, flat)
1441
+ * 2. assets/.storyboard-public/terminal-snapshots/<widgetId>.snapshot.txt (human-readable companion)
1442
+ * 3. .storyboard/terminal-snapshots/<canvasDir>/<widgetId>.json (legacy, nested)
1443
+ *
1444
+ * All are emitted to `_storyboard/terminal-snapshots/` in the build.
1445
+ * Tilde-prefixed files (~) are excluded (private).
1446
+ */
1447
+ export function terminalSnapshotPlugin() {
1448
+ return {
1449
+ name: 'storyboard-terminal-snapshots',
1450
+
1451
+ generateBundle() {
1452
+ const emittedIds = new Set()
1453
+
1454
+ // 1. New public snapshots (flat structure) — .json and .txt
1455
+ const publicDir = path.resolve('assets/.storyboard-public/terminal-snapshots')
1456
+ if (fs.existsSync(publicDir)) {
1457
+ for (const file of fs.readdirSync(publicDir)) {
1458
+ if (file.startsWith('~') || file.startsWith('.')) continue
1459
+ const isJson = file.endsWith('.snapshot.json')
1460
+ const isTxt = file.endsWith('.snapshot.txt')
1461
+ if (!isJson && !isTxt) continue
1462
+ if (isJson) {
1463
+ const widgetId = file.replace(/\.snapshot\.json$/, '')
1464
+ if (widgetId) emittedIds.add(widgetId)
1465
+ }
1466
+ this.emitFile({
1467
+ type: 'asset',
1468
+ fileName: `_storyboard/terminal-snapshots/${file}`,
1469
+ source: fs.readFileSync(path.join(publicDir, file), 'utf-8'),
1470
+ })
1471
+ }
1472
+ }
1473
+
1474
+ // 2. Legacy snapshots (nested by canvas dir) — skip if already emitted
1475
+ const legacyDir = path.resolve('.storyboard/terminal-snapshots')
1476
+ if (fs.existsSync(legacyDir)) {
1477
+ const walk = (dir) => {
1478
+ const entries = fs.readdirSync(dir, { withFileTypes: true })
1479
+ for (const entry of entries) {
1480
+ const full = path.join(dir, entry.name)
1481
+ if (entry.isDirectory()) {
1482
+ walk(full)
1483
+ } else if (entry.name.endsWith('.json') && !entry.name.startsWith('~')) {
1484
+ const widgetId = entry.name.replace(/\.json$/, '')
1485
+ if (emittedIds.has(widgetId)) continue // new format takes priority
1486
+ const rel = path.relative(legacyDir, full).replace(/\\/g, '/')
1487
+ this.emitFile({
1488
+ type: 'asset',
1489
+ fileName: `_storyboard/terminal-snapshots/${rel}`,
1490
+ source: fs.readFileSync(full, 'utf-8'),
1491
+ })
1492
+ }
1493
+ }
1494
+ }
1495
+ walk(legacyDir)
1496
+ }
1497
+ },
1158
1498
  }
1159
1499
  }
1160
1500
 
@@ -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'