@dfosco/storyboard-react 4.2.0-alpha.15 → 4.2.0-alpha.16

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-alpha.15",
3
+ "version": "4.2.0-alpha.16",
4
4
  "type": "module",
5
5
  "dependencies": {
6
6
  "@base-ui/react": "^1.4.0",
7
- "@dfosco/storyboard-core": "4.2.0-alpha.15",
8
- "@dfosco/tiny-canvas": "4.2.0-alpha.15",
7
+ "@dfosco/storyboard-core": "4.2.0-alpha.16",
8
+ "@dfosco/tiny-canvas": "4.2.0-alpha.16",
9
9
  "@neodrag/react": "^2.3.1",
10
10
  "glob": "^11.0.0",
11
11
  "jsonc-parser": "^3.3.1",
@@ -1,8 +1,8 @@
1
1
  /**
2
- * BranchBar — dark top bar showing current branch on non-main routes.
2
+ * BranchBar — blue accent bar showing current branch and local dev status.
3
3
  *
4
- * Dev: shows branch name as a static label (use CLI to switch branches).
5
- * Prod: same label (dropdown switching deferred to ViewfinderNew).
4
+ * Dev: always visible (main or branch). Shows "Local development" label.
5
+ * Prod: shows on non-main branches only.
6
6
  */
7
7
  import { useState, useEffect, useMemo } from 'react'
8
8
  import { GitBranchIcon } from '@primer/octicons-react'
@@ -22,6 +22,7 @@ export default function BranchBar({ basePath }) {
22
22
  return m ? m[1] : 'main'
23
23
  }, [basePath])
24
24
 
25
+ const isLocalDev = import.meta.env.DEV
25
26
  const isOnBranch = currentBranch !== 'main'
26
27
 
27
28
  useEffect(() => {
@@ -32,7 +33,7 @@ export default function BranchBar({ basePath }) {
32
33
  return () => observer.disconnect()
33
34
  }, [])
34
35
 
35
- if (!isOnBranch || hidden || isHiddenByParam) return null
36
+ if ((!isOnBranch && !isLocalDev) || hidden || isHiddenByParam) return null
36
37
 
37
38
  function hideChrome() {
38
39
  window.dispatchEvent(new KeyboardEvent('keydown', {
@@ -46,6 +47,8 @@ export default function BranchBar({ basePath }) {
46
47
  <span className={css.barLabel}>
47
48
  <GitBranchIcon size={12} />
48
49
  <span className={css.barBranchName}>{currentBranch}</span>
50
+ <span className={css.barSeparator}>·</span>
51
+ <span>Local development</span>
49
52
  </span>
50
53
  <div className={css.barActions}>
51
54
  <button className={css.barAction} onClick={hideChrome}>Hide</button>
@@ -16,8 +16,8 @@
16
16
  justify-content: center;
17
17
  gap: 8px;
18
18
  height: 32px;
19
- background: var(--bgColor-emphasis, #1a1a1a);
20
- color: var(--fgColor-onEmphasis, #ccc);
19
+ background: hsl(212, 92%, 45%);
20
+ color: #fff;
21
21
  padding: 4px 12px;
22
22
  position: relative;
23
23
  }
@@ -68,6 +68,11 @@
68
68
  white-space: nowrap;
69
69
  }
70
70
 
71
+ .barSeparator {
72
+ opacity: 0.6;
73
+ margin: 0 2px;
74
+ }
75
+
71
76
  .barAction {
72
77
  background: none;
73
78
  border: none;
@@ -204,6 +204,11 @@ function buildConfigSections(prefix, onNavigateToPage, onCreateAction) {
204
204
  continue
205
205
  }
206
206
 
207
+ if (tool.inlineAction === 'open-palette') {
208
+ // Skip — no point opening the palette from within itself
209
+ continue
210
+ }
211
+
207
212
  // Any remaining tools (all surfaces)
208
213
  if (tool.render === 'link' && tool.url) {
209
214
  remainingItems.push({
@@ -489,7 +494,7 @@ function buildDynamicSection(section, prefix, onNavigateToPage, onCreateAction)
489
494
  /**
490
495
  * Build a section from toolbar.config.json tools.
491
496
  * If toolIds is provided, only include those tools in that order (with optional custom labels).
492
- * Otherwise include all command-list tools.
497
+ * Otherwise include all command-palette tools.
493
498
  *
494
499
  * toolIds format: ["theme", "flows"] or [{ id: "theme", label: "Change theme" }]
495
500
  */
@@ -515,7 +520,7 @@ function buildToolsSection(section, prefix, onNavigateToPage) {
515
520
  }
516
521
  } else {
517
522
  for (const [toolId, tool] of Object.entries(tools)) {
518
- if (tool.surface !== 'command-list') continue
523
+ if (tool.surface !== 'command-palette') continue
519
524
  const state = getToolbarToolState(toolId)
520
525
  if (state === 'disabled' || state === 'hidden') continue
521
526
  if (isHiddenInPalette(tool, basePath)) continue
@@ -543,6 +548,19 @@ function buildToolsSection(section, prefix, onNavigateToPage) {
543
548
  continue
544
549
  }
545
550
 
551
+ if (tool.inlineAction === 'open-palette') {
552
+ items.push({
553
+ id: `cfg:${section.id}:${toolId}`,
554
+ children: label,
555
+ keywords: [label, toolId, 'command', 'palette', 'search'].filter(Boolean),
556
+ showType: false,
557
+ onClick: () => {
558
+ document.dispatchEvent(new CustomEvent('storyboard:open-palette'))
559
+ },
560
+ })
561
+ continue
562
+ }
563
+
546
564
  if (tool.render === 'link' && tool.url) {
547
565
  items.push({
548
566
  id: `cfg:${section.id}:${toolId}`,
package/src/Icon.jsx CHANGED
@@ -15,6 +15,10 @@
15
15
  /* ─── Custom SVG paths (fill-based, no namespace prefix) ─── */
16
16
 
17
17
  const customIcons = {
18
+ 'home': {
19
+ viewBox: '0 0 16 16',
20
+ path: 'M6.906.664a1.749 1.749 0 0 1 2.187 0l5.25 4.2c.415.332.657.835.657 1.367v7.019A1.75 1.75 0 0 1 13.25 15h-3.5a.75.75 0 0 1-.75-.75V9H7v5.25a.75.75 0 0 1-.75.75h-3.5A1.75 1.75 0 0 1 1 13.25V6.23c0-.531.242-1.034.657-1.366l5.25-4.2Zm1.25 1.171a.25.25 0 0 0-.312 0l-5.25 4.2a.25.25 0 0 0-.094.196v7.019c0 .138.112.25.25.25H5.5V8.25a.75.75 0 0 1 .75-.75h3.5a.75.75 0 0 1 .75.75v5.25h2.75a.25.25 0 0 0 .25-.25V6.23a.25.25 0 0 0-.094-.195Z',
21
+ },
18
22
  'folder': {
19
23
  viewBox: '0 0 24 24',
20
24
  path: 'M4 20q-.825 0-1.412-.587T2 18V6q0-.825.588-1.412T4 4h5.175q.4 0 .763.15t.637.425L12 6h8q.825 0 1.413.588T22 8v10q0 .825-.587 1.413T20 20z',
@@ -1071,7 +1071,7 @@ export default function Viewfinder({
1071
1071
  {activeTab === 'Starred' && 'No starred items. Click ☆ on a card to star it.'}
1072
1072
  {activeTab === 'All' && 'No items found. Create a prototype, canvas, or component to get started.'}
1073
1073
  </div>
1074
- ) : groupByFolders && grouped && activeTab === 'All' && activeNav === 'all' ? (
1074
+ ) : groupByFolders && grouped && activeTab === 'All' ? (
1075
1075
  <>
1076
1076
  {grouped.folders.map(folder => (
1077
1077
  <FolderSection
@@ -63,12 +63,16 @@ vi.mock('./widgets/widgetProps.js', () => ({
63
63
  getDefaults: () => ({}),
64
64
  }))
65
65
 
66
- vi.mock('./widgets/widgetConfig.js', () => ({
67
- getFeatures: () => [],
68
- isResizable: () => false,
69
- schemas: {},
70
- getMenuWidgetTypes: () => [],
71
- }))
66
+ vi.mock('./widgets/widgetConfig.js', async () => {
67
+ const actual = await vi.importActual('./widgets/widgetConfig.js')
68
+ return {
69
+ getFeatures: () => [],
70
+ isResizable: () => false,
71
+ schemas: {},
72
+ getMenuWidgetTypes: () => [],
73
+ getConnectorDefaults: actual.getConnectorDefaults,
74
+ }
75
+ })
72
76
 
73
77
  vi.mock('./widgets/figmaUrl.js', () => ({
74
78
  isFigmaUrl: () => false,
@@ -129,6 +133,8 @@ describe('CanvasPage canvas bridge', () => {
129
133
  expect(window.__storyboardCanvasBridgeState).toEqual({
130
134
  active: true,
131
135
  canvasId: 'design-overview',
136
+ connectors: [],
137
+ widgets: [{ id: 'widget-1', type: 'mock-widget', position: { x: 10, y: 20 }, props: {} }],
132
138
  zoom: 100,
133
139
  })
134
140
  expect(mountedHandler).toHaveBeenCalled()
@@ -138,6 +144,8 @@ describe('CanvasPage canvas bridge', () => {
138
144
  expect(statusHandler.mock.calls.at(-1)?.[0]?.detail).toEqual({
139
145
  active: true,
140
146
  canvasId: 'design-overview',
147
+ connectors: [],
148
+ widgets: [{ id: 'widget-1', type: 'mock-widget', position: { x: 10, y: 20 }, props: {} }],
141
149
  zoom: 100,
142
150
  })
143
151
 
@@ -48,12 +48,16 @@ vi.mock('./widgets/widgetProps.js', () => ({
48
48
  getDefaults: () => ({}),
49
49
  }))
50
50
 
51
- vi.mock('./widgets/widgetConfig.js', () => ({
52
- getFeatures: () => [],
53
- isResizable: () => false,
54
- schemas: {},
55
- getMenuWidgetTypes: () => [],
56
- }))
51
+ vi.mock('./widgets/widgetConfig.js', async () => {
52
+ const actual = await vi.importActual('./widgets/widgetConfig.js')
53
+ return {
54
+ getFeatures: () => [],
55
+ isResizable: () => false,
56
+ schemas: {},
57
+ getMenuWidgetTypes: () => [],
58
+ getConnectorDefaults: actual.getConnectorDefaults,
59
+ }
60
+ })
57
61
 
58
62
  vi.mock('./widgets/figmaUrl.js', () => ({
59
63
  isFigmaUrl: () => false,
@@ -12,7 +12,6 @@ import { getPasteRules } from '@dfosco/storyboard-core'
12
12
  import { registerSmoothCorners } from '@dfosco/storyboard-core/smooth-corners'
13
13
  import { isGitHubEmbedUrl } from './widgets/githubUrl.js'
14
14
  import WidgetChrome from './widgets/WidgetChrome.jsx'
15
- import { EmbedControllerProvider } from './widgets/useEmbedController.jsx'
16
15
  import ComponentWidget from './widgets/ComponentWidget.jsx'
17
16
  import useUndoRedo from './useUndoRedo.js'
18
17
  import useMarqueeSelect from './useMarqueeSelect.js'
@@ -531,7 +530,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
531
530
  const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasThemeFromStorage())
532
531
  const [snapEnabled, setSnapEnabled] = useState(canvas?.snapToGrid ?? false)
533
532
  const [snapGridSize, setSnapGridSize] = useState(canvas?.gridSize || 40)
534
- const [perfMode, setPerfMode] = useState(canvas?.performanceMode ?? false)
535
533
  const [showGhInstallBanner, setShowGhInstallBanner] = useState(false)
536
534
 
537
535
  // Refs for snap settings (used by drop handler inside effect closure)
@@ -672,7 +670,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
672
670
  setLocalSources(canvas?.sources ?? [])
673
671
  setSnapEnabled(canvas?.snapToGrid ?? false)
674
672
  setSnapGridSize(canvas?.gridSize || 40)
675
- setPerfMode(canvas?.performanceMode ?? false)
676
673
  undoRedo.reset()
677
674
  // Only reset viewport state when switching to a different canvas,
678
675
  // not when the same canvas refreshes with server data.
@@ -686,11 +683,14 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
686
683
  }
687
684
  }
688
685
 
689
- // Debounced save to server
686
+ // Debounced save to server — routed through queueWrite to serialize
687
+ // with deletes and other writes, preventing stale data from overwriting.
690
688
  const debouncedSave = useRef(
691
689
  debounce((canvasId, widgets) => {
692
- updateCanvas(canvasId, { widgets }).catch((err) =>
693
- console.error('[canvas] Failed to save:', err)
690
+ queueWrite(() =>
691
+ updateCanvas(canvasId, { widgets }).catch((err) =>
692
+ console.error('[canvas] Failed to save:', err)
693
+ )
694
694
  )
695
695
  }, 2000)
696
696
  ).current
@@ -714,6 +714,10 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
714
714
  }, [canvasId, debouncedSave, undoRedo, snapEnabled, snapGridSize])
715
715
 
716
716
  const handleWidgetRemove = useCallback((widgetId) => {
717
+ // Cancel any pending debounced save — it may contain stale data
718
+ // that includes the widget we're about to delete
719
+ debouncedSave.cancel()
720
+
717
721
  undoRedo.snapshot(stateRef.current, 'remove', widgetId)
718
722
  setLocalWidgets((prev) => prev ? prev.filter((w) => w.id !== widgetId) : prev)
719
723
  // Cascade: remove connectors referencing this widget
@@ -734,7 +738,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
734
738
  console.error('[canvas] Failed to remove widget:', err)
735
739
  )
736
740
  )
737
- }, [canvasId, undoRedo])
741
+ }, [canvasId, undoRedo, debouncedSave])
738
742
 
739
743
  const handleConnectorAdd = useCallback(async ({ startWidgetId, startAnchor, endWidgetId, endAnchor }) => {
740
744
  try {
@@ -923,10 +927,14 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
923
927
  }
924
928
  const position = { x: baseX + n * 40, y: baseY + n * 40 }
925
929
  try {
930
+ const copyProps = { ...widget.props }
931
+ // Terminal widgets must get unique names — strip prettyName so the server generates a fresh one
932
+ if (widget.type === 'terminal') delete copyProps.prettyName
933
+
926
934
  undoRedo.snapshot(stateRef.current, 'add')
927
935
  const result = await addWidgetApi(canvasId, {
928
936
  type: widget.type,
929
- props: { ...widget.props },
937
+ props: copyProps,
930
938
  position,
931
939
  })
932
940
  if (result.success && result.widget) {
@@ -1127,6 +1135,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1127
1135
  }
1128
1136
 
1129
1137
  undoRedo.snapshot(stateRef.current, 'move', dragId)
1138
+ debouncedSave.cancel()
1130
1139
  setLocalWidgets((prev) => {
1131
1140
  if (!prev) return prev
1132
1141
  const next = prev.map((w) =>
@@ -1552,21 +1561,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1552
1561
  return () => document.removeEventListener('storyboard:canvas:toggle-snap', handleSnapToggle)
1553
1562
  }, [canvasId])
1554
1563
 
1555
- // Listen for performance mode toggle from command palette
1556
- useEffect(() => {
1557
- function handlePerfToggle() {
1558
- setPerfMode((prev) => {
1559
- const next = !prev
1560
- updateCanvas(canvasId, { settings: { performanceMode: next } }).catch((err) =>
1561
- console.error('[canvas] Failed to persist performance mode:', err)
1562
- )
1563
- return next
1564
- })
1565
- }
1566
- document.addEventListener('storyboard:canvas:toggle-performance-mode', handlePerfToggle)
1567
- return () => document.removeEventListener('storyboard:canvas:toggle-performance-mode', handlePerfToggle)
1568
- }, [canvasId])
1569
-
1570
1564
  // Broadcast snap state to Svelte toolbar
1571
1565
  useEffect(() => {
1572
1566
  document.dispatchEvent(new CustomEvent('storyboard:canvas:snap-state', {
@@ -1804,6 +1798,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1804
1798
  undoRedo.snapshot(stateRef.current, 'add')
1805
1799
  setLocalWidgets((prev) => [...(prev || []), result.widget])
1806
1800
  setSelectedWidgetIds(new Set([result.widget.id]))
1801
+ navigator.clipboard?.writeText(result.widget.id).catch(() => {})
1807
1802
  }
1808
1803
  return true
1809
1804
  } catch (err) {
@@ -1895,9 +1890,11 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1895
1890
  for (const w of sourceWidgets) {
1896
1891
  const relX = (w.position?.x ?? 0) - minX
1897
1892
  const relY = (w.position?.y ?? 0) - minY
1893
+ const pasteProps = { ...w.props }
1894
+ if (w.type === 'terminal') delete pasteProps.prettyName
1898
1895
  const result = await addWidgetApi(canvasId, {
1899
1896
  type: w.type,
1900
- props: { ...w.props },
1897
+ props: pasteProps,
1901
1898
  position: { x: baseX + relX, y: baseY + relY },
1902
1899
  })
1903
1900
  if (result.success && result.widget) {
@@ -2382,7 +2379,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2382
2379
  <>
2383
2380
  <div className={styles.canvasTitle}>
2384
2381
  <a href={(import.meta.env?.BASE_URL || '/')} className={styles.canvasLogo} aria-label="Go to homepage">
2385
- <Icon name="iconoir/key-command" size={16} color="#fff" />
2382
+ <Icon name="home" size={16} color="#fff" />
2386
2383
  </a>
2387
2384
  <CanvasTitleEditable
2388
2385
  canvasId={canvasId}
@@ -2391,9 +2388,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2391
2388
  isLocalDev={isLocalDev}
2392
2389
  />
2393
2390
  <PageSelector currentName={canvasId} pages={siblingPages} isLocalDev={isLocalDev} />
2394
- {isLocalDev && (
2395
- <span className={styles.localEditingLabel}>Local editing</span>
2396
- )}
2397
2391
  </div>
2398
2392
  <div
2399
2393
  ref={scrollRef}
@@ -2429,11 +2423,9 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2429
2423
  dragPreview={connectorDrag}
2430
2424
  hidden={widgetDragging}
2431
2425
  />
2432
- <EmbedControllerProvider performanceMode={perfMode} scrollRef={scrollRef}>
2433
2426
  <Canvas {...canvasProps} onDragStart={isLocalDev ? handleItemDragStart : undefined} onDrag={isLocalDev ? handleItemDrag : undefined} onDragEnd={isLocalDev ? handleItemDragEnd : undefined}>
2434
2427
  {allChildren}
2435
2428
  </Canvas>
2436
- </EmbedControllerProvider>
2437
2429
  </div>
2438
2430
  </div>
2439
2431
  {showGhInstallBanner && (
@@ -109,21 +109,6 @@
109
109
  isolation: isolate;
110
110
  }
111
111
 
112
- .localEditingLabel {
113
- display: inline-flex;
114
- align-items: center;
115
- padding: 4px 12px;
116
- background: hsl(212, 92%, 45%);
117
- color: #fff;
118
- font-size: 13px;
119
- font-weight: 600;
120
- border-radius: 6px;
121
- letter-spacing: 0.01em;
122
- white-space: nowrap;
123
- pointer-events: none;
124
- user-select: none;
125
- }
126
-
127
112
  .ghInstallBanner {
128
113
  position: fixed;
129
114
  left: 50%;
@@ -89,12 +89,16 @@ vi.mock('./widgets/widgetProps.js', () => ({
89
89
  getDefaults: () => ({}),
90
90
  }))
91
91
 
92
- vi.mock('./widgets/widgetConfig.js', () => ({
93
- getFeatures: () => [],
94
- isResizable: () => false,
95
- schemas: {},
96
- getMenuWidgetTypes: () => [],
97
- }))
92
+ vi.mock('./widgets/widgetConfig.js', async () => {
93
+ const actual = await vi.importActual('./widgets/widgetConfig.js')
94
+ return {
95
+ getFeatures: () => [],
96
+ isResizable: () => false,
97
+ schemas: {},
98
+ getMenuWidgetTypes: () => [],
99
+ getConnectorDefaults: actual.getConnectorDefaults,
100
+ }
101
+ })
98
102
 
99
103
  vi.mock('./widgets/figmaUrl.js', () => ({
100
104
  isFigmaUrl: () => false,
@@ -1,5 +1,5 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest'
2
- import { render, screen, fireEvent } from '@testing-library/react'
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { render, screen, fireEvent, waitFor } from '@testing-library/react'
3
3
  import PageSelector from './PageSelector.jsx'
4
4
 
5
5
  const PAGES = [
@@ -10,9 +10,15 @@ const PAGES = [
10
10
 
11
11
  describe('PageSelector', () => {
12
12
  beforeEach(() => {
13
- // Reset location mock
13
+ // Reset location mock — use a setter spy so we can track assignments
14
14
  delete window.location
15
- window.location = { href: '' }
15
+ const loc = { _href: '' }
16
+ Object.defineProperty(loc, 'href', {
17
+ get() { return loc._href },
18
+ set(v) { loc._href = v },
19
+ configurable: true,
20
+ })
21
+ window.location = loc
16
22
  })
17
23
 
18
24
  it('renders nothing when fewer than 2 pages', () => {
@@ -58,14 +64,17 @@ describe('PageSelector', () => {
58
64
  expect(options[0].getAttribute('aria-selected')).toBe('false')
59
65
  })
60
66
 
61
- it('navigates to selected page', () => {
67
+ it('navigates to selected page', async () => {
62
68
  render(<PageSelector currentName="research/interviews" pages={PAGES} />)
63
69
  fireEvent.click(screen.getByTitle('Switch canvas page'))
64
70
  // Click the option in the menu (not the trigger label)
65
71
  const options = screen.getAllByRole('option')
66
72
  fireEvent.click(options[1]) // Surveys
67
73
 
68
- expect(window.location.href).toContain('/canvas/research/surveys')
74
+ // Navigation uses a 300ms setTimeout for mouse clicks
75
+ await waitFor(() => {
76
+ expect(window.location.href).toContain('/canvas/research/surveys')
77
+ })
69
78
  })
70
79
 
71
80
  it('closes dropdown on Escape', () => {
@@ -59,7 +59,7 @@ const ImageWidget = forwardRef(function ImageWidget({ props, onUpdate, resizable
59
59
  const url = getImageUrl(src)
60
60
  const a = document.createElement('a')
61
61
  a.href = url
62
- a.download = src.replace(/^_/, '')
62
+ a.download = src.replace(/^~/, '')
63
63
  document.body.appendChild(a)
64
64
  a.click()
65
65
  document.body.removeChild(a)
@@ -6,7 +6,7 @@ import ResizeHandle from './ResizeHandle.jsx'
6
6
  import { readProp, prototypeEmbedSchema } from './widgetProps.js'
7
7
  import { getEmbedChromeVars } from './embedTheme.js'
8
8
  import { useIframeDevLogs } from './iframeDevLogs.js'
9
- import { useEmbedActive } from './useEmbedController.jsx'
9
+ import { useEmbedsPaused } from './useEmbedsPaused.js'
10
10
  import styles from './PrototypeEmbed.module.css'
11
11
  import overlayStyles from './embedOverlay.module.css'
12
12
 
@@ -68,13 +68,14 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
68
68
  const [expanded, setExpanded] = useState(false)
69
69
  const [filter, setFilter] = useState('')
70
70
  const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasThemeFromStorage())
71
+ const embedsPaused = useEmbedsPaused()
72
+ const frozenSrcRef = useRef(null)
71
73
  const inputRef = useRef(null)
72
74
  const filterRef = useRef(null)
73
75
  const embedRef = useRef(null)
74
76
  const iframeRef = useRef(null)
75
77
  const inlineContainerRef = useRef(null)
76
78
  const modalContainerRef = useRef(null)
77
- const { active: embedActive, activate: activateEmbed, performanceMode, tooMany } = useEmbedActive(widgetId, embedRef)
78
79
 
79
80
  const iframeSrc = useMemo(() => {
80
81
  if (!rawSrc) return ''
@@ -86,7 +87,15 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
86
87
  return `${base}${sep}_sb_embed&_sb_hide_branch_bar&_sb_theme_target=prototype&_sb_canvas_theme=${canvasTheme}${hash}`
87
88
  }, [rawSrc, canvasTheme])
88
89
 
89
- const inactive = !embedActive
90
+ // When paused and not interactive, freeze the iframe src to prevent reloads
91
+ const effectiveSrc = (() => {
92
+ if (!embedsPaused || interactive) {
93
+ frozenSrcRef.current = iframeSrc
94
+ return iframeSrc
95
+ }
96
+ if (frozenSrcRef.current == null) frozenSrcRef.current = iframeSrc
97
+ return frozenSrcRef.current
98
+ })()
90
99
 
91
100
  const prototypeIndex = useMemo(() => {
92
101
  try { return buildPrototypeIndex() }
@@ -157,8 +166,8 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
157
166
 
158
167
  useIframeDevLogs({
159
168
  widget: 'PrototypeEmbed',
160
- loaded: Boolean(iframeSrc && interactive),
161
- src: iframeSrc,
169
+ loaded: Boolean(effectiveSrc && interactive),
170
+ src: effectiveSrc,
162
171
  })
163
172
 
164
173
  useEffect(() => {
@@ -299,7 +308,7 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
299
308
  className={styles.embed}
300
309
  style={{ width, height, ...chromeVars }}
301
310
  >
302
- <div className={`${styles.header}${inactive ? ` ${styles.headerPaused}` : ''}`}>
311
+ <div className={`${styles.header}${embedsPaused && !interactive ? ` ${styles.headerPaused}` : ''}`}>
303
312
  <span className={styles.headerIcon}><CollageFrameIcon size={16} /></span>
304
313
  <span className={styles.headerTitle}>{prototypeTitle}</span>
305
314
  </div>
@@ -357,17 +366,6 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
357
366
  </div>
358
367
  </form>
359
368
  </div>
360
- ) : iframeSrc && inactive ? (
361
- <div
362
- className={overlayStyles.interactOverlay}
363
- onClick={() => { activateEmbed(); enterInteractive() }}
364
- role="button"
365
- tabIndex={0}
366
- onKeyDown={(e) => { if (e.key === 'Enter') { activateEmbed(); enterInteractive() } }}
367
- aria-label="Click to refresh"
368
- >
369
- <span className={overlayStyles.interactHint}>Click to refresh</span>
370
- </div>
371
369
  ) : iframeSrc ? (
372
370
  <>
373
371
  <div
@@ -377,7 +375,7 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
377
375
  >
378
376
  <iframe
379
377
  ref={iframeRef}
380
- src={iframeSrc}
378
+ src={effectiveSrc}
381
379
  className={styles.iframe}
382
380
  style={{
383
381
  width: width / scale,
@@ -4,7 +4,7 @@ import { getEmbedChromeVars } from './embedTheme.js'
4
4
  describe('getEmbedChromeVars', () => {
5
5
  it('follows toolbar theme variants for embed edit chrome', () => {
6
6
  expect(getEmbedChromeVars('light')['--bgColor-default']).toBe('#ffffff')
7
- expect(getEmbedChromeVars('dark')['--bgColor-default']).toBe('#161b22')
8
- expect(getEmbedChromeVars('dark_dimmed')['--bgColor-default']).toBe('#22272e')
7
+ expect(getEmbedChromeVars('dark')['--bgColor-default']).toBe('#0d1117')
8
+ expect(getEmbedChromeVars('dark_dimmed')['--bgColor-default']).toBe('#212830')
9
9
  })
10
10
  })
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import { forwardRef, useImperativeHandle, useRef, useCallback, useState, useEffect, useMemo } from 'react'
12
12
  import { getStoryData } from '@dfosco/storyboard-core'
13
- import { useEmbedActive } from './useEmbedController.jsx'
13
+ import { useEmbedsPaused } from './useEmbedsPaused.js'
14
14
  import { createInspectorHighlighter } from '@dfosco/storyboard-core/inspector/highlighter'
15
15
  import WidgetWrapper from './WidgetWrapper.jsx'
16
16
  import ResizeHandle from './ResizeHandle.jsx'
@@ -66,7 +66,8 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
66
66
  const [highlightedHtml, setHighlightedHtml] = useState(null)
67
67
  const [sourceLoading, setSourceLoading] = useState(false)
68
68
  const [storyIndexKey, setStoryIndexKey] = useState(0)
69
- const { active: embedActive, activate: activateEmbed, performanceMode, tooMany } = useEmbedActive(widgetId, containerRef)
69
+ const embedsPaused = useEmbedsPaused()
70
+ const frozenSrcRef = useRef(null)
70
71
 
71
72
  // Re-resolve story URL when the story index is live-patched
72
73
  useEffect(() => {
@@ -173,17 +174,24 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
173
174
  [storyId, exportName, storyIndexKey],
174
175
  )
175
176
 
176
- // Only render iframe when embed is active (controlled by EmbedController)
177
- const shouldRenderIframe = embedActive && iframeSrc
177
+ // When paused and not interactive, freeze the iframe src to prevent reloads
178
+ const effectiveSrc = (() => {
179
+ if (!embedsPaused || interactive) {
180
+ frozenSrcRef.current = iframeSrc
181
+ return iframeSrc
182
+ }
183
+ // Paused & not interactive — use frozen src (or current if first time)
184
+ if (frozenSrcRef.current == null) frozenSrcRef.current = iframeSrc
185
+ return frozenSrcRef.current
186
+ })()
178
187
 
179
188
  useIframeDevLogs({
180
189
  widget: 'StoryWidget',
181
- loaded: interactive && !showCode && Boolean(iframeSrc),
182
- src: iframeSrc,
190
+ loaded: interactive && !showCode && Boolean(effectiveSrc),
191
+ src: effectiveSrc,
183
192
  })
184
193
 
185
194
  const displayName = exportName ? `${storyId} / ${exportName}` : storyId
186
- const inactive = !embedActive
187
195
 
188
196
  if (!storyId) {
189
197
  return (
@@ -198,7 +206,7 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
198
206
  )
199
207
  }
200
208
 
201
- if (!iframeSrc) {
209
+ if (!effectiveSrc) {
202
210
  return (
203
211
  <WidgetWrapper>
204
212
  <div className={styles.container} ref={containerRef}>
@@ -218,7 +226,7 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
218
226
  return (
219
227
  <WidgetWrapper>
220
228
  <div ref={containerRef} className={styles.container} style={sizeStyle}>
221
- <div className={`${styles.header}${inactive ? ` ${styles.headerPaused}` : ''}`}>
229
+ <div className={`${styles.header}${embedsPaused && !interactive ? ` ${styles.headerPaused}` : ''}`}>
222
230
  <span className={styles.headerIcon}><ComponentIcon size={16} /></span>
223
231
  <span className={styles.headerTitle}>{displayName}</span>
224
232
  </div>
@@ -242,23 +250,12 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
242
250
  <pre className={styles.codeBlock}><code>{sourceCode || ''}</code></pre>
243
251
  )}
244
252
  </div>
245
- ) : inactive ? (
246
- <div
247
- className={overlayStyles.interactOverlay}
248
- onClick={() => { activateEmbed(); enterInteractive() }}
249
- role="button"
250
- tabIndex={0}
251
- onKeyDown={(e) => { if (e.key === 'Enter') { activateEmbed(); enterInteractive() } }}
252
- aria-label="Click to refresh"
253
- >
254
- <span className={overlayStyles.interactHint}>Click to refresh</span>
255
- </div>
256
253
  ) : (
257
254
  <>
258
255
  <div className={styles.content}>
259
256
  <iframe
260
257
  ref={iframeRef}
261
- src={iframeSrc}
258
+ src={effectiveSrc}
262
259
  className={styles.iframe}
263
260
  title={displayName}
264
261
  />