@dfosco/storyboard-react 4.2.0-beta.0 → 4.2.0-beta.17

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 (48) hide show
  1. package/package.json +5 -4
  2. package/src/AuthModal/AuthModal.jsx +6 -2
  3. package/src/BranchBar/BranchBar.jsx +17 -5
  4. package/src/BranchBar/BranchBar.module.css +11 -2
  5. package/src/CommandPalette/CommandPalette.jsx +267 -164
  6. package/src/CommandPalette/command-palette.css +130 -78
  7. package/src/Icon.jsx +112 -48
  8. package/src/Viewfinder.jsx +511 -61
  9. package/src/Viewfinder.module.css +414 -2
  10. package/src/canvas/CanvasPage.bridge.test.jsx +14 -6
  11. package/src/canvas/CanvasPage.dragdrop.test.jsx +10 -6
  12. package/src/canvas/CanvasPage.jsx +157 -174
  13. package/src/canvas/CanvasPage.module.css +0 -15
  14. package/src/canvas/CanvasPage.multiselect.test.jsx +10 -6
  15. package/src/canvas/ConnectorLayer.jsx +5 -5
  16. package/src/canvas/PageSelector.test.jsx +15 -6
  17. package/src/canvas/useCanvas.js +1 -1
  18. package/src/canvas/widgets/ActionWidget.jsx +200 -0
  19. package/src/canvas/widgets/ActionWidget.module.css +122 -0
  20. package/src/canvas/widgets/FigmaEmbed.jsx +97 -29
  21. package/src/canvas/widgets/FigmaEmbed.module.css +61 -0
  22. package/src/canvas/widgets/ImageWidget.jsx +1 -1
  23. package/src/canvas/widgets/LinkPreview.jsx +64 -5
  24. package/src/canvas/widgets/LinkPreview.module.css +127 -0
  25. package/src/canvas/widgets/MarkdownBlock.jsx +39 -17
  26. package/src/canvas/widgets/MarkdownBlock.module.css +123 -0
  27. package/src/canvas/widgets/PrototypeEmbed.jsx +183 -20
  28. package/src/canvas/widgets/PrototypeEmbed.module.css +117 -0
  29. package/src/canvas/widgets/PrototypeEmbed.test.jsx +2 -2
  30. package/src/canvas/widgets/SplitExpandModal.jsx +234 -0
  31. package/src/canvas/widgets/SplitExpandModal.module.css +335 -0
  32. package/src/canvas/widgets/SplitScreenTopBar.jsx +30 -0
  33. package/src/canvas/widgets/SplitScreenTopBar.module.css +58 -0
  34. package/src/canvas/widgets/StoryWidget.jsx +7 -4
  35. package/src/canvas/widgets/TerminalReadWidget.jsx +140 -0
  36. package/src/canvas/widgets/TerminalReadWidget.module.css +92 -0
  37. package/src/canvas/widgets/TerminalWidget.jsx +299 -49
  38. package/src/canvas/widgets/TerminalWidget.module.css +155 -1
  39. package/src/canvas/widgets/WidgetChrome.jsx +19 -14
  40. package/src/canvas/widgets/WidgetChrome.module.css +10 -0
  41. package/src/canvas/widgets/embedInteraction.test.jsx +24 -26
  42. package/src/canvas/widgets/expandUtils.js +188 -0
  43. package/src/canvas/widgets/index.js +5 -0
  44. package/src/canvas/widgets/snapshotDisplay.test.jsx +23 -71
  45. package/src/canvas/widgets/widgetConfig.js +19 -1
  46. package/src/hooks/useConfig.js +14 -0
  47. package/src/index.js +4 -0
  48. package/src/vite/data-plugin.js +264 -14
@@ -0,0 +1,14 @@
1
+ import { useSyncExternalStore, useCallback } from 'react'
2
+ import { getConfig, subscribeToConfig, getConfigSnapshot } from '@dfosco/storyboard-core'
3
+
4
+ /**
5
+ * React hook for reading from the unified config store.
6
+ *
7
+ * @param {string} [domain] - Optional domain key (e.g. 'toolbar', 'canvas')
8
+ * @returns {object} The config object (full or domain slice)
9
+ */
10
+ export function useConfig(domain) {
11
+ const snapshot = useSyncExternalStore(subscribeToConfig, getConfigSnapshot)
12
+ // eslint-disable-next-line react-hooks/exhaustive-deps
13
+ return useCallback(() => getConfig(domain), [snapshot, domain])()
14
+ }
package/src/index.js CHANGED
@@ -25,6 +25,7 @@ export { useUndoRedo } from './hooks/useUndoRedo.js'
25
25
  export { useFeatureFlag } from './hooks/useFeatureFlag.js'
26
26
  export { useMode } from './hooks/useMode.js'
27
27
  export { useThemeState, useThemeSyncTargets } from './hooks/useThemeState.js'
28
+ export { useConfig } from './hooks/useConfig.js'
28
29
 
29
30
  // React Router integration
30
31
  export { installHashPreserver } from './hashPreserver.js'
@@ -50,3 +51,6 @@ export { default as AuthModal } from './AuthModal/AuthModal.jsx'
50
51
  // Canvas
51
52
  export { default as CanvasPage } from './canvas/CanvasPage.jsx'
52
53
  export { useCanvas } from './canvas/useCanvas.js'
54
+
55
+ // Icon
56
+ export { default as Icon } from './Icon.jsx'
@@ -526,6 +526,166 @@ function readModesConfig(root) {
526
526
  return fallback
527
527
  }
528
528
 
529
+ /**
530
+ * Read a JSON/JSONC file, returning null on failure.
531
+ */
532
+ function readJsonFile(filePath) {
533
+ try {
534
+ const raw = fs.readFileSync(filePath, 'utf-8')
535
+ const errors = []
536
+ const parsed = parseJsonc(raw, errors)
537
+ return errors.length === 0 ? parsed : null
538
+ } catch {
539
+ return null
540
+ }
541
+ }
542
+
543
+ /**
544
+ * Find a core config file from either the monorepo workspace or node_modules.
545
+ */
546
+ function readCoreConfigFile(root, filename) {
547
+ const candidates = [
548
+ path.resolve(root, `packages/core/${filename}`),
549
+ path.resolve(root, `node_modules/@dfosco/storyboard-core/${filename}`),
550
+ ]
551
+ for (const p of candidates) {
552
+ const parsed = readJsonFile(p)
553
+ if (parsed) return parsed
554
+ }
555
+ return null
556
+ }
557
+
558
+ /**
559
+ * Deep-merge helper (same as loader.js deepMerge but available at build time).
560
+ * Arrays are replaced, not concatenated. Objects are recursively merged.
561
+ */
562
+ function deepMergeBuild(target, source) {
563
+ if (!source || typeof source !== 'object') return target
564
+ if (!target || typeof target !== 'object') return source
565
+ const result = { ...target }
566
+ for (const key of Object.keys(source)) {
567
+ const sv = source[key]
568
+ const tv = target[key]
569
+ if (sv && typeof sv === 'object' && !Array.isArray(sv) && tv && typeof tv === 'object' && !Array.isArray(tv)) {
570
+ result[key] = deepMergeBuild(tv, sv)
571
+ } else {
572
+ result[key] = sv
573
+ }
574
+ }
575
+ return result
576
+ }
577
+
578
+ /**
579
+ * Build the unified config object by reading and merging all config sources.
580
+ *
581
+ * Priority (lowest → highest):
582
+ * core defaults → user widgets → user paste → user toolbar → user commandpalette → storyboard.config.json
583
+ *
584
+ * Returns { unified, warnings } where warnings is an array of overlap messages.
585
+ */
586
+ function buildUnifiedConfig(root) {
587
+ const warnings = []
588
+
589
+ // 1. Read core defaults
590
+ const coreToolbar = readCoreConfigFile(root, 'toolbar.config.json') || {}
591
+ const coreCommandPalette = readCoreConfigFile(root, 'commandpalette.config.json') || {}
592
+ const corePaste = readCoreConfigFile(root, 'paste.config.json') || {}
593
+ const coreWidgets = readCoreConfigFile(root, 'widgets.config.json') || {}
594
+
595
+ // 2. Read user config files (priority order)
596
+ const userFiles = [
597
+ { domain: 'widgets', filename: 'widgets.config.json', priority: 1 },
598
+ { domain: 'paste', filename: 'paste.config.json', priority: 2 },
599
+ { domain: 'toolbar', filename: 'toolbar.config.json', priority: 3 },
600
+ { domain: 'commandPalette', filename: 'commandpalette.config.json', priority: 4 },
601
+ ]
602
+
603
+ const userConfigs = {}
604
+ for (const { domain, filename } of userFiles) {
605
+ const filePath = path.resolve(root, filename)
606
+ const parsed = readJsonFile(filePath)
607
+ if (parsed) userConfigs[domain] = { data: parsed, filename }
608
+ }
609
+
610
+ // 3. Read storyboard.config.json (highest priority)
611
+ // Use the schema-defaulted config for most things, but also read
612
+ // the raw file to know which keys were explicitly set by the user.
613
+ const { config: sbConfig } = readConfig(root)
614
+ const rawSbConfig = readJsonFile(path.resolve(root, 'storyboard.config.json')) || {}
615
+
616
+ // 4. Merge core defaults with user overrides per domain
617
+ const toolbar = userConfigs.toolbar
618
+ ? deepMergeBuild(coreToolbar, userConfigs.toolbar.data)
619
+ : coreToolbar
620
+ const commandPalette = userConfigs.commandPalette
621
+ ? deepMergeBuild(coreCommandPalette, userConfigs.commandPalette.data)
622
+ : coreCommandPalette
623
+ const paste = userConfigs.paste
624
+ ? deepMergeBuild(corePaste, userConfigs.paste.data)
625
+ : corePaste
626
+ const widgets = userConfigs.widgets
627
+ ? deepMergeBuild(coreWidgets, userConfigs.widgets.data)
628
+ : coreWidgets
629
+
630
+ // 5. Apply storyboard.config.json overrides (highest priority for all domains)
631
+ // Only merge when the user explicitly defined the key in storyboard.config.json
632
+ // (not from configSchema defaults, which would overwrite core config with empty arrays).
633
+ const finalToolbar = rawSbConfig.toolbar
634
+ ? deepMergeBuild(toolbar, sbConfig.toolbar)
635
+ : toolbar
636
+ const finalCommandPalette = rawSbConfig.commandPalette
637
+ ? deepMergeBuild(commandPalette, sbConfig.commandPalette)
638
+ : commandPalette
639
+
640
+ // 6. Detect overlaps between user config files and storyboard.config.json
641
+ if (rawSbConfig.toolbar && userConfigs.toolbar) {
642
+ const overlaps = findOverlappingKeys(userConfigs.toolbar.data, rawSbConfig.toolbar)
643
+ for (const key of overlaps) {
644
+ warnings.push(`Config overlap: "${key}" is defined in both toolbar.config.json and storyboard.config.json.toolbar — storyboard.config.json wins.`)
645
+ }
646
+ }
647
+ if (rawSbConfig.commandPalette && userConfigs.commandPalette) {
648
+ const overlaps = findOverlappingKeys(userConfigs.commandPalette.data, rawSbConfig.commandPalette)
649
+ for (const key of overlaps) {
650
+ warnings.push(`Config overlap: "${key}" is defined in both commandpalette.config.json and storyboard.config.json.commandPalette — storyboard.config.json wins.`)
651
+ }
652
+ }
653
+
654
+ // 7. Build the unified config object
655
+ const unified = {
656
+ toolbar: finalToolbar,
657
+ commandPalette: finalCommandPalette,
658
+ paste,
659
+ widgets,
660
+ featureFlags: sbConfig?.featureFlags || {},
661
+ modes: sbConfig?.modes || {},
662
+ ui: sbConfig?.ui || {},
663
+ canvas: sbConfig?.canvas || {},
664
+ comments: sbConfig?.comments || {},
665
+ customerMode: sbConfig?.customerMode || {},
666
+ plugins: sbConfig?.plugins || {},
667
+ repository: sbConfig?.repository || {},
668
+ workshop: sbConfig?.workshop || {},
669
+ }
670
+
671
+ return { unified, warnings }
672
+ }
673
+
674
+ /**
675
+ * Find top-level keys that exist in both objects (overlap detection).
676
+ */
677
+ function findOverlappingKeys(a, b, prefix = '') {
678
+ const overlaps = []
679
+ if (!a || !b || typeof a !== 'object' || typeof b !== 'object') return overlaps
680
+ for (const key of Object.keys(a)) {
681
+ if (key in b) {
682
+ const path = prefix ? `${prefix}.${key}` : key
683
+ overlaps.push(path)
684
+ }
685
+ }
686
+ return overlaps
687
+ }
688
+
529
689
  function generateModule({ index, protoFolders, flowRoutes, canvasRoutes, canvasAliases, canvasGroups, storyRoutes }, root) {
530
690
  const declarations = []
531
691
  const INDEX_KEYS = ['flow', 'object', 'record', 'prototype', 'folder', 'canvas']
@@ -585,18 +745,28 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes, canvasA
585
745
  parsed = { ...parsed, folder: protoFolders[name] }
586
746
  }
587
747
 
588
- // Load toolbar.config.json from prototype directory if present
748
+ // Load prototype-level config overrides from the prototype directory.
749
+ // Any config file placed alongside the .prototype.json becomes an override
750
+ // for that domain when the prototype is active.
589
751
  if (suffix === 'prototype') {
590
752
  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 */ }
753
+ const protoConfigFiles = [
754
+ { filename: 'toolbar.config.json', key: 'toolbarConfig' },
755
+ { filename: 'commandpalette.config.json', key: 'commandPaletteConfig' },
756
+ { filename: 'widgets.config.json', key: 'widgetsConfig' },
757
+ { filename: 'paste.config.json', key: 'pasteConfig' },
758
+ ]
759
+ for (const { filename, key } of protoConfigFiles) {
760
+ const cfgPath = path.join(protoDir, filename)
761
+ if (fs.existsSync(cfgPath)) {
762
+ try {
763
+ const raw = fs.readFileSync(cfgPath, 'utf-8')
764
+ const cfg = parseJsonc(raw)
765
+ if (cfg) {
766
+ parsed = { ...parsed, [key]: cfg }
767
+ }
768
+ } catch { /* skip invalid config */ }
769
+ }
600
770
  }
601
771
  }
602
772
 
@@ -694,6 +864,14 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes, canvasA
694
864
  const imports = [`import { init } from '@dfosco/storyboard-core'`]
695
865
  const initCalls = [`init({ flows, objects, records, prototypes, folders, canvases, stories })`]
696
866
 
867
+ // Build unified config from all sources
868
+ const { unified: unifiedConfig, warnings: configWarnings } = buildUnifiedConfig(root)
869
+ for (const w of configWarnings) {
870
+ console.warn(`[storyboard] ⚠ ${w}`)
871
+ }
872
+ imports.push(`import { initConfig } from '@dfosco/storyboard-core'`)
873
+ initCalls.push(`initConfig(${JSON.stringify(unifiedConfig)})`)
874
+
697
875
  // Feature flags from storyboard.config.json
698
876
  const { config } = readConfig(root)
699
877
  if (config?.featureFlags && Object.keys(config.featureFlags).length > 0) {
@@ -737,6 +915,20 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes, canvasA
737
915
  initCalls.push(`initCustomerModeConfig(${JSON.stringify(config.customerMode)})`)
738
916
  }
739
917
 
918
+ // Client toolbar overrides from root toolbar.config.json
919
+ const clientToolbarPath = path.resolve(root, 'toolbar.config.json')
920
+ try {
921
+ if (fs.existsSync(clientToolbarPath)) {
922
+ const raw = fs.readFileSync(clientToolbarPath, 'utf-8')
923
+ const errors = []
924
+ const parsed = parseJsonc(raw, errors)
925
+ if (parsed && errors.length === 0) {
926
+ imports.push(`import { setClientToolbarOverrides } from '@dfosco/storyboard-core'`)
927
+ initCalls.push(`setClientToolbarOverrides(${JSON.stringify(parsed)})`)
928
+ }
929
+ }
930
+ } catch { /* skip if unreadable */ }
931
+
740
932
  // Log info when multiple flows target the same route
741
933
  const routeGroups = {}
742
934
  for (const [name, { route, isDefault }] of Object.entries(resolvedFlowRoutes)) {
@@ -833,7 +1025,7 @@ export default function storyboardDataPlugin() {
833
1025
  // can't trace into its deps. Include the remark entry points so
834
1026
  // Vite pre-bundles the full chain — covers all transitive CJS
835
1027
  // 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'],
1028
+ include: ['cmdk', 'remark', 'remark-gfm', 'remark-html', 'use-sync-external-store/shim', 'use-sync-external-store/shim/with-selector'],
837
1029
  exclude: ['@dfosco/storyboard-react'],
838
1030
  },
839
1031
  }
@@ -974,8 +1166,21 @@ export default function storyboardDataPlugin() {
974
1166
  return
975
1167
  }
976
1168
 
977
- // Invalidate when toolbar.config.json inside a prototype changes
978
- if (normalized.endsWith('/toolbar.config.json') && normalized.includes('/prototypes/')) {
1169
+ // Invalidate when any config file inside a prototype changes
1170
+ const protoConfigPattern = /\/(toolbar|commandpalette|widgets|paste)\.config\.json$/
1171
+ if (protoConfigPattern.test(normalized) && normalized.includes('/prototypes/')) {
1172
+ buildResult = null
1173
+ const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
1174
+ if (mod) {
1175
+ server.moduleGraph.invalidateModule(mod)
1176
+ server.ws.send({ type: 'full-reload' })
1177
+ }
1178
+ return
1179
+ }
1180
+
1181
+ // Invalidate when root toolbar.config.json changes
1182
+ if (normalized === path.resolve(root, 'toolbar.config.json').split(path.sep).join('/') ||
1183
+ normalized === path.resolve(root, 'toolbar.config.json')) {
979
1184
  buildResult = null
980
1185
  const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
981
1186
  if (mod) {
@@ -989,6 +1194,9 @@ export default function storyboardDataPlugin() {
989
1194
  // Also invalidate when files are added/removed inside .folder/ directories
990
1195
  const inFolder = normalized.includes('.folder/')
991
1196
  if (!parsed && !inFolder) return
1197
+ // Source files inside .folder/ dirs (jsx, css, etc.) are handled by
1198
+ // Vite's built-in HMR / React Fast Refresh — don't full-reload for them.
1199
+ if (!parsed && inFolder) return
992
1200
  // Rebuild index and invalidate virtual module
993
1201
  buildResult = null
994
1202
  const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
@@ -1002,6 +1210,9 @@ export default function storyboardDataPlugin() {
1002
1210
  const parsed = parseDataFile(filePath)
1003
1211
  const inFolder = filePath.replace(/\\/g, '/').includes('.folder/')
1004
1212
  if (!parsed && !inFolder) return
1213
+ // Source files (jsx, css, etc.) inside .folder/ dirs are handled by
1214
+ // Vite's built-in HMR — don't trigger a full-reload for them.
1215
+ if (!parsed && inFolder) return
1005
1216
 
1006
1217
  // Canvas writers/editors can emit unlink+add for an in-place save.
1007
1218
  // Treat canvas add/unlink as runtime data updates and never full-reload
@@ -1097,8 +1308,14 @@ export default function storyboardDataPlugin() {
1097
1308
  // Watch storyboard.config.json for changes
1098
1309
  const { configPath } = readConfig(root)
1099
1310
  watcher.add(configPath)
1311
+
1312
+ // Watch root toolbar.config.json for changes
1313
+ const clientToolbarConfigPath = path.resolve(root, 'toolbar.config.json')
1314
+ watcher.add(clientToolbarConfigPath)
1315
+
1100
1316
  const invalidateConfig = (filePath) => {
1101
- if (path.resolve(filePath) === configPath) {
1317
+ const resolved = path.resolve(filePath)
1318
+ if (resolved === configPath || resolved === clientToolbarConfigPath) {
1102
1319
  buildResult = null
1103
1320
  const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
1104
1321
  if (mod) {
@@ -1158,5 +1375,38 @@ export default function storyboardDataPlugin() {
1158
1375
  }
1159
1376
  }
1160
1377
 
1378
+ /**
1379
+ * Vite plugin that copies `.storyboard/terminal-snapshots/` into the build
1380
+ * output so TerminalReadWidget can fetch them as static files in production.
1381
+ */
1382
+ export function terminalSnapshotPlugin() {
1383
+ return {
1384
+ name: 'storyboard-terminal-snapshots',
1385
+
1386
+ generateBundle() {
1387
+ const snapshotsDir = path.resolve('.storyboard/terminal-snapshots')
1388
+ if (!fs.existsSync(snapshotsDir)) return
1389
+
1390
+ const walk = (dir) => {
1391
+ const entries = fs.readdirSync(dir, { withFileTypes: true })
1392
+ for (const entry of entries) {
1393
+ const full = path.join(dir, entry.name)
1394
+ if (entry.isDirectory()) {
1395
+ walk(full)
1396
+ } else if (entry.name.endsWith('.json')) {
1397
+ const rel = path.relative(snapshotsDir, full).replace(/\\/g, '/')
1398
+ this.emitFile({
1399
+ type: 'asset',
1400
+ fileName: `_storyboard/terminal-snapshots/${rel}`,
1401
+ source: fs.readFileSync(full, 'utf-8'),
1402
+ })
1403
+ }
1404
+ }
1405
+ }
1406
+ walk(snapshotsDir)
1407
+ },
1408
+ }
1409
+ }
1410
+
1161
1411
  // Exported for testing
1162
1412
  export { resolveTemplateVars, computeTemplateVars, parseDataFile }