@dfosco/storyboard-react 4.2.0-beta.2 → 4.2.0-beta.20

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 (85) hide show
  1. package/package.json +9 -4
  2. package/src/AuthModal/AuthModal.jsx +6 -2
  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 +478 -186
  8. package/src/CommandPalette/command-palette.css +142 -78
  9. package/src/Icon.jsx +157 -58
  10. package/src/Viewfinder.jsx +561 -191
  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 +10 -6
  15. package/src/canvas/CanvasPage.jsx +738 -216
  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 -153
  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/connectorGeometry.js +132 -0
  23. package/src/canvas/hotPoolDevLogs.js +25 -0
  24. package/src/canvas/useCanvas.js +1 -1
  25. package/src/canvas/useMarqueeSelect.js +30 -4
  26. package/src/canvas/widgets/CodePenEmbed.jsx +1 -0
  27. package/src/canvas/widgets/ComponentSetWidget.jsx +199 -0
  28. package/src/canvas/widgets/ComponentSetWidget.module.css +89 -0
  29. package/src/canvas/widgets/ComponentWidget.jsx +1 -0
  30. package/src/canvas/widgets/CropOverlay.jsx +219 -0
  31. package/src/canvas/widgets/CropOverlay.module.css +118 -0
  32. package/src/canvas/widgets/ExpandedPane.jsx +472 -0
  33. package/src/canvas/widgets/ExpandedPane.module.css +179 -0
  34. package/src/canvas/widgets/ExpandedPane.test.jsx +240 -0
  35. package/src/canvas/widgets/ExpandedPaneTopBar.jsx +111 -0
  36. package/src/canvas/widgets/ExpandedPaneTopBar.module.css +59 -0
  37. package/src/canvas/widgets/ExpandedPaneTopBar.test.jsx +45 -0
  38. package/src/canvas/widgets/FigmaEmbed.jsx +62 -47
  39. package/src/canvas/widgets/FigmaEmbed.module.css +61 -0
  40. package/src/canvas/widgets/ImageWidget.jsx +130 -9
  41. package/src/canvas/widgets/ImageWidget.module.css +30 -0
  42. package/src/canvas/widgets/LinkPreview.jsx +112 -4
  43. package/src/canvas/widgets/LinkPreview.module.css +127 -0
  44. package/src/canvas/widgets/MarkdownBlock.jsx +164 -17
  45. package/src/canvas/widgets/MarkdownBlock.module.css +148 -0
  46. package/src/canvas/widgets/PromptWidget.jsx +414 -0
  47. package/src/canvas/widgets/PromptWidget.module.css +273 -0
  48. package/src/canvas/widgets/PrototypeEmbed.jsx +77 -38
  49. package/src/canvas/widgets/PrototypeEmbed.module.css +117 -0
  50. package/src/canvas/widgets/PrototypeEmbed.test.jsx +2 -2
  51. package/src/canvas/widgets/ResizeHandle.jsx +17 -6
  52. package/src/canvas/widgets/StoryWidget.jsx +72 -15
  53. package/src/canvas/widgets/TerminalReadWidget.jsx +146 -0
  54. package/src/canvas/widgets/TerminalReadWidget.module.css +94 -0
  55. package/src/canvas/widgets/TerminalWidget.jsx +496 -69
  56. package/src/canvas/widgets/TerminalWidget.module.css +271 -8
  57. package/src/canvas/widgets/TilesWidget.jsx +302 -0
  58. package/src/canvas/widgets/TilesWidget.module.css +133 -0
  59. package/src/canvas/widgets/WidgetChrome.jsx +73 -153
  60. package/src/canvas/widgets/WidgetChrome.module.css +30 -1
  61. package/src/canvas/widgets/embedInteraction.test.jsx +24 -26
  62. package/src/canvas/widgets/expandUtils.js +557 -0
  63. package/src/canvas/widgets/expandUtils.test.js +155 -0
  64. package/src/canvas/widgets/index.js +9 -0
  65. package/src/canvas/widgets/snapshotDisplay.test.jsx +23 -71
  66. package/src/canvas/widgets/tilePool.js +23 -0
  67. package/src/canvas/widgets/tiles/diagonal-bl.png +0 -0
  68. package/src/canvas/widgets/tiles/diagonal-br.png +0 -0
  69. package/src/canvas/widgets/tiles/diagonal-tl.png +0 -0
  70. package/src/canvas/widgets/tiles/leaf.png +0 -0
  71. package/src/canvas/widgets/tiles/quarter-tl.png +0 -0
  72. package/src/canvas/widgets/tiles/quarter-tr.png +0 -0
  73. package/src/canvas/widgets/tiles/solid-a.png +0 -0
  74. package/src/canvas/widgets/tiles/solid-b.png +0 -0
  75. package/src/canvas/widgets/widgetConfig.js +55 -4
  76. package/src/canvas/widgets/widgetIcons.jsx +190 -0
  77. package/src/canvas/widgets/widgetProps.js +1 -0
  78. package/src/context.jsx +47 -19
  79. package/src/hooks/useConfig.js +14 -0
  80. package/src/hooks/usePrototypeReloadGuard.js +64 -0
  81. package/src/index.js +8 -2
  82. package/src/story/ComponentSetPage.jsx +186 -0
  83. package/src/story/ComponentSetPage.module.css +121 -0
  84. package/src/story/StoryPage.jsx +32 -2
  85. package/src/vite/data-plugin.js +324 -30
@@ -0,0 +1,121 @@
1
+ /* ComponentSetPage — grid layout for all exports of a story */
2
+
3
+ .grid {
4
+ background-color: var(--bgColor-muted, #f6f8fa);
5
+ display: flex;
6
+ flex-wrap: nowrap;
7
+ gap: 12px;
8
+ padding: 12px;
9
+ min-height: 100vh;
10
+ width: max-content;
11
+ min-width: 100%;
12
+ box-sizing: border-box;
13
+ }
14
+
15
+ .grid[data-layout="horizontal"] {
16
+ flex-direction: row;
17
+ }
18
+
19
+ .grid[data-layout="vertical"] {
20
+ flex-direction: column;
21
+ }
22
+
23
+ .cell {
24
+ flex: 0 0 auto;
25
+ display: flex;
26
+ flex-direction: column;
27
+ border: 2px solid var(--borderColor-muted, #d8dee4);
28
+ border-radius: 2px;
29
+ overflow: hidden;
30
+ transition: border-color 120ms ease, box-shadow 120ms ease;
31
+ position: relative;
32
+ background: var(--bgColor-default, #ffffff);
33
+ }
34
+
35
+ /* In horizontal layout, each cell snaps to the widest component */
36
+ .grid[data-layout="horizontal"] .cell {
37
+ min-width: var(--cell-snap-w, 200px);
38
+ }
39
+
40
+ /* In vertical layout, each cell snaps to the tallest component */
41
+ .grid[data-layout="vertical"] .cell {
42
+ min-height: var(--cell-snap-h, 120px);
43
+ min-width: 100%;
44
+ }
45
+
46
+ .cell[data-selected] {
47
+ border-color: var(--fgColor-accent, #0969da);
48
+ box-shadow: 0 0 0 1px var(--fgColor-accent, #0969da);
49
+ }
50
+
51
+ .cellLabel {
52
+ all: unset;
53
+ display: flex;
54
+ align-items: center;
55
+ gap: 6px;
56
+ padding: 6px 10px;
57
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
58
+ font-size: 11px;
59
+ font-weight: 600;
60
+ color: var(--fgColor-muted, #656d76);
61
+ background: var(--bgColor-muted, #f6f8fa);
62
+ border-bottom: 1px solid var(--borderColor-muted, #d8dee4);
63
+ cursor: pointer;
64
+ user-select: none;
65
+ transition: background 100ms ease, color 100ms ease;
66
+ flex-shrink: 0;
67
+ /* Round top corners to match cell */
68
+ border-radius: 6px 6px 0 0;
69
+ }
70
+
71
+ .cellLabel:hover {
72
+ background: var(--bgColor-neutral-muted, #eaeef2);
73
+ color: var(--fgColor-default, #1f2328);
74
+ }
75
+
76
+ .cellLabel[data-selected] {
77
+ color: var(--fgColor-accent, #0969da);
78
+ background: var(--bgColor-accent-muted, #ddf4ff);
79
+ }
80
+
81
+ .cellRadio {
82
+ display: inline-flex;
83
+ align-items: center;
84
+ justify-content: center;
85
+ width: 12px;
86
+ height: 12px;
87
+ border-radius: 50%;
88
+ border: 2px solid var(--borderColor-accent, #d8dee4);
89
+ flex-shrink: 0;
90
+ transition: border-color 100ms ease, background 100ms ease;
91
+ }
92
+
93
+ .cellRadio[data-selected] {
94
+ border-color: var(--fgColor-accent, #0969da);
95
+ background: var(--fgColor-accent, #0969da);
96
+ }
97
+
98
+ .cellContent {
99
+ flex: 1;
100
+ overflow: visible;
101
+ position: relative;
102
+ }
103
+
104
+ .error {
105
+ display: flex;
106
+ flex-direction: column;
107
+ gap: 4px;
108
+ padding: 1rem;
109
+ color: var(--fgColor-danger, #cf222e);
110
+ font-size: 0.875rem;
111
+ line-height: 1.5;
112
+ }
113
+
114
+ .loading {
115
+ display: flex;
116
+ align-items: center;
117
+ justify-content: center;
118
+ padding: 3rem;
119
+ color: var(--fgColor-muted, #656d76);
120
+ font-size: 0.875rem;
121
+ }
@@ -5,12 +5,14 @@
5
5
  * When ?export=ExportName is present, renders that single export.
6
6
  * Without ?export, renders all named exports stacked.
7
7
  */
8
- import { useState, useEffect, useMemo } from 'react'
8
+ import { useState, useEffect, useMemo, lazy, Suspense } from 'react'
9
9
  import { useLocation } from 'react-router-dom'
10
10
  import { getStoryData } from '@dfosco/storyboard-core'
11
11
  import { ThemeProvider, BaseStyles } from '@primer/react'
12
12
  import styles from './StoryPage.module.css'
13
13
 
14
+ const ComponentSetPageLazy = lazy(() => import('./ComponentSetPage.jsx'))
15
+
14
16
  function StoryErrorFallback({ name, error }) {
15
17
  return (
16
18
  <div className={styles.error}>
@@ -25,12 +27,31 @@ export default function StoryPage({ name }) {
25
27
  const searchParams = new URLSearchParams(location.search)
26
28
  const exportFilter = searchParams.get('export')
27
29
  const isEmbed = searchParams.has('_sb_embed')
30
+ const isComponentSet = searchParams.has('_sb_component_set')
31
+
32
+ // When embedded as a canvas iframe, suppress HMR full-reloads.
33
+ // Story content updates via React Fast Refresh; a full-reload
34
+ // causes a visible flash and can create a reload loop when
35
+ // multiple story iframes are on the same canvas.
36
+ useEffect(() => {
37
+ if (!isEmbed || !import.meta.hot) return
38
+ const msg = { active: true }
39
+ import.meta.hot.send('storyboard:canvas-hmr-guard', msg)
40
+ const interval = setInterval(() => {
41
+ import.meta.hot.send('storyboard:canvas-hmr-guard', msg)
42
+ }, 3000)
43
+ return () => {
44
+ clearInterval(interval)
45
+ import.meta.hot.send('storyboard:canvas-hmr-guard', { active: false })
46
+ }
47
+ }, [isEmbed])
28
48
 
29
49
  const story = useMemo(() => getStoryData(name), [name])
30
50
  const [exports, setExports] = useState(null)
31
51
  const [error, setError] = useState(null)
32
52
 
33
53
  useEffect(() => {
54
+ if (isComponentSet) return
34
55
  if (!story?._storyImport) {
35
56
  Promise.resolve().then(() => setError(`Story "${name}" not found or missing import`))
36
57
  return
@@ -55,7 +76,7 @@ export default function StoryPage({ name }) {
55
76
  })
56
77
 
57
78
  return () => { cancelled = true }
58
- }, [name, story])
79
+ }, [name, story, isComponentSet])
59
80
 
60
81
  // Signal snapshot-ready after story renders in embed mode.
61
82
  useEffect(() => {
@@ -67,6 +88,15 @@ export default function StoryPage({ name }) {
67
88
  })
68
89
  }, [isEmbed, exports])
69
90
 
91
+ // Delegate to ComponentSetPage for grid view (after all hooks)
92
+ if (isComponentSet) {
93
+ return (
94
+ <Suspense fallback={isEmbed ? null : <div className={styles.loading}>Loading component set…</div>}>
95
+ <ComponentSetPageLazy name={name} />
96
+ </Suspense>
97
+ )
98
+ }
99
+
70
100
  if (error) {
71
101
  return (
72
102
  <StoryErrorFallback name={name} error={error} />
@@ -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
@@ -526,6 +528,166 @@ function readModesConfig(root) {
526
528
  return fallback
527
529
  }
528
530
 
531
+ /**
532
+ * Read a JSON/JSONC file, returning null on failure.
533
+ */
534
+ function readJsonFile(filePath) {
535
+ try {
536
+ const raw = fs.readFileSync(filePath, 'utf-8')
537
+ const errors = []
538
+ const parsed = parseJsonc(raw, errors)
539
+ return errors.length === 0 ? parsed : null
540
+ } catch {
541
+ return null
542
+ }
543
+ }
544
+
545
+ /**
546
+ * Find a core config file from either the monorepo workspace or node_modules.
547
+ */
548
+ function readCoreConfigFile(root, filename) {
549
+ const candidates = [
550
+ path.resolve(root, `packages/core/${filename}`),
551
+ path.resolve(root, `node_modules/@dfosco/storyboard-core/${filename}`),
552
+ ]
553
+ for (const p of candidates) {
554
+ const parsed = readJsonFile(p)
555
+ if (parsed) return parsed
556
+ }
557
+ return null
558
+ }
559
+
560
+ /**
561
+ * Deep-merge helper (same as loader.js deepMerge but available at build time).
562
+ * Arrays are replaced, not concatenated. Objects are recursively merged.
563
+ */
564
+ function deepMergeBuild(target, source) {
565
+ if (!source || typeof source !== 'object') return target
566
+ if (!target || typeof target !== 'object') return source
567
+ const result = { ...target }
568
+ for (const key of Object.keys(source)) {
569
+ const sv = source[key]
570
+ const tv = target[key]
571
+ if (sv && typeof sv === 'object' && !Array.isArray(sv) && tv && typeof tv === 'object' && !Array.isArray(tv)) {
572
+ result[key] = deepMergeBuild(tv, sv)
573
+ } else {
574
+ result[key] = sv
575
+ }
576
+ }
577
+ return result
578
+ }
579
+
580
+ /**
581
+ * Build the unified config object by reading and merging all config sources.
582
+ *
583
+ * Priority (lowest → highest):
584
+ * core defaults → user widgets → user paste → user toolbar → user commandpalette → storyboard.config.json
585
+ *
586
+ * Returns { unified, warnings } where warnings is an array of overlap messages.
587
+ */
588
+ function buildUnifiedConfig(root) {
589
+ const warnings = []
590
+
591
+ // 1. Read core defaults
592
+ const coreToolbar = readCoreConfigFile(root, 'toolbar.config.json') || {}
593
+ const coreCommandPalette = readCoreConfigFile(root, 'commandpalette.config.json') || {}
594
+ const corePaste = readCoreConfigFile(root, 'paste.config.json') || {}
595
+ const coreWidgets = readCoreConfigFile(root, 'widgets.config.json') || {}
596
+
597
+ // 2. Read user config files (priority order)
598
+ const userFiles = [
599
+ { domain: 'widgets', filename: 'widgets.config.json', priority: 1 },
600
+ { domain: 'paste', filename: 'paste.config.json', priority: 2 },
601
+ { domain: 'toolbar', filename: 'toolbar.config.json', priority: 3 },
602
+ { domain: 'commandPalette', filename: 'commandpalette.config.json', priority: 4 },
603
+ ]
604
+
605
+ const userConfigs = {}
606
+ for (const { domain, filename } of userFiles) {
607
+ const filePath = path.resolve(root, filename)
608
+ const parsed = readJsonFile(filePath)
609
+ if (parsed) userConfigs[domain] = { data: parsed, filename }
610
+ }
611
+
612
+ // 3. Read storyboard.config.json (highest priority)
613
+ // Use the schema-defaulted config for most things, but also read
614
+ // the raw file to know which keys were explicitly set by the user.
615
+ const { config: sbConfig } = readConfig(root)
616
+ const rawSbConfig = readJsonFile(path.resolve(root, 'storyboard.config.json')) || {}
617
+
618
+ // 4. Merge core defaults with user overrides per domain
619
+ const toolbar = userConfigs.toolbar
620
+ ? deepMergeBuild(coreToolbar, userConfigs.toolbar.data)
621
+ : coreToolbar
622
+ const commandPalette = userConfigs.commandPalette
623
+ ? deepMergeBuild(coreCommandPalette, userConfigs.commandPalette.data)
624
+ : coreCommandPalette
625
+ const paste = userConfigs.paste
626
+ ? deepMergeBuild(corePaste, userConfigs.paste.data)
627
+ : corePaste
628
+ const widgets = userConfigs.widgets
629
+ ? deepMergeBuild(coreWidgets, userConfigs.widgets.data)
630
+ : coreWidgets
631
+
632
+ // 5. Apply storyboard.config.json overrides (highest priority for all domains)
633
+ // Only merge when the user explicitly defined the key in storyboard.config.json
634
+ // (not from configSchema defaults, which would overwrite core config with empty arrays).
635
+ const finalToolbar = rawSbConfig.toolbar
636
+ ? deepMergeBuild(toolbar, sbConfig.toolbar)
637
+ : toolbar
638
+ const finalCommandPalette = rawSbConfig.commandPalette
639
+ ? deepMergeBuild(commandPalette, sbConfig.commandPalette)
640
+ : commandPalette
641
+
642
+ // 6. Detect overlaps between user config files and storyboard.config.json
643
+ if (rawSbConfig.toolbar && userConfigs.toolbar) {
644
+ const overlaps = findOverlappingKeys(userConfigs.toolbar.data, rawSbConfig.toolbar)
645
+ for (const key of overlaps) {
646
+ warnings.push(`Config overlap: "${key}" is defined in both toolbar.config.json and storyboard.config.json.toolbar — storyboard.config.json wins.`)
647
+ }
648
+ }
649
+ if (rawSbConfig.commandPalette && userConfigs.commandPalette) {
650
+ const overlaps = findOverlappingKeys(userConfigs.commandPalette.data, rawSbConfig.commandPalette)
651
+ for (const key of overlaps) {
652
+ warnings.push(`Config overlap: "${key}" is defined in both commandpalette.config.json and storyboard.config.json.commandPalette — storyboard.config.json wins.`)
653
+ }
654
+ }
655
+
656
+ // 7. Build the unified config object
657
+ const unified = {
658
+ toolbar: finalToolbar,
659
+ commandPalette: finalCommandPalette,
660
+ paste,
661
+ widgets,
662
+ featureFlags: sbConfig?.featureFlags || {},
663
+ modes: sbConfig?.modes || {},
664
+ ui: sbConfig?.ui || {},
665
+ canvas: sbConfig?.canvas || {},
666
+ comments: sbConfig?.comments || {},
667
+ customerMode: sbConfig?.customerMode || {},
668
+ plugins: sbConfig?.plugins || {},
669
+ repository: sbConfig?.repository || {},
670
+ workshop: sbConfig?.workshop || {},
671
+ }
672
+
673
+ return { unified, warnings }
674
+ }
675
+
676
+ /**
677
+ * Find top-level keys that exist in both objects (overlap detection).
678
+ */
679
+ function findOverlappingKeys(a, b, prefix = '') {
680
+ const overlaps = []
681
+ if (!a || !b || typeof a !== 'object' || typeof b !== 'object') return overlaps
682
+ for (const key of Object.keys(a)) {
683
+ if (key in b) {
684
+ const path = prefix ? `${prefix}.${key}` : key
685
+ overlaps.push(path)
686
+ }
687
+ }
688
+ return overlaps
689
+ }
690
+
529
691
  function generateModule({ index, protoFolders, flowRoutes, canvasRoutes, canvasAliases, canvasGroups, storyRoutes }, root) {
530
692
  const declarations = []
531
693
  const INDEX_KEYS = ['flow', 'object', 'record', 'prototype', 'folder', 'canvas']
@@ -585,18 +747,28 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes, canvasA
585
747
  parsed = { ...parsed, folder: protoFolders[name] }
586
748
  }
587
749
 
588
- // Load toolbar.config.json from prototype directory if present
750
+ // Load prototype-level config overrides from the prototype directory.
751
+ // Any config file placed alongside the .prototype.json becomes an override
752
+ // for that domain when the prototype is active.
589
753
  if (suffix === 'prototype') {
590
754
  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 */ }
755
+ const protoConfigFiles = [
756
+ { filename: 'toolbar.config.json', key: 'toolbarConfig' },
757
+ { filename: 'commandpalette.config.json', key: 'commandPaletteConfig' },
758
+ { filename: 'widgets.config.json', key: 'widgetsConfig' },
759
+ { filename: 'paste.config.json', key: 'pasteConfig' },
760
+ ]
761
+ for (const { filename, key } of protoConfigFiles) {
762
+ const cfgPath = path.join(protoDir, filename)
763
+ if (fs.existsSync(cfgPath)) {
764
+ try {
765
+ const raw = fs.readFileSync(cfgPath, 'utf-8')
766
+ const cfg = parseJsonc(raw)
767
+ if (cfg) {
768
+ parsed = { ...parsed, [key]: cfg }
769
+ }
770
+ } catch { /* skip invalid config */ }
771
+ }
600
772
  }
601
773
  }
602
774
 
@@ -694,6 +866,14 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes, canvasA
694
866
  const imports = [`import { init } from '@dfosco/storyboard-core'`]
695
867
  const initCalls = [`init({ flows, objects, records, prototypes, folders, canvases, stories })`]
696
868
 
869
+ // Build unified config from all sources
870
+ const { unified: unifiedConfig, warnings: configWarnings } = buildUnifiedConfig(root)
871
+ for (const w of configWarnings) {
872
+ console.warn(`[storyboard] ⚠ ${w}`)
873
+ }
874
+ imports.push(`import { initConfig } from '@dfosco/storyboard-core'`)
875
+ initCalls.push(`initConfig(${JSON.stringify(unifiedConfig)})`)
876
+
697
877
  // Feature flags from storyboard.config.json
698
878
  const { config } = readConfig(root)
699
879
  if (config?.featureFlags && Object.keys(config.featureFlags).length > 0) {
@@ -737,6 +917,20 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes, canvasA
737
917
  initCalls.push(`initCustomerModeConfig(${JSON.stringify(config.customerMode)})`)
738
918
  }
739
919
 
920
+ // Client toolbar overrides from root toolbar.config.json
921
+ const clientToolbarPath = path.resolve(root, 'toolbar.config.json')
922
+ try {
923
+ if (fs.existsSync(clientToolbarPath)) {
924
+ const raw = fs.readFileSync(clientToolbarPath, 'utf-8')
925
+ const errors = []
926
+ const parsed = parseJsonc(raw, errors)
927
+ if (parsed && errors.length === 0) {
928
+ imports.push(`import { setClientToolbarOverrides } from '@dfosco/storyboard-core'`)
929
+ initCalls.push(`setClientToolbarOverrides(${JSON.stringify(parsed)})`)
930
+ }
931
+ }
932
+ } catch { /* skip if unreadable */ }
933
+
740
934
  // Log info when multiple flows target the same route
741
935
  const routeGroups = {}
742
936
  for (const [name, { route, isDefault }] of Object.entries(resolvedFlowRoutes)) {
@@ -833,7 +1027,7 @@ export default function storyboardDataPlugin() {
833
1027
  // can't trace into its deps. Include the remark entry points so
834
1028
  // Vite pre-bundles the full chain — covers all transitive CJS
835
1029
  // 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'],
1030
+ include: ['cmdk', 'remark', 'remark-gfm', 'remark-html', 'use-sync-external-store/shim', 'use-sync-external-store/shim/with-selector'],
837
1031
  exclude: ['@dfosco/storyboard-react'],
838
1032
  },
839
1033
  }
@@ -961,21 +1155,40 @@ export default function storyboardDataPlugin() {
961
1155
  // custom HMR event with updated metadata so the canvas page and
962
1156
  // viewfinder can react in place.
963
1157
  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
- })
1158
+ // If this file change was caused by the canvas server API, it has
1159
+ // already pushed an HMR event via pushCanvasUpdate(). Skip the
1160
+ // duplicate watcher-triggered event to prevent stale-data rollbacks.
1161
+ const absPath = path.resolve(root, filePath)
1162
+ if (!isCanvasWriteInFlight(absPath)) {
1163
+ const parsed = parseDataFile(filePath)
1164
+ if (parsed?.suffix === 'canvas' && parsed?.id) {
1165
+ const metadata = readCanvasMetadata(filePath, parsed)
1166
+ server.ws.send({
1167
+ type: 'custom',
1168
+ event: 'storyboard:canvas-file-changed',
1169
+ data: { canvasId: parsed.id, name: parsed.id, ...(metadata ? { metadata } : {}) },
1170
+ })
1171
+ }
972
1172
  }
973
1173
  softInvalidate()
974
1174
  return
975
1175
  }
976
1176
 
977
- // Invalidate when toolbar.config.json inside a prototype changes
978
- if (normalized.endsWith('/toolbar.config.json') && normalized.includes('/prototypes/')) {
1177
+ // Invalidate when any config file inside a prototype changes
1178
+ const protoConfigPattern = /\/(toolbar|commandpalette|widgets|paste)\.config\.json$/
1179
+ if (protoConfigPattern.test(normalized) && normalized.includes('/prototypes/')) {
1180
+ buildResult = null
1181
+ const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
1182
+ if (mod) {
1183
+ server.moduleGraph.invalidateModule(mod)
1184
+ server.ws.send({ type: 'full-reload' })
1185
+ }
1186
+ return
1187
+ }
1188
+
1189
+ // Invalidate when root toolbar.config.json changes
1190
+ if (normalized === path.resolve(root, 'toolbar.config.json').split(path.sep).join('/') ||
1191
+ normalized === path.resolve(root, 'toolbar.config.json')) {
979
1192
  buildResult = null
980
1193
  const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
981
1194
  if (mod) {
@@ -989,6 +1202,20 @@ export default function storyboardDataPlugin() {
989
1202
  // Also invalidate when files are added/removed inside .folder/ directories
990
1203
  const inFolder = normalized.includes('.folder/')
991
1204
  if (!parsed && !inFolder) return
1205
+ // Source files inside .folder/ dirs (jsx, css, etc.) are handled by
1206
+ // Vite's built-in HMR / React Fast Refresh — don't full-reload for them.
1207
+ if (!parsed && inFolder) return
1208
+
1209
+ // Story file content changes are handled by Vite's built-in HMR
1210
+ // (React Fast Refresh). Only soft-invalidate the virtual module so
1211
+ // the next page load picks up updated metadata — don't full-reload,
1212
+ // which would destroy canvas state and cause embedded iframes to
1213
+ // reload unnecessarily.
1214
+ if (parsed?.suffix === 'story') {
1215
+ softInvalidate()
1216
+ return
1217
+ }
1218
+
992
1219
  // Rebuild index and invalidate virtual module
993
1220
  buildResult = null
994
1221
  const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
@@ -1002,6 +1229,9 @@ export default function storyboardDataPlugin() {
1002
1229
  const parsed = parseDataFile(filePath)
1003
1230
  const inFolder = filePath.replace(/\\/g, '/').includes('.folder/')
1004
1231
  if (!parsed && !inFolder) return
1232
+ // Source files (jsx, css, etc.) inside .folder/ dirs are handled by
1233
+ // Vite's built-in HMR — don't trigger a full-reload for them.
1234
+ if (!parsed && inFolder) return
1005
1235
 
1006
1236
  // Canvas writers/editors can emit unlink+add for an in-place save.
1007
1237
  // Treat canvas add/unlink as runtime data updates and never full-reload
@@ -1097,8 +1327,14 @@ export default function storyboardDataPlugin() {
1097
1327
  // Watch storyboard.config.json for changes
1098
1328
  const { configPath } = readConfig(root)
1099
1329
  watcher.add(configPath)
1330
+
1331
+ // Watch root toolbar.config.json for changes
1332
+ const clientToolbarConfigPath = path.resolve(root, 'toolbar.config.json')
1333
+ watcher.add(clientToolbarConfigPath)
1334
+
1100
1335
  const invalidateConfig = (filePath) => {
1101
- if (path.resolve(filePath) === configPath) {
1336
+ const resolved = path.resolve(filePath)
1337
+ if (resolved === configPath || resolved === clientToolbarConfigPath) {
1102
1338
  buildResult = null
1103
1339
  const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
1104
1340
  if (mod) {
@@ -1128,19 +1364,16 @@ export default function storyboardDataPlugin() {
1128
1364
  },
1129
1365
 
1130
1366
  // Inject __SB_BRANCHES__ into HTML so the Viewfinder branch selector works.
1131
- // Reads .worktrees/ports.json to enumerate active worktree dev servers.
1367
+ // Uses server registry (live running processes) instead of stale ports.json.
1132
1368
  transformIndexHtml(html, ctx) {
1133
1369
  // Only inject in dev mode
1134
1370
  if (!ctx.server) return html
1135
1371
 
1136
1372
  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 }))
1373
+ const servers = listRunningServers()
1374
+ const branches = servers
1375
+ .filter(srv => srv.worktree !== 'main')
1376
+ .map(srv => ({ branch: srv.worktree, folder: `branch--${srv.worktree}`, port: srv.port }))
1144
1377
 
1145
1378
  if (branches.length === 0) return html
1146
1379
 
@@ -1158,5 +1391,66 @@ export default function storyboardDataPlugin() {
1158
1391
  }
1159
1392
  }
1160
1393
 
1394
+ /**
1395
+ * Vite plugin that copies terminal snapshots into the build output
1396
+ * so TerminalReadWidget can fetch them as static files in production.
1397
+ *
1398
+ * Sources (in priority order):
1399
+ * 1. assets/.storyboard-public/terminal-snapshots/<widgetId>.snapshot.json (new, flat)
1400
+ * 2. .storyboard/terminal-snapshots/<canvasDir>/<widgetId>.json (legacy, nested)
1401
+ *
1402
+ * Both are emitted to `_storyboard/terminal-snapshots/` in the build.
1403
+ * Tilde-prefixed files (~) are excluded (private).
1404
+ */
1405
+ export function terminalSnapshotPlugin() {
1406
+ return {
1407
+ name: 'storyboard-terminal-snapshots',
1408
+
1409
+ generateBundle() {
1410
+ const emittedIds = new Set()
1411
+
1412
+ // 1. New public snapshots (flat structure)
1413
+ const publicDir = path.resolve('assets/.storyboard-public/terminal-snapshots')
1414
+ if (fs.existsSync(publicDir)) {
1415
+ for (const file of fs.readdirSync(publicDir)) {
1416
+ if (file.startsWith('~') || file.startsWith('.') || !file.endsWith('.json')) continue
1417
+ // Extract widgetId from filename: <widgetId>.snapshot.json
1418
+ const widgetId = file.replace(/\.snapshot\.json$/, '')
1419
+ if (widgetId) emittedIds.add(widgetId)
1420
+ this.emitFile({
1421
+ type: 'asset',
1422
+ fileName: `_storyboard/terminal-snapshots/${file}`,
1423
+ source: fs.readFileSync(path.join(publicDir, file), 'utf-8'),
1424
+ })
1425
+ }
1426
+ }
1427
+
1428
+ // 2. Legacy snapshots (nested by canvas dir) — skip if already emitted
1429
+ const legacyDir = path.resolve('.storyboard/terminal-snapshots')
1430
+ if (fs.existsSync(legacyDir)) {
1431
+ const walk = (dir) => {
1432
+ const entries = fs.readdirSync(dir, { withFileTypes: true })
1433
+ for (const entry of entries) {
1434
+ const full = path.join(dir, entry.name)
1435
+ if (entry.isDirectory()) {
1436
+ walk(full)
1437
+ } else if (entry.name.endsWith('.json') && !entry.name.startsWith('~')) {
1438
+ const widgetId = entry.name.replace(/\.json$/, '')
1439
+ if (emittedIds.has(widgetId)) continue // new format takes priority
1440
+ const rel = path.relative(legacyDir, full).replace(/\\/g, '/')
1441
+ this.emitFile({
1442
+ type: 'asset',
1443
+ fileName: `_storyboard/terminal-snapshots/${rel}`,
1444
+ source: fs.readFileSync(full, 'utf-8'),
1445
+ })
1446
+ }
1447
+ }
1448
+ }
1449
+ walk(legacyDir)
1450
+ }
1451
+ },
1452
+ }
1453
+ }
1454
+
1161
1455
  // Exported for testing
1162
1456
  export { resolveTemplateVars, computeTemplateVars, parseDataFile }