@dfosco/storyboard-react 4.2.0-beta.25 → 4.2.0-beta.27

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.25",
3
+ "version": "4.2.0-beta.27",
4
4
  "type": "module",
5
5
  "dependencies": {
6
6
  "@base-ui/react": "^1.4.0",
7
- "@dfosco/storyboard-core": "4.2.0-beta.25",
8
- "@dfosco/tiny-canvas": "4.2.0-beta.25",
7
+ "@dfosco/storyboard-core": "4.2.0-beta.27",
8
+ "@dfosco/tiny-canvas": "4.2.0-beta.27",
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",
@@ -6,7 +6,6 @@
6
6
  */
7
7
  import { useState, useEffect, useCallback } from 'react'
8
8
  import { Dialog } from '@base-ui/react/dialog'
9
- import { Button } from '@base-ui/react/button'
10
9
  import css from './AuthModal.module.css'
11
10
 
12
11
  const COMMENTS_TOKEN_KEY = 'sb-comments-token'
@@ -31,11 +30,6 @@ export default function AuthModal() {
31
30
  return () => document.removeEventListener('storyboard:open-auth-modal', handleOpen)
32
31
  }, [])
33
32
 
34
- const handleClose = useCallback(() => {
35
- setOpen(false)
36
- setTokenValue('')
37
- }, [])
38
-
39
33
  const handleSignIn = useCallback(() => {
40
34
  const trimmed = tokenValue.trim()
41
35
  if (!trimmed) return
@@ -86,6 +86,7 @@ function isHiddenInPalette(tool, basePath) {
86
86
  function buildConfigSections(prefix, onNavigateToPage, onCreateAction) {
87
87
  const config = getCommandPaletteConfig()
88
88
  const sections = config?.sections || []
89
+ console.log('[devlog] CommandPalette buildConfigSections:', { sectionsCount: sections.length, providers: config?.providers, configKeys: Object.keys(config || {}) })
89
90
  const groups = []
90
91
  const toolMenus = []
91
92
  const usedToolIds = new Set()
@@ -417,7 +418,7 @@ function buildDynamicSection(section, prefix, onNavigateToPage, onCreateAction)
417
418
  if (section.source === 'starred') {
418
419
  const STARRED_KEY = 'sb-workspace-starred'
419
420
  let starredIds = []
420
- try { starredIds = JSON.parse(localStorage.getItem(STARRED_KEY)) || [] } catch {}
421
+ try { starredIds = JSON.parse(localStorage.getItem(STARRED_KEY)) || [] } catch { /* empty */ }
421
422
  if (starredIds.length === 0) return null
422
423
 
423
424
  const index = buildPrototypeIndex()
@@ -639,7 +640,7 @@ function buildToolsSection(section, prefix, onNavigateToPage) {
639
640
  const items = []
640
641
  const subPages = []
641
642
 
642
- for (const { toolId, tool, label, toolIcon, toolMeta, closeOnSelect: entryCloseOnSelect } of entries) {
643
+ for (const { toolId, tool, label, closeOnSelect: entryCloseOnSelect } of entries) {
643
644
  // Inline actions
644
645
  if (tool.inlineAction === 'toggle-chrome') {
645
646
  const isHidden = document.documentElement.classList.contains('storyboard-chrome-hidden')
@@ -994,6 +995,7 @@ export default function StoryboardCommandPalette({ basePath }) {
994
995
  useEffect(() => {
995
996
  if (refreshKey === 0) return
996
997
  const built = buildPaletteItems(basePath, handleCreateAction, handleNavigateToPage)
998
+ // eslint-disable-next-line react-hooks/set-state-in-effect
997
999
  setItems(built.groups)
998
1000
  setToolMenus(built.toolMenus)
999
1001
  }, [refreshKey, basePath])
@@ -1196,7 +1198,7 @@ export default function StoryboardCommandPalette({ basePath }) {
1196
1198
  !search && <Command.Separator key={list.id} />
1197
1199
  ) : (
1198
1200
  <Command.Group key={list.id} heading={list.heading}>
1199
- {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 }) => {
1200
1202
  if (search && hideFromSearch) return null
1201
1203
  if (hiddenFromSearchIds.size > 0) {
1202
1204
  for (const toolId of hiddenFromSearchIds) {
@@ -5,8 +5,8 @@
5
5
  * Formerly known as Viewfinder — renamed to match the /workspace route.
6
6
  */
7
7
  import { useState, useEffect, useRef, useMemo, useCallback, useSyncExternalStore } from 'react'
8
- import { buildPrototypeIndex, listStories, getStoryData, getLocal, setLocal, BranchSelect } from '@dfosco/storyboard-core'
9
- import { MarkGithubIcon, GitBranchIcon, ChevronDownIcon, ChevronRightIcon, FileDirectoryFillIcon, PlusIcon, StarIcon, StarFillIcon, ThreeBarsIcon, XIcon, StackIcon, TrashIcon, ShieldLockIcon, KebabHorizontalIcon, PencilIcon } from '@primer/octicons-react'
8
+ import { buildPrototypeIndex, listStories, getStoryData, BranchSelect } from '@dfosco/storyboard-core'
9
+ import { MarkGithubIcon, GitBranchIcon, ChevronDownIcon, ChevronRightIcon, PlusIcon, StarIcon, StarFillIcon, ThreeBarsIcon, XIcon, StackIcon, TrashIcon, ShieldLockIcon, KebabHorizontalIcon, PencilIcon } from '@primer/octicons-react'
10
10
  import { Menu } from '@base-ui/react/menu'
11
11
  import { Dialog } from '@base-ui/react/dialog'
12
12
  import Icon from './Icon.jsx'
@@ -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)
@@ -1258,7 +1245,7 @@ export default function Workspace({
1258
1245
  const toggleGrouping = useCallback(() => {
1259
1246
  setGroupByFolders(prev => {
1260
1247
  const next = !prev
1261
- try { localStorage.setItem(GROUP_BY_FOLDERS_KEY, String(next)) } catch {}
1248
+ try { localStorage.setItem(GROUP_BY_FOLDERS_KEY, String(next)) } catch { /* empty */ }
1262
1249
  return next
1263
1250
  })
1264
1251
  }, [])
@@ -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, afterEach } from 'vitest'
1
+ import { describe, it, expect, beforeEach } from 'vitest'
2
2
  import { render, screen, fireEvent, waitFor } from '@testing-library/react'
3
3
  import PageSelector from './PageSelector.jsx'
4
4
 
@@ -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
@@ -66,6 +66,7 @@ export default function ExpandedPane({ initialPanes, initialLayout, variant = 'm
66
66
 
67
67
  // Sync layout when initialLayout changes (preserves column/row sizes)
68
68
  useEffect(() => {
69
+ // eslint-disable-next-line react-hooks/set-state-in-effect
69
70
  if (initialLayout) setLayout(initialLayout)
70
71
  }, [initialLayout])
71
72
 
@@ -84,7 +85,7 @@ export default function ExpandedPane({ initialPanes, initialLayout, variant = 'm
84
85
  const [rowRatios, setRowRatios] = useState(() =>
85
86
  layout.map((col) => col.map(() => 1)),
86
87
  )
87
- const [activePaneId, setActivePaneId] = useState(null)
88
+ const [, setActivePaneId] = useState(null)
88
89
 
89
90
  // Ref map: paneId → container DOM element (callback refs)
90
91
  const containerRefs = useRef(new Map())
@@ -95,7 +96,6 @@ export default function ExpandedPane({ initialPanes, initialLayout, variant = 'm
95
96
 
96
97
  const totalPanes = allPanes.length
97
98
  const isSplit = totalPanes >= 2
98
- const useFullLayout = isSplit || variant === 'full'
99
99
 
100
100
  // ── External pane attach/detach via useLayoutEffect ──
101
101
  useLayoutEffect(() => {
@@ -108,12 +108,14 @@ export default function ExpandedPane({ initialPanes, initialLayout, variant = 'm
108
108
  detachRefs.current.set(pane.id, detach)
109
109
  }
110
110
  return () => {
111
- for (const [id, detach] of detachRefs.current) {
111
+ for (const [, detach] of detachRefs.current) {
112
112
  detach?.()
113
113
  }
114
114
  detachRefs.current.clear()
115
115
  }
116
- }, []) // eslint-disable-line react-hooks/exhaustive-deps — intentional mount-only
116
+ // intentional mount-only
117
+ // eslint-disable-next-line react-hooks/exhaustive-deps
118
+ }, [])
117
119
 
118
120
  // Handle pane list changes: attach new external panes, detach removed ones
119
121
  useLayoutEffect(() => {
@@ -453,7 +455,7 @@ function ColumnWithDivider({ colIndex, isLast, onDividerPointerDown, children })
453
455
  /**
454
456
  * Renders a pane within a row-split column, optionally followed by a horizontal row divider.
455
457
  */
456
- function PaneWithRowDivider({ pane, flex, isLast, colIndex, onRowDividerPointerDown, children }) {
458
+ function PaneWithRowDivider({ flex, isLast, colIndex, onRowDividerPointerDown, children }) {
457
459
  return (
458
460
  <>
459
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 || '')
@@ -105,6 +105,7 @@ export default forwardRef(function MarkdownBlock({ id, props, onUpdate, resizabl
105
105
 
106
106
  // Async-highlight code blocks after initial render or theme change
107
107
  useEffect(() => {
108
+ // eslint-disable-next-line react-hooks/set-state-in-effect
108
109
  setRenderedHtml(rawHtml)
109
110
  if (!rawHtml.includes('<code class="language-')) return
110
111
  let cancelled = false
@@ -149,6 +150,7 @@ export default forwardRef(function MarkdownBlock({ id, props, onUpdate, resizabl
149
150
  }
150
151
  }
151
152
  } else {
153
+ // eslint-disable-next-line react-hooks/set-state-in-effect
152
154
  setEditHeight(null)
153
155
  }
154
156
  }, [editingActive])
@@ -308,6 +310,7 @@ export function ExpandedMarkdownEditor({ content, onUpdate, editing, onToggleEdi
308
310
  const [renderedHtml, setRenderedHtml] = useState(rawHtml)
309
311
 
310
312
  useEffect(() => {
313
+ // eslint-disable-next-line react-hooks/set-state-in-effect
311
314
  setRenderedHtml(rawHtml)
312
315
  if (!rawHtml.includes('<code class="language-')) return
313
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
@@ -451,6 +451,7 @@ export default function WidgetChrome({
451
451
  <div className={`${styles.toolbarContent} ${showToolbar ? styles.toolbarContentVisible : ''}`}>
452
452
  {showFeatures && (
453
453
  <div className={styles.featureButtons}>
454
+ {/* eslint-disable-next-line react-hooks/refs */}
454
455
  {features.map((feature) => {
455
456
  // Menu features are rendered in WidgetOverflowMenu
456
457
  if (feature.menu) return null
@@ -22,7 +22,9 @@ export { isSplitScreenCapable }
22
22
  */
23
23
  function MarkdownSecondaryPane({ widget, editingRef, onUpdate }) {
24
24
  const [editing, setEditing] = useState(false)
25
+ // eslint-disable-next-line react-hooks/refs
25
26
  editingRef.current = editing
27
+ // eslint-disable-next-line react-hooks/refs
26
28
  editingRef.setter = (v) => {
27
29
  setEditing(v)
28
30
  // Notify ExpandedPane to re-render so titlebar features resolve updated toggle state
@@ -31,6 +33,7 @@ function MarkdownSecondaryPane({ widget, editingRef, onUpdate }) {
31
33
 
32
34
  const content = widget.props?.content || ''
33
35
 
36
+ // eslint-disable-next-line react-hooks/refs
34
37
  return createElement(ExpandedMarkdownEditor, {
35
38
  content,
36
39
  onUpdate,
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.
@@ -581,84 +544,104 @@ function deepMergeBuild(target, source) {
581
544
  * Build the unified config object by reading and merging all config sources.
582
545
  *
583
546
  * Priority (lowest → highest):
584
- * core defaults → user widgets user paste user toolbar → user commandpalette → storyboard.config.json
547
+ * configSchema defaults → core domain configsstoryboard.config.json → user domain configs
548
+ *
549
+ * Domain-specific config files (toolbar.config.json, commandpalette.config.json, etc.)
550
+ * always win over storyboard.config.json — specificity beats generality.
551
+ * Deep merge is used at every layer: objects are recursively merged (keys append),
552
+ * arrays and scalars are replaced.
585
553
  *
586
554
  * Returns { unified, warnings } where warnings is an array of overlap messages.
587
555
  */
588
556
  function buildUnifiedConfig(root) {
589
557
  const warnings = []
590
558
 
591
- // 1. Read core defaults
559
+ // 1. Read core defaults (lowest priority domain configs)
592
560
  const coreToolbar = readCoreConfigFile(root, 'toolbar.config.json') || {}
593
561
  const coreCommandPalette = readCoreConfigFile(root, 'commandpalette.config.json') || {}
594
562
  const corePaste = readCoreConfigFile(root, 'paste.config.json') || {}
595
563
  const coreWidgets = readCoreConfigFile(root, 'widgets.config.json') || {}
596
564
 
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)
565
+ // 2. Read storyboard.config.json (middle priority)
613
566
  // Use the schema-defaulted config for most things, but also read
614
567
  // the raw file to know which keys were explicitly set by the user.
615
568
  const { config: sbConfig } = readConfig(root)
616
569
  const rawSbConfig = readJsonFile(path.resolve(root, 'storyboard.config.json')) || {}
617
570
 
618
- // 4. Merge core defaults with user overrides per domain
619
- const toolbar = userConfigs.toolbar
620
- ? deepMergeBuild(coreToolbar, userConfigs.toolbar.data)
571
+ // 3. Apply storyboard.config.json overrides on top of core domain configs.
572
+ // Only merge when the user explicitly defined the key in storyboard.config.json
573
+ // (not from configSchema defaults, which would overwrite core config with empty arrays).
574
+ const afterSbToolbar = rawSbConfig.toolbar
575
+ ? deepMergeBuild(coreToolbar, sbConfig.toolbar)
621
576
  : coreToolbar
622
- const commandPalette = userConfigs.commandPalette
623
- ? deepMergeBuild(coreCommandPalette, userConfigs.commandPalette.data)
577
+ const afterSbCommandPalette = rawSbConfig.commandPalette
578
+ ? deepMergeBuild(coreCommandPalette, sbConfig.commandPalette)
624
579
  : coreCommandPalette
625
- const paste = userConfigs.paste
626
- ? deepMergeBuild(corePaste, userConfigs.paste.data)
580
+ const afterSbPaste = rawSbConfig.paste
581
+ ? deepMergeBuild(corePaste, sbConfig.paste || {})
627
582
  : corePaste
628
- const widgets = userConfigs.widgets
629
- ? deepMergeBuild(coreWidgets, userConfigs.widgets.data)
583
+ const afterSbWidgets = rawSbConfig.widgets
584
+ ? deepMergeBuild(coreWidgets, sbConfig.widgets || {})
630
585
  : coreWidgets
631
586
 
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
- }
587
+ // 4. Read user domain config files (highest priority)
588
+ const userFiles = [
589
+ { domain: 'widgets', filename: 'widgets.config.json' },
590
+ { domain: 'paste', filename: 'paste.config.json' },
591
+ { domain: 'toolbar', filename: 'toolbar.config.json' },
592
+ { domain: 'commandPalette', filename: 'commandpalette.config.json' },
593
+ ]
594
+
595
+ const userConfigs = {}
596
+ for (const { domain, filename } of userFiles) {
597
+ const filePath = path.resolve(root, filename)
598
+ const parsed = readJsonFile(filePath)
599
+ if (parsed) userConfigs[domain] = { data: parsed, filename }
648
600
  }
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.`)
601
+
602
+ // 5. Apply user domain configs on top of everything (highest priority)
603
+ const finalToolbar = userConfigs.toolbar
604
+ ? deepMergeBuild(afterSbToolbar, userConfigs.toolbar.data)
605
+ : afterSbToolbar
606
+ const finalCommandPalette = userConfigs.commandPalette
607
+ ? deepMergeBuild(afterSbCommandPalette, userConfigs.commandPalette.data)
608
+ : afterSbCommandPalette
609
+ const finalPaste = userConfigs.paste
610
+ ? deepMergeBuild(afterSbPaste, userConfigs.paste.data)
611
+ : afterSbPaste
612
+ const finalWidgets = userConfigs.widgets
613
+ ? deepMergeBuild(afterSbWidgets, userConfigs.widgets.data)
614
+ : afterSbWidgets
615
+
616
+ // 6. Detect overlaps between storyboard.config.json and user domain configs
617
+ const domainOverlapChecks = [
618
+ { sbKey: 'toolbar', domain: 'toolbar', label: 'toolbar.config.json' },
619
+ { sbKey: 'commandPalette', domain: 'commandPalette', label: 'commandpalette.config.json' },
620
+ { sbKey: 'paste', domain: 'paste', label: 'paste.config.json' },
621
+ { sbKey: 'widgets', domain: 'widgets', label: 'widgets.config.json' },
622
+ ]
623
+ for (const { sbKey, domain, label } of domainOverlapChecks) {
624
+ if (rawSbConfig[sbKey] && userConfigs[domain]) {
625
+ const overlaps = findOverlappingKeys(rawSbConfig[sbKey], userConfigs[domain].data)
626
+ for (const key of overlaps) {
627
+ warnings.push(`Config overlap: "${key}" is defined in both storyboard.config.json.${sbKey} and ${label} — ${label} wins.`)
628
+ }
653
629
  }
654
630
  }
655
631
 
656
632
  // 7. Build the unified config object
633
+ console.log('[storyboard] [devlog] buildUnifiedConfig:', {
634
+ coreCPSections: coreCommandPalette?.sections?.length,
635
+ afterSbCPSections: afterSbCommandPalette?.sections?.length,
636
+ finalCPSections: finalCommandPalette?.sections?.length,
637
+ rawSbHasCP: !!rawSbConfig.commandPalette,
638
+ userHasCP: !!userConfigs.commandPalette,
639
+ })
657
640
  const unified = {
658
641
  toolbar: finalToolbar,
659
642
  commandPalette: finalCommandPalette,
660
- paste,
661
- widgets,
643
+ paste: finalPaste,
644
+ widgets: finalWidgets,
662
645
  featureFlags: sbConfig?.featureFlags || {},
663
646
  modes: sbConfig?.modes || {},
664
647
  ui: sbConfig?.ui || {},
@@ -1328,13 +1311,19 @@ export default function storyboardDataPlugin() {
1328
1311
  const { configPath } = readConfig(root)
1329
1312
  watcher.add(configPath)
1330
1313
 
1331
- // Watch root toolbar.config.json for changes
1332
- const clientToolbarConfigPath = path.resolve(root, 'toolbar.config.json')
1333
- watcher.add(clientToolbarConfigPath)
1314
+ // Watch all root domain config files for changes
1315
+ const domainConfigFiles = [
1316
+ 'toolbar.config.json',
1317
+ 'commandpalette.config.json',
1318
+ 'paste.config.json',
1319
+ 'widgets.config.json',
1320
+ ].map(f => path.resolve(root, f))
1321
+ const watchedConfigPaths = new Set([configPath, ...domainConfigFiles])
1322
+ for (const p of domainConfigFiles) watcher.add(p)
1334
1323
 
1335
1324
  const invalidateConfig = (filePath) => {
1336
1325
  const resolved = path.resolve(filePath)
1337
- if (resolved === configPath || resolved === clientToolbarConfigPath) {
1326
+ if (watchedConfigPaths.has(resolved)) {
1338
1327
  buildResult = null
1339
1328
  const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
1340
1329
  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'