@dfosco/storyboard-react 4.2.0-beta.26 → 4.2.0-beta.28

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.
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-react",
3
- "version": "4.2.0-beta.26",
3
+ "version": "4.2.0-beta.28",
4
4
  "type": "module",
5
5
  "dependencies": {
6
6
  "@base-ui/react": "^1.4.0",
7
- "@dfosco/storyboard-core": "4.2.0-beta.26",
8
- "@dfosco/tiny-canvas": "4.2.0-beta.26",
7
+ "@dfosco/storyboard-core": "4.2.0-beta.28",
8
+ "@dfosco/tiny-canvas": "4.2.0-beta.28",
9
9
  "@neodrag/react": "^2.3.1",
10
10
  "@radix-ui/react-dialog": "^1.1.15",
11
11
  "@radix-ui/react-visually-hidden": "^1.2.4",
@@ -30,11 +30,6 @@ export default function AuthModal() {
30
30
  return () => document.removeEventListener('storyboard:open-auth-modal', handleOpen)
31
31
  }, [])
32
32
 
33
- const _handleClose = useCallback(() => {
34
- setOpen(false)
35
- setTokenValue('')
36
- }, [])
37
-
38
33
  const handleSignIn = useCallback(() => {
39
34
  const trimmed = tokenValue.trim()
40
35
  if (!trimmed) return
@@ -1198,7 +1198,7 @@ export default function StoryboardCommandPalette({ basePath }) {
1198
1198
  !search && <Command.Separator key={list.id} />
1199
1199
  ) : (
1200
1200
  <Command.Group key={list.id} heading={list.heading}>
1201
- {list.items.map(({ id, children, keywords, onClick, itemType, toolIcon, toolMeta, closeOnSelect, hideFromSearch, url, ..._rest }) => {
1201
+ {list.items.map(({ id, children, keywords, onClick, itemType, toolIcon, toolMeta, closeOnSelect, hideFromSearch, url }) => {
1202
1202
  if (search && hideFromSearch) return null
1203
1203
  if (hiddenFromSearchIds.size > 0) {
1204
1204
  for (const toolId of hiddenFromSearchIds) {
@@ -56,7 +56,7 @@ const COMMENTS_TOKEN_KEY = 'sb-comments-token'
56
56
  * Resolve the current GitHub user for display in the sidebar.
57
57
  * Priority: 1) PAT-cached user (from comments auth), 2) gh CLI login via git-user endpoint.
58
58
  */
59
- function useGitHubUser(_basePath) {
59
+ function useGitHubUser() {
60
60
  const [user, setUser] = useState(() => {
61
61
  try {
62
62
  const raw = localStorage.getItem(COMMENTS_USER_KEY)
@@ -154,16 +154,6 @@ function withBase(basePath, route) {
154
154
  return `${normalizedBase}${normalizedRoute}`.replace(/\/+/g, '/')
155
155
  }
156
156
 
157
- /* ─── Thumbnail color from name hash ─── */
158
-
159
- const THUMB_CLASSES = ['thumbBlue', 'thumbAmber', 'thumbGreen', 'thumbPurple', 'thumbRose', 'thumbSlate']
160
-
161
- function _thumbClass(name) {
162
- let h = 0
163
- for (let i = 0; i < name.length; i++) h = ((h << 5) - h + name.charCodeAt(i)) | 0
164
- return css[THUMB_CLASSES[Math.abs(h) % THUMB_CLASSES.length]]
165
- }
166
-
167
157
  /* ─── Type helpers ─── */
168
158
 
169
159
  function getTypeLabel(type) {
@@ -1078,10 +1068,7 @@ export default function Workspace({
1078
1068
  basePath,
1079
1069
  title = 'Storyboard',
1080
1070
  subtitle,
1081
- hideDefaultFlow,
1082
- hideDefaultScene = false,
1083
1071
  }) {
1084
- const _shouldHideDefault = hideDefaultFlow ?? hideDefaultScene
1085
1072
  const themeAttrs = useToolbarTheme()
1086
1073
  const ghUser = useGitHubUser(basePath)
1087
1074
  const [settingsOpen, setSettingsOpen] = useState(false)
@@ -1291,8 +1278,6 @@ export default function Workspace({
1291
1278
  // Starred items for sidebar
1292
1279
  const starredItems = useMemo(() => visibleItems.filter(i => starred.has(i.id)), [visibleItems, starred])
1293
1280
 
1294
- const _pageTitle = NAV_ITEMS.find(n => n.id === activeNav)?.label || 'All artifacts'
1295
-
1296
1281
  return (
1297
1282
  <div className={css.layout} {...themeAttrs}>
1298
1283
  {/* ─── Full-width Header ─── */}
@@ -5,7 +5,7 @@
5
5
  * onto the canvas, including coordinate conversion and file filtering.
6
6
  */
7
7
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
8
- import { fireEvent, render, act, waitFor } from '@testing-library/react'
8
+ import { render, act } from '@testing-library/react'
9
9
  import CanvasPage from './CanvasPage.jsx'
10
10
  import { addWidget, uploadImage } from './canvasApi.js'
11
11
 
@@ -57,9 +57,6 @@ const GH_INSTALL_URL = 'https://github.com/cli/cli'
57
57
  registerSmoothCorners()
58
58
  registerHotPoolDevLogs()
59
59
 
60
- /** Matches branch-deploy base path prefixes like /branch--my-feature/ */
61
- const _BRANCH_PREFIX_RE = /^\/branch--[^/]+/
62
-
63
60
  // Build a reverse map from story route paths → { storyId, route }
64
61
  const storyRouteIndex = new Map()
65
62
  for (const [storyId, data] of Object.entries(storyIndex || {})) {
@@ -415,6 +412,7 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
415
412
  return adjusted
416
413
  }, [rawFeatures, widget.props?.github, widget.props?.collapsed, widget.type, widget.id, connectorCount, allWidgets])
417
414
 
415
+ // eslint-disable-next-line react-hooks/preserve-manual-memoization
418
416
  const handleAction = useCallback((actionId, opts) => {
419
417
  if (actionId === 'delete') {
420
418
  onRemove?.(widget.id)
@@ -19,18 +19,6 @@ function getEndpointStyle(widgetType, side) {
19
19
  return connectorConfig[key]
20
20
  }
21
21
 
22
- const DOT_OUTSET = 8
23
-
24
- function _getDotOffset(anchor) {
25
- switch (anchor) {
26
- case 'top': return { dx: 0, dy: -DOT_OUTSET }
27
- case 'bottom': return { dx: 0, dy: DOT_OUTSET }
28
- case 'left': return { dx: -DOT_OUTSET, dy: 0 }
29
- case 'right': return { dx: DOT_OUTSET, dy: 0 }
30
- default: return { dx: 0, dy: 0 }
31
- }
32
- }
33
-
34
22
  /**
35
23
  * Render an endpoint shape (circle, arrow-start, arrow-end, or none) at the given point.
36
24
  * - "circle" (default): filled dot
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest'
1
+ import { describe, it, expect, beforeEach } from 'vitest'
2
2
  import { enableCanvasGuard, disableCanvasGuard, isCanvasGuardActive } from './canvasReloadGuard.js'
3
3
 
4
4
  describe('canvasReloadGuard', () => {
@@ -56,7 +56,7 @@ const MIN_PANE_HEIGHT_PX = 80
56
56
  * @param {() => void} props.onClose — close callback
57
57
  * @param {((panes: PaneConfig[]) => void)} [props.onPanesChange] — notify parent of pane changes
58
58
  */
59
- export default function ExpandedPane({ initialPanes, initialLayout, variant = 'modal', onClose, onPanesChange }) {
59
+ export default function ExpandedPane({ initialPanes, initialLayout, variant = 'modal', onClose }) {
60
60
  // Normalize to 2D layout: outer = columns, inner = rows
61
61
  const [layout, setLayout] = useState(() => {
62
62
  if (initialLayout) return initialLayout
@@ -85,7 +85,7 @@ export default function ExpandedPane({ initialPanes, initialLayout, variant = 'm
85
85
  const [rowRatios, setRowRatios] = useState(() =>
86
86
  layout.map((col) => col.map(() => 1)),
87
87
  )
88
- const [activePaneId, setActivePaneId] = useState(null)
88
+ const [, setActivePaneId] = useState(null)
89
89
 
90
90
  // Ref map: paneId → container DOM element (callback refs)
91
91
  const containerRefs = useRef(new Map())
@@ -96,7 +96,6 @@ export default function ExpandedPane({ initialPanes, initialLayout, variant = 'm
96
96
 
97
97
  const totalPanes = allPanes.length
98
98
  const isSplit = totalPanes >= 2
99
- const useFullLayout = isSplit || variant === 'full'
100
99
 
101
100
  // ── External pane attach/detach via useLayoutEffect ──
102
101
  useLayoutEffect(() => {
@@ -109,7 +108,7 @@ export default function ExpandedPane({ initialPanes, initialLayout, variant = 'm
109
108
  detachRefs.current.set(pane.id, detach)
110
109
  }
111
110
  return () => {
112
- for (const [id, detach] of detachRefs.current) {
111
+ for (const [, detach] of detachRefs.current) {
113
112
  detach?.()
114
113
  }
115
114
  detachRefs.current.clear()
@@ -456,7 +455,7 @@ function ColumnWithDivider({ colIndex, isLast, onDividerPointerDown, children })
456
455
  /**
457
456
  * Renders a pane within a row-split column, optionally followed by a horizontal row divider.
458
457
  */
459
- function PaneWithRowDivider({ pane, flex, isLast, colIndex, onRowDividerPointerDown, children }) {
458
+ function PaneWithRowDivider({ flex, isLast, colIndex, onRowDividerPointerDown, children }) {
460
459
  return (
461
460
  <>
462
461
  <div className={styles.pane} style={{ flex }}>
@@ -1,5 +1,5 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
- import { render, screen, fireEvent, act } from '@testing-library/react'
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
3
  import ExpandedPane from './ExpandedPane.jsx'
4
4
 
5
5
  // Mock createPortal to render inline for testing
@@ -135,7 +135,7 @@ describe('ExpandedPane', () => {
135
135
 
136
136
  describe('external pane attach/detach', () => {
137
137
  it('calls attach with container element on mount', async () => {
138
- const { pane, attach, detach } = makeExternalPane('term-1')
138
+ const { pane, attach } = makeExternalPane('term-1')
139
139
  render(<ExpandedPane initialPanes={[pane]} variant="full" onClose={vi.fn()} />)
140
140
  // useLayoutEffect runs synchronously in test
141
141
  expect(attach).toHaveBeenCalledOnce()
@@ -7,10 +7,9 @@ import WidgetWrapper from './WidgetWrapper.jsx'
7
7
  import ResizeHandle from './ResizeHandle.jsx'
8
8
  import { readProp, linkPreviewSchema } from './widgetProps.js'
9
9
  import ExpandedPane from './ExpandedPane.jsx'
10
- import { findAllConnectedSplitTargets, getSplitPaneLabel, buildPaneForWidget, buildSplitLayout } from './expandUtils.js'
10
+ import { findAllConnectedSplitTargets, buildPaneForWidget, buildSplitLayout } from './expandUtils.js'
11
11
  import styles from './LinkPreview.module.css'
12
12
 
13
- const VIDEO_EXT_RE = /\.(mp4|mov|webm|ogg)(\?[^)]*)?$/i
14
13
  const VIDEO_URL_LINE_RE = /^<p>\s*(https?:\/\/[^\s<]+\.(mp4|mov|webm|ogg)(?:\?[^\s<]*)?)\s*<\/p>$/gim
15
14
 
16
15
  /**
@@ -119,6 +118,7 @@ function GitHubIssueCard({ id, url, title, github, width, collapsed, expanded, e
119
118
  const kindLabel = getCommentKindLabel(github)
120
119
 
121
120
  // Prefer pre-rendered bodyHtml (has signed image URLs), fall back to remark for discussions
121
+ // eslint-disable-next-line react-hooks/preserve-manual-memoization
122
122
  const bodyHtml = useMemo(() => {
123
123
  if (github?.bodyHtml) return postProcessHtml(github.bodyHtml)
124
124
  return renderMarkdown(github?.body || '')
@@ -310,6 +310,7 @@ export function ExpandedMarkdownEditor({ content, onUpdate, editing, onToggleEdi
310
310
  const [renderedHtml, setRenderedHtml] = useState(rawHtml)
311
311
 
312
312
  useEffect(() => {
313
+ // eslint-disable-next-line react-hooks/set-state-in-effect
313
314
  setRenderedHtml(rawHtml)
314
315
  if (!rawHtml.includes('<code class="language-')) return
315
316
  let cancelled = false
@@ -61,7 +61,6 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
61
61
  }, [src, basePath, baseSegment])
62
62
 
63
63
  const scale = zoom / 100
64
- const isExternal = /^https?:\/\//.test(src || '')
65
64
 
66
65
  const [editing, setEditing] = useState(false)
67
66
  const [interactive, setInteractive] = useState(false)
@@ -105,6 +105,7 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
105
105
  if (!showCode || sourceCode !== null) return
106
106
  const story = getStoryData(storyId)
107
107
  if (!story?._storyModule) {
108
+ // eslint-disable-next-line react-hooks/set-state-in-effect
108
109
  setSourceCode('// Source not available')
109
110
  return
110
111
  }
@@ -313,7 +314,7 @@ function StoryExpandPane({ widgetId, storyId, exportName, splitMode, onClose })
313
314
  kind: 'react',
314
315
  render: () => url
315
316
  ? <iframe src={url} style={{ border: 'none', width: '100%', height: '100%', display: 'block' }} title={storyId} onLoad={(e) => e.target.blur()} />
316
- : <div style={{ padding: 32, color: 'var(--fgColor-muted)' }}>Story "{storyId}" not found</div>,
317
+ : <div style={{ padding: 32, color: 'var(--fgColor-muted)' }}>Story &quot;{storyId}&quot; not found</div>,
317
318
  }
318
319
  }
319
320
  return buildPaneForWidget(widget)
@@ -1,7 +1,7 @@
1
1
  import { useRef, useEffect, useCallback, useState, useMemo, forwardRef, useImperativeHandle } from 'react'
2
2
  import { readProp } from './widgetProps.js'
3
3
  import { schemas } from './widgetProps.js'
4
- import { getTerminalConfig, getTerminalDimensions, getStoryData } from '@dfosco/storyboard-core'
4
+ import { getTerminalConfig, getTerminalDimensions } from '@dfosco/storyboard-core'
5
5
  import { useOverride } from '../../hooks/useOverride.js'
6
6
  import { getSplitPaneLabel, findAllConnectedSplitTargets, buildPaneForWidget, buildSplitLayout } from './expandUtils.js'
7
7
  import ExpandedPane from './ExpandedPane.jsx'
@@ -79,46 +79,6 @@ function fitTerminalToElement(widgetId, containerEl) {
79
79
  }
80
80
  }
81
81
 
82
- const EMBED_TYPES = new Set(['prototype', 'story'])
83
-
84
- function findConnectedEmbed(widgetId) {
85
- const bridge = window.__storyboardCanvasBridgeState
86
- if (!bridge?.connectors || !bridge?.widgets) return null
87
- const connectedIds = new Set()
88
- for (const c of bridge.connectors) {
89
- if (c.start?.widgetId === widgetId) connectedIds.add(c.end?.widgetId)
90
- if (c.end?.widgetId === widgetId) connectedIds.add(c.start?.widgetId)
91
- }
92
- for (const w of bridge.widgets) {
93
- if (connectedIds.has(w.id) && EMBED_TYPES.has(w.type)) return w
94
- }
95
- return null
96
- }
97
-
98
- function buildEmbedUrl(widget) {
99
- if (!widget) return null
100
- const base = (typeof import.meta !== 'undefined' && import.meta.env?.BASE_URL) || '/'
101
- const baseClean = base.endsWith('/') ? base.slice(0, -1) : base
102
- if (widget.type === 'prototype') {
103
- const src = widget.props?.src
104
- if (!src) return null
105
- if (/^https?:\/\//.test(src)) return src
106
- return `${baseClean}${src.startsWith('/') ? '' : '/'}${src}?_sb_embed&_sb_hide_branch_bar`
107
- }
108
- if (widget.type === 'story') {
109
- const storyId = widget.props?.storyId
110
- const exportName = widget.props?.exportName
111
- if (!storyId) return null
112
- const storyData = getStoryData(storyId)
113
- if (storyData?._route) {
114
- const route = exportName ? `${storyData._route}?export=${exportName}` : storyData._route
115
- return `${baseClean}${route}`
116
- }
117
- return null
118
- }
119
- return null
120
- }
121
-
122
82
  const DEFAULT_THEME = {
123
83
  background: '#0d1117',
124
84
  foreground: '#e6edf3',
@@ -142,7 +102,7 @@ const DEFAULT_THEME = {
142
102
  brightWhite: '#f0f6fc',
143
103
  }
144
104
 
145
- export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizable, multiSelected }, ref) {
105
+ export default forwardRef(function TerminalWidget({ id, props, onUpdate, multiSelected }, ref) {
146
106
  const cfg = getTerminalConfig()
147
107
  const fontSize = cfg.fontSize ?? 13
148
108
  const agentId = props?.agentId || null
@@ -155,11 +115,6 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizab
155
115
  const prettyName = props?.prettyName || null
156
116
  const startupCommand = props?.startupCommand || null
157
117
 
158
- // Snap dimensions to cell grid so the terminal fills its container exactly
159
- const { cols, rows } = useMemo(
160
- () => calcDimensions(rawWidth, rawHeight, fontSize),
161
- [rawWidth, rawHeight, fontSize],
162
- )
163
118
  const width = rawWidth
164
119
  const height = rawHeight
165
120
  // Snapped dimensions computed from ghostty's actual cell metrics (set after open)
@@ -220,6 +175,7 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizab
220
175
 
221
176
  // Exit interactive when terminal becomes part of a multi-selection
222
177
  useEffect(() => {
178
+ // eslint-disable-next-line react-hooks/set-state-in-effect
223
179
  if (multiSelected && interactive) setInteractive(false)
224
180
  }, [multiSelected])
225
181
 
@@ -385,6 +341,7 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizab
385
341
  // Reveal mask — hide terminal for 750ms after ready to mask startup flash
386
342
  useEffect(() => {
387
343
  if (!ready) {
344
+ // eslint-disable-next-line react-hooks/set-state-in-effect
388
345
  setRevealed(false)
389
346
  return
390
347
  }
@@ -47,8 +47,6 @@ const TilesWidget = forwardRef(function TilesWidget({ id, props, onUpdate, resiz
47
47
  const columns = (isProd ? localState?.columns : null) ?? (readProp(props, 'columns', tilesSchema) || 3)
48
48
  const rows = (isProd ? localState?.rows : null) ?? (readProp(props, 'rows', tilesSchema) || 3)
49
49
  const tileSize = readProp(props, 'tileSize', tilesSchema) || 80
50
- const width = readProp(props, 'width', tilesSchema)
51
- const height = readProp(props, 'height', tilesSchema)
52
50
  const savedTiles = (isProd ? localState?.tiles : null) ?? readProp(props, 'tiles', tilesSchema)
53
51
 
54
52
  // Local state for interactions
package/src/context.jsx CHANGED
@@ -1,7 +1,7 @@
1
1
  import { useState, useEffect, useMemo, Suspense, lazy } from 'react'
2
2
  import { useParams, useLocation } from 'react-router-dom'
3
3
  // Named import seeds the core data index via init() AND provides canvas/story route data
4
- import { canvases, canvasAliases, stories } from 'virtual:storyboard-data-index'
4
+ import { canvases, stories } from 'virtual:storyboard-data-index'
5
5
  import { loadFlow, flowExists, findRecord, deepMerge, setFlowClass, installBodyClassSync, resolveFlowName, resolveRecordName, isModesEnabled } from '@dfosco/storyboard-core'
6
6
  import { StoryboardContext } from './StoryboardContext.js'
7
7
  import usePrototypeReloadGuard from './hooks/usePrototypeReloadGuard.js'
@@ -36,6 +36,7 @@ export function useFlowData(path, opts) {
36
36
  const storageString = useSyncExternalStore(subscribeToStorage, getStorageSnapshot)
37
37
 
38
38
  // Collect overrides relevant to this path
39
+ // eslint-disable-next-line react-hooks/preserve-manual-memoization
39
40
  const result = useMemo(() => {
40
41
  if (loading || error || data == null) return undefined
41
42
 
@@ -1,5 +1,5 @@
1
1
  import { renderHook, act } from '@testing-library/react'
2
- import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import { describe, it, expect, beforeEach } from 'vitest'
3
3
  import { setTheme, setThemeSyncTarget } from '@dfosco/storyboard-core'
4
4
  import { useThemeState, useThemeSyncTargets } from './useThemeState.js'
5
5
 
@@ -49,10 +49,6 @@ function parseDataFile(filePath) {
49
49
  const canvasCheck = normalized.match(/(?:^|\/)src\/canvas\//)
50
50
  if (canvasCheck) {
51
51
  const dirPath = normalized.substring(0, normalized.lastIndexOf('/'))
52
- const routeBase = (dirPath + '/')
53
- .replace(/^.*?src\/canvas\//, '')
54
- .replace(/[^/]*\.folder\/?/g, '')
55
- .replace(/\/$/, '')
56
52
  // Path-based ID: include folder context for uniqueness.
57
53
  // .folder dirs contribute their name (sans .folder suffix) to the ID.
58
54
  const idBase = (dirPath + '/')
@@ -192,39 +188,6 @@ function parseDataFile(filePath) {
192
188
  return { name, suffix, ext: match[3], inferredRoute }
193
189
  }
194
190
 
195
- /**
196
- * Look up the git author who first created a file.
197
- * Used to auto-fill the author field in .prototype.json when missing.
198
- */
199
- function getGitAuthor(root, filePath) {
200
- try {
201
- const result = execSync(
202
- `git log --follow --diff-filter=A --format="%aN" -- "${filePath}"`,
203
- { cwd: root, encoding: 'utf-8', timeout: 5000 },
204
- ).trim()
205
- const lines = result.split('\n').filter(Boolean)
206
- return lines.length > 0 ? lines[lines.length - 1] : null
207
- } catch {
208
- return null
209
- }
210
- }
211
-
212
- /**
213
- * Look up the most recent commit date for any file in a directory.
214
- * Returns an ISO 8601 timestamp, or null if unavailable.
215
- */
216
- function getLastModified(root, dirPath) {
217
- try {
218
- const result = execSync(
219
- `git log -1 --format="%aI" -- "${dirPath}"`,
220
- { cwd: root, encoding: 'utf-8', timeout: 5000 },
221
- ).trim()
222
- return result || null
223
- } catch {
224
- return null
225
- }
226
- }
227
-
228
191
  /**
229
192
  * Batch-fetch git metadata (author + lastModified) for multiple files in a
230
193
  * single subprocess, avoiding per-file git overhead during startup.
@@ -570,6 +533,15 @@ function deepMergeBuild(target, source) {
570
533
  const tv = target[key]
571
534
  if (sv && typeof sv === 'object' && !Array.isArray(sv) && tv && typeof tv === 'object' && !Array.isArray(tv)) {
572
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()]
573
545
  } else {
574
546
  result[key] = sv
575
547
  }
@@ -581,84 +553,104 @@ function deepMergeBuild(target, source) {
581
553
  * Build the unified config object by reading and merging all config sources.
582
554
  *
583
555
  * Priority (lowest → highest):
584
- * core defaults → user widgets user paste user toolbar → user commandpalette → storyboard.config.json
556
+ * configSchema defaults → core domain configsstoryboard.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.
585
562
  *
586
563
  * Returns { unified, warnings } where warnings is an array of overlap messages.
587
564
  */
588
565
  function buildUnifiedConfig(root) {
589
566
  const warnings = []
590
567
 
591
- // 1. Read core defaults
568
+ // 1. Read core defaults (lowest priority domain configs)
592
569
  const coreToolbar = readCoreConfigFile(root, 'toolbar.config.json') || {}
593
570
  const coreCommandPalette = readCoreConfigFile(root, 'commandpalette.config.json') || {}
594
571
  const corePaste = readCoreConfigFile(root, 'paste.config.json') || {}
595
572
  const coreWidgets = readCoreConfigFile(root, 'widgets.config.json') || {}
596
573
 
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)
574
+ // 2. Read storyboard.config.json (middle priority)
613
575
  // Use the schema-defaulted config for most things, but also read
614
576
  // the raw file to know which keys were explicitly set by the user.
615
577
  const { config: sbConfig } = readConfig(root)
616
578
  const rawSbConfig = readJsonFile(path.resolve(root, 'storyboard.config.json')) || {}
617
579
 
618
- // 4. Merge core defaults with user overrides per domain
619
- const toolbar = userConfigs.toolbar
620
- ? deepMergeBuild(coreToolbar, userConfigs.toolbar.data)
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)
621
585
  : coreToolbar
622
- const commandPalette = userConfigs.commandPalette
623
- ? deepMergeBuild(coreCommandPalette, userConfigs.commandPalette.data)
586
+ const afterSbCommandPalette = rawSbConfig.commandPalette
587
+ ? deepMergeBuild(coreCommandPalette, sbConfig.commandPalette)
624
588
  : coreCommandPalette
625
- const paste = userConfigs.paste
626
- ? deepMergeBuild(corePaste, userConfigs.paste.data)
589
+ const afterSbPaste = rawSbConfig.paste
590
+ ? deepMergeBuild(corePaste, sbConfig.paste || {})
627
591
  : corePaste
628
- const widgets = userConfigs.widgets
629
- ? deepMergeBuild(coreWidgets, userConfigs.widgets.data)
592
+ const afterSbWidgets = rawSbConfig.widgets
593
+ ? deepMergeBuild(coreWidgets, sbConfig.widgets || {})
630
594
  : coreWidgets
631
595
 
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
- }
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 }
648
609
  }
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.`)
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
+ }
653
638
  }
654
639
  }
655
640
 
656
641
  // 7. Build the unified config object
642
+ console.log('[storyboard] [devlog] buildUnifiedConfig:', {
643
+ coreCPSections: coreCommandPalette?.sections?.length,
644
+ afterSbCPSections: afterSbCommandPalette?.sections?.length,
645
+ finalCPSections: finalCommandPalette?.sections?.length,
646
+ rawSbHasCP: !!rawSbConfig.commandPalette,
647
+ userHasCP: !!userConfigs.commandPalette,
648
+ })
657
649
  const unified = {
658
650
  toolbar: finalToolbar,
659
651
  commandPalette: finalCommandPalette,
660
- paste,
661
- widgets,
652
+ paste: finalPaste,
653
+ widgets: finalWidgets,
662
654
  featureFlags: sbConfig?.featureFlags || {},
663
655
  modes: sbConfig?.modes || {},
664
656
  ui: sbConfig?.ui || {},
@@ -1328,13 +1320,19 @@ export default function storyboardDataPlugin() {
1328
1320
  const { configPath } = readConfig(root)
1329
1321
  watcher.add(configPath)
1330
1322
 
1331
- // Watch root toolbar.config.json for changes
1332
- const clientToolbarConfigPath = path.resolve(root, 'toolbar.config.json')
1333
- watcher.add(clientToolbarConfigPath)
1323
+ // Watch all root domain config files for changes
1324
+ const domainConfigFiles = [
1325
+ 'toolbar.config.json',
1326
+ 'commandpalette.config.json',
1327
+ 'paste.config.json',
1328
+ 'widgets.config.json',
1329
+ ].map(f => path.resolve(root, f))
1330
+ const watchedConfigPaths = new Set([configPath, ...domainConfigFiles])
1331
+ for (const p of domainConfigFiles) watcher.add(p)
1334
1332
 
1335
1333
  const invalidateConfig = (filePath) => {
1336
1334
  const resolved = path.resolve(filePath)
1337
- if (resolved === configPath || resolved === clientToolbarConfigPath) {
1335
+ if (watchedConfigPaths.has(resolved)) {
1338
1336
  buildResult = null
1339
1337
  const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
1340
1338
  if (mod) {
@@ -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'