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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/package.json +3 -3
  2. package/src/BranchBar/BranchBar.jsx +3 -1
  3. package/src/BranchBar/BranchBar.module.css +2 -2
  4. package/src/BranchBar/useBranches.js +20 -6
  5. package/src/BranchBar/useBranches.test.js +68 -0
  6. package/src/CommandPalette/CommandPalette.jsx +250 -61
  7. package/src/CommandPalette/command-palette.css +12 -0
  8. package/src/Icon.jsx +46 -11
  9. package/src/Viewfinder.jsx +53 -133
  10. package/src/Viewfinder.module.css +20 -91
  11. package/src/Workspace.jsx +7 -0
  12. package/src/canvas/CanvasPage.jsx +601 -62
  13. package/src/canvas/CanvasPage.module.css +15 -2
  14. package/src/canvas/CanvasPage.multiselect.test.jsx +7 -0
  15. package/src/canvas/ConnectorLayer.jsx +120 -152
  16. package/src/canvas/ConnectorLayer.module.css +69 -0
  17. package/src/canvas/canvasApi.js +68 -2
  18. package/src/canvas/connectorGeometry.js +132 -0
  19. package/src/canvas/hotPoolDevLogs.js +25 -0
  20. package/src/canvas/useMarqueeSelect.js +30 -4
  21. package/src/canvas/widgets/CodePenEmbed.jsx +1 -0
  22. package/src/canvas/widgets/ComponentSetWidget.jsx +199 -0
  23. package/src/canvas/widgets/ComponentSetWidget.module.css +89 -0
  24. package/src/canvas/widgets/ComponentWidget.jsx +1 -0
  25. package/src/canvas/widgets/CropOverlay.jsx +219 -0
  26. package/src/canvas/widgets/CropOverlay.module.css +118 -0
  27. package/src/canvas/widgets/ExpandedPane.jsx +472 -0
  28. package/src/canvas/widgets/ExpandedPane.module.css +179 -0
  29. package/src/canvas/widgets/ExpandedPane.test.jsx +240 -0
  30. package/src/canvas/widgets/ExpandedPaneTopBar.jsx +111 -0
  31. package/src/canvas/widgets/ExpandedPaneTopBar.module.css +59 -0
  32. package/src/canvas/widgets/ExpandedPaneTopBar.test.jsx +45 -0
  33. package/src/canvas/widgets/FigmaEmbed.jsx +49 -102
  34. package/src/canvas/widgets/ImageWidget.jsx +129 -8
  35. package/src/canvas/widgets/ImageWidget.module.css +30 -0
  36. package/src/canvas/widgets/LinkPreview.jsx +93 -44
  37. package/src/canvas/widgets/MarkdownBlock.jsx +141 -16
  38. package/src/canvas/widgets/MarkdownBlock.module.css +25 -0
  39. package/src/canvas/widgets/PromptWidget.jsx +414 -0
  40. package/src/canvas/widgets/PromptWidget.module.css +273 -0
  41. package/src/canvas/widgets/PrototypeEmbed.jsx +46 -170
  42. package/src/canvas/widgets/ResizeHandle.jsx +17 -6
  43. package/src/canvas/widgets/StoryWidget.jsx +65 -11
  44. package/src/canvas/widgets/TerminalReadWidget.jsx +11 -5
  45. package/src/canvas/widgets/TerminalReadWidget.module.css +3 -1
  46. package/src/canvas/widgets/TerminalWidget.jsx +301 -124
  47. package/src/canvas/widgets/TerminalWidget.module.css +121 -12
  48. package/src/canvas/widgets/TilesWidget.jsx +302 -0
  49. package/src/canvas/widgets/TilesWidget.module.css +133 -0
  50. package/src/canvas/widgets/WidgetChrome.jsx +67 -152
  51. package/src/canvas/widgets/WidgetChrome.module.css +20 -1
  52. package/src/canvas/widgets/expandUtils.js +385 -16
  53. package/src/canvas/widgets/expandUtils.test.js +155 -0
  54. package/src/canvas/widgets/index.js +6 -2
  55. package/src/canvas/widgets/tilePool.js +23 -0
  56. package/src/canvas/widgets/tiles/diagonal-bl.png +0 -0
  57. package/src/canvas/widgets/tiles/diagonal-br.png +0 -0
  58. package/src/canvas/widgets/tiles/diagonal-tl.png +0 -0
  59. package/src/canvas/widgets/tiles/leaf.png +0 -0
  60. package/src/canvas/widgets/tiles/quarter-tl.png +0 -0
  61. package/src/canvas/widgets/tiles/quarter-tr.png +0 -0
  62. package/src/canvas/widgets/tiles/solid-a.png +0 -0
  63. package/src/canvas/widgets/tiles/solid-b.png +0 -0
  64. package/src/canvas/widgets/widgetConfig.js +37 -4
  65. package/src/canvas/widgets/widgetIcons.jsx +190 -0
  66. package/src/canvas/widgets/widgetProps.js +1 -0
  67. package/src/context.jsx +47 -19
  68. package/src/hooks/usePrototypeReloadGuard.js +64 -0
  69. package/src/index.js +4 -2
  70. package/src/story/ComponentSetPage.jsx +186 -0
  71. package/src/story/ComponentSetPage.module.css +121 -0
  72. package/src/story/StoryPage.jsx +32 -2
  73. package/src/vite/data-plugin.js +79 -35
  74. package/src/canvas/widgets/ActionWidget.jsx +0 -200
  75. package/src/canvas/widgets/ActionWidget.module.css +0 -122
  76. package/src/canvas/widgets/SplitExpandModal.jsx +0 -234
  77. package/src/canvas/widgets/SplitExpandModal.module.css +0 -335
  78. package/src/canvas/widgets/SplitScreenTopBar.jsx +0 -30
  79. package/src/canvas/widgets/SplitScreenTopBar.module.css +0 -58
@@ -6,12 +6,16 @@ import { shouldPreventCanvasTextSelection } from './textSelection.js'
6
6
  import { getCanvasThemeVars, getCanvasPrimerAttrs } from './canvasTheme.js'
7
7
  import { getWidgetComponent } from './widgets/index.js'
8
8
  import { schemas, getDefaults } from './widgets/widgetProps.js'
9
- import { getFeatures, isResizable, isExpandable, getAnchorState, canAcceptConnection } from './widgets/widgetConfig.js'
9
+ import { getFeatures, isResizable, isExpandable, getAnchorState, canAcceptConnection, isSplitScreenCapable } from './widgets/widgetConfig.js'
10
10
  import { createPasteContext, resolvePaste } from './widgets/pasteRules.js'
11
11
  import { getPasteRules } from '@dfosco/storyboard-core'
12
+ import { isTerminalResizable, getTerminalDimensions } from '@dfosco/storyboard-core'
13
+ import { getFlag } from '@dfosco/storyboard-core'
14
+ import { getCanvasZoom } from '@dfosco/storyboard-core'
12
15
  import { registerSmoothCorners } from '@dfosco/storyboard-core/smooth-corners'
16
+ import { registerHotPoolDevLogs } from './hotPoolDevLogs.js'
13
17
  import { isGitHubEmbedUrl } from './widgets/githubUrl.js'
14
- import { findConnectedSplitTarget } from './widgets/expandUtils.js'
18
+
15
19
  import WidgetChrome from './widgets/WidgetChrome.jsx'
16
20
  import ComponentWidget from './widgets/ComponentWidget.jsx'
17
21
  import useUndoRedo from './useUndoRedo.js'
@@ -20,6 +24,7 @@ import MarqueeOverlay from './MarqueeOverlay.jsx'
20
24
  import {
21
25
  addWidget as addWidgetApi,
22
26
  checkGitHubCliAvailable,
27
+ duplicateImage,
23
28
  fetchGitHubEmbed,
24
29
  getCanvas as getCanvasApi,
25
30
  removeWidget as removeWidgetApi,
@@ -28,6 +33,8 @@ import {
28
33
  uploadImage,
29
34
  addConnector as addConnectorApi,
30
35
  removeConnector as removeConnectorApi,
36
+ updateConnector as updateConnectorApi,
37
+ batchOperations,
31
38
  } from './canvasApi.js'
32
39
  import PageSelector from './PageSelector.jsx'
33
40
  import Icon from '../Icon.jsx'
@@ -35,8 +42,11 @@ import { stories as storyIndex } from 'virtual:storyboard-data-index'
35
42
  import styles from './CanvasPage.module.css'
36
43
  import ConnectorLayer from './ConnectorLayer.jsx'
37
44
 
38
- const ZOOM_MIN = 25
39
- const ZOOM_MAX = 200
45
+ /** Canvas zoom limits — read from storyboard.config.json via canvasConfig. */
46
+ function zoomLimits() {
47
+ const z = getCanvasZoom()
48
+ return { ZOOM_MIN: z.min, ZOOM_MAX: z.max, ZOOM_STEP: z.step }
49
+ }
40
50
 
41
51
  /** Saved viewport state older than this is considered stale — zoom-to-fit instead. */
42
52
  const VIEWPORT_TTL_MS = 15 * 60 * 1000
@@ -45,6 +55,7 @@ const CANVAS_BRIDGE_STATE_KEY = '__storyboardCanvasBridgeState'
45
55
  const GH_INSTALL_URL = 'https://github.com/cli/cli'
46
56
 
47
57
  registerSmoothCorners()
58
+ registerHotPoolDevLogs()
48
59
 
49
60
  /** Matches branch-deploy base path prefixes like /branch--my-feature/ */
50
61
  const BRANCH_PREFIX_RE = /^\/branch--[^/]+/
@@ -133,16 +144,17 @@ function getViewportStorageKey(canvasId) {
133
144
  function loadViewportState(canvasId) {
134
145
  try {
135
146
  const raw = localStorage.getItem(getViewportStorageKey(canvasId))
136
- if (!raw) { console.log('[viewport] no saved state for', canvasId); return null }
147
+ if (!raw) { if (getFlag('dev-logs')) console.log('[viewport] no saved state for', canvasId); return null }
137
148
  const state = JSON.parse(raw)
138
149
  const timestamp = typeof state.timestamp === 'number' ? state.timestamp : 0
139
150
  const age = Date.now() - timestamp
140
151
  if (age > VIEWPORT_TTL_MS) {
141
- console.log('[viewport] stale state for', canvasId, '— age:', Math.round(age / 1000), 's')
152
+ if (getFlag('dev-logs')) console.log('[viewport] stale state for', canvasId, '— age:', Math.round(age / 1000), 's')
142
153
  localStorage.removeItem(getViewportStorageKey(canvasId))
143
154
  return null
144
155
  }
145
- console.log('[viewport] loaded state for', canvasId, '— age:', Math.round(age / 1000), 's, zoom:', state.zoom, 'scroll:', state.scrollLeft, state.scrollTop)
156
+ if (getFlag('dev-logs')) console.log('[viewport] loaded state for', canvasId, '— age:', Math.round(age / 1000), 's, zoom:', state.zoom, 'scroll:', state.scrollLeft, state.scrollTop)
157
+ const { ZOOM_MIN, ZOOM_MAX } = zoomLimits()
146
158
  return {
147
159
  zoom: typeof state.zoom === 'number' ? Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, state.zoom)) : null,
148
160
  scrollLeft: typeof state.scrollLeft === 'number' ? state.scrollLeft : null,
@@ -272,13 +284,15 @@ function computeCanvasBounds(widgets, componentEntries) {
272
284
  }
273
285
 
274
286
  /** Renders a single JSON-defined widget by type lookup. */
275
- function WidgetRenderer({ widget, onUpdate, widgetRef, onRefreshGitHub, canRefreshGitHub }) {
287
+ function WidgetRenderer({ widget, onUpdate, widgetRef, onRefreshGitHub, canRefreshGitHub, multiSelected }) {
276
288
  const Component = getWidgetComponent(widget.type)
277
289
  if (!Component) {
278
290
  console.warn(`[canvas] Unknown widget type: ${widget.type}`)
279
291
  return null
280
292
  }
281
- const resizable = isResizable(widget.type) && !!onUpdate
293
+ const resizable = (widget.type === 'terminal' || widget.type === 'agent')
294
+ ? isTerminalResizable(widget.props?.agentId) && !!onUpdate
295
+ : isResizable(widget.type) && !!onUpdate
282
296
  // Only pass ref to forwardRef-wrapped components (e.g. PrototypeEmbed)
283
297
  const elementProps = {
284
298
  id: widget.id,
@@ -287,6 +301,7 @@ function WidgetRenderer({ widget, onUpdate, widgetRef, onRefreshGitHub, canRefre
287
301
  resizable,
288
302
  onRefreshGitHub,
289
303
  canRefreshGitHub,
304
+ multiSelected,
290
305
  }
291
306
  if (Component.$$typeof === Symbol.for('react.forward_ref')) {
292
307
  elementProps.ref = widgetRef
@@ -305,11 +320,13 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
305
320
  selected,
306
321
  multiSelected,
307
322
  connectorCount,
323
+ allWidgets,
308
324
  onSelect,
309
325
  onDeselect,
310
326
  onUpdate,
311
327
  onRemove,
312
328
  onCopy,
329
+ onCopyWithConnectors,
313
330
  onRefreshGitHub,
314
331
  canRefreshGitHub,
315
332
  onConnectorDragStart,
@@ -336,9 +353,15 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
336
353
  return f
337
354
  }).filter(Boolean)
338
355
 
339
- // Add dynamic "Split Screen" action when a connected split target exists
356
+ // Add dynamic "Split Screen" action when a connected split target exists.
357
+ // Uses connectorCount/allWidgets props (reactive) instead of the global
358
+ // bridge state which may be stale during React render.
340
359
  if (isExpandable(widget.type)) {
341
- const hasConnected = Boolean(findConnectedSplitTarget(widget.id))
360
+ const hasConnected = (connectorCount || []).some((c) => {
361
+ const otherId = c.start?.widgetId === widget.id ? c.end?.widgetId : c.start?.widgetId
362
+ const otherWidget = (allWidgets || []).find((w) => w.id === otherId)
363
+ return otherWidget && isSplitScreenCapable(otherWidget.type)
364
+ })
342
365
  if (hasConnected) {
343
366
  // Insert before the first menu-only feature
344
367
  const insertIdx = adjusted.findIndex((f) => f.menu)
@@ -355,14 +378,52 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
355
378
  }
356
379
  }
357
380
 
381
+ // Add dynamic "Broadcast" toggle for terminal/agent widgets with connected peers
382
+ if (widget.type === 'terminal' || widget.type === 'agent') {
383
+ const widgetConnectors = connectorCount || []
384
+ const widgetList = allWidgets || []
385
+ let hasBroadcastPeers = false
386
+ let allBroadcastActive = true
387
+ const broadcastConnectorIds = []
388
+
389
+ for (const conn of widgetConnectors) {
390
+ const peerId = conn.start?.widgetId === widget.id ? conn.end?.widgetId : conn.start?.widgetId
391
+ const peer = widgetList.find((w) => w.id === peerId)
392
+ if (peer && (peer.type === 'terminal' || peer.type === 'agent')) {
393
+ hasBroadcastPeers = true
394
+ broadcastConnectorIds.push(conn.id)
395
+ if (conn.meta?.messagingMode !== 'two-way') allBroadcastActive = false
396
+ }
397
+ }
398
+
399
+ if (hasBroadcastPeers) {
400
+ const isActive = allBroadcastActive
401
+ const insertIdx = adjusted.findIndex((f) => f.menu)
402
+ const broadcastFeature = {
403
+ id: 'broadcast',
404
+ type: 'action',
405
+ action: `broadcast-toggle:${broadcastConnectorIds.join(',')}:${isActive ? 'off' : 'on'}`,
406
+ label: isActive ? 'Broadcast On' : 'Broadcast',
407
+ icon: 'broadcast',
408
+ active: isActive,
409
+ }
410
+ if (insertIdx >= 0) adjusted.splice(insertIdx, 0, broadcastFeature)
411
+ else adjusted.push(broadcastFeature)
412
+ }
413
+ }
414
+
358
415
  return adjusted
359
- }, [rawFeatures, widget.props?.github, widget.props?.collapsed, widget.type, widget.id, connectorCount])
416
+ }, [rawFeatures, widget.props?.github, widget.props?.collapsed, widget.type, widget.id, connectorCount, allWidgets])
360
417
 
361
- const handleAction = useCallback((actionId) => {
418
+ const handleAction = useCallback((actionId, opts) => {
362
419
  if (actionId === 'delete') {
363
420
  onRemove?.(widget.id)
364
421
  } else if (actionId === 'copy') {
365
- onCopy?.(widget)
422
+ if (opts?.altKey && onCopyWithConnectors) {
423
+ onCopyWithConnectors(widget)
424
+ } else {
425
+ onCopy?.(widget)
426
+ }
366
427
  } else if (actionId === 'copy-text') {
367
428
  const title = widget.props?.title || ''
368
429
  const body = widget.props?.text || widget.props?.content || widget.props?.github?.body || ''
@@ -384,8 +445,20 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
384
445
  if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
385
446
  })
386
447
  }
448
+ } else if (actionId.startsWith('broadcast-toggle:')) {
449
+ // broadcast-toggle:<connectorId1,connectorId2,...>:<on|off>
450
+ const parts = actionId.split(':')
451
+ const connectorIds = parts[1].split(',')
452
+ const turnOn = parts[2] === 'on'
453
+ const bridge = window.__storyboardCanvasBridgeState
454
+ const canvasId = bridge?.canvasId || ''
455
+ const meta = turnOn ? { messagingMode: 'two-way' } : { messagingMode: null }
456
+ for (const cid of connectorIds) {
457
+ updateConnectorApi(canvasId, cid, meta)
458
+ .catch((err) => console.error('[canvas] Failed to toggle broadcast:', err))
459
+ }
387
460
  }
388
- }, [widget, onRemove, onCopy, onRefreshGitHub])
461
+ }, [widget, onRemove, onCopy, onCopyWithConnectors, onRefreshGitHub])
389
462
 
390
463
  const handleWidgetFieldUpdate = useCallback((updates) => {
391
464
  onUpdate?.(widget.id, updates)
@@ -413,6 +486,7 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
413
486
  widgetRef={widgetRef}
414
487
  onRefreshGitHub={onRefreshGitHub}
415
488
  canRefreshGitHub={canRefreshGitHub}
489
+ multiSelected={multiSelected}
416
490
  />
417
491
  </WidgetChrome>
418
492
  )
@@ -422,6 +496,7 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
422
496
  prev.selected === next.selected &&
423
497
  prev.multiSelected === next.multiSelected &&
424
498
  prev.connectorCount === next.connectorCount &&
499
+ prev.allWidgets === next.allWidgets &&
425
500
  prev.readOnly === next.readOnly &&
426
501
  prev.onSelect === next.onSelect &&
427
502
  prev.onDeselect === next.onDeselect &&
@@ -556,6 +631,11 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
556
631
  const [snapGridSize, setSnapGridSize] = useState(canvas?.gridSize || 40)
557
632
  const [showGhInstallBanner, setShowGhInstallBanner] = useState(false)
558
633
 
634
+ // Scroll lock: prevents focus-triggered scroll jumps when adding terminal/agent widgets.
635
+ // The lock captures the current scroll position and forces it back on every scroll event
636
+ // until unlocked by the widget's ready signal or a safety timeout.
637
+ // Visual UI (outline + banner) only appears after 1.5s if still locked.
638
+
559
639
  // Refs for snap settings (used by drop handler inside effect closure)
560
640
  const snapEnabledRef = useRef(snapEnabled)
561
641
  const snapGridSizeRef = useRef(snapGridSize)
@@ -599,12 +679,38 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
599
679
  // Prevents HMR echoes from overwriting in-flight local state.
600
680
  const dirtyRef = useRef(false)
601
681
 
682
+ // Counter of in-flight writes. dirtyRef is only cleared when this reaches 0,
683
+ // preventing early clears when multiple writes are queued in sequence.
684
+ const inflightWritesRef = useRef(0)
685
+
686
+ // Grace period timer — after all writes complete, dirtyRef stays true for a
687
+ // brief window to absorb delayed file-watcher HMR events that arrive after
688
+ // the server's immediate push. Defense-in-depth for the write guard.
689
+ const dirtyGraceTimerRef = useRef(null)
690
+
602
691
  // Serialized write queue — ensures JSONL events land in the right order
603
692
  const writeQueueRef = useRef(Promise.resolve())
604
693
  function queueWrite(fn) {
605
- writeQueueRef.current = writeQueueRef.current.then(fn).catch((err) =>
606
- console.error('[canvas] Write queue error:', err)
607
- )
694
+ clearTimeout(dirtyGraceTimerRef.current)
695
+ inflightWritesRef.current += 1
696
+ writeQueueRef.current = writeQueueRef.current
697
+ .then(fn)
698
+ .catch((err) => console.error('[canvas] Write queue error:', err))
699
+ .finally(() => {
700
+ inflightWritesRef.current -= 1
701
+ if (inflightWritesRef.current < 0) {
702
+ console.warn('[canvas] Write queue counter underflow — resetting')
703
+ inflightWritesRef.current = 0
704
+ }
705
+ if (inflightWritesRef.current === 0) {
706
+ // Grace period — absorb delayed watcher HMR events before clearing
707
+ dirtyGraceTimerRef.current = setTimeout(() => {
708
+ if (inflightWritesRef.current === 0) {
709
+ dirtyRef.current = false
710
+ }
711
+ }, 600)
712
+ }
713
+ })
608
714
  return writeQueueRef.current
609
715
  }
610
716
 
@@ -691,7 +797,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
691
797
 
692
798
  if (canvas !== trackedCanvas) {
693
799
  const isCanvasSwitch = trackedCanvas && canvas && trackedCanvas._route !== canvas._route
694
- console.log('[viewport] canvas changed —', isCanvasSwitch ? 'new canvas, resetting viewport' : 'same canvas, updating widgets only')
800
+ if (getFlag('dev-logs')) console.log('[viewport] canvas changed —', isCanvasSwitch ? 'new canvas, resetting viewport' : 'same canvas, updating widgets only')
695
801
  setTrackedCanvas(canvas)
696
802
 
697
803
  // Skip replacing local state with server data when optimistic edits are
@@ -705,7 +811,9 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
705
811
 
706
812
  setSnapEnabled(canvas?.snapToGrid ?? false)
707
813
  setSnapGridSize(canvas?.gridSize || 40)
708
- undoRedo.reset()
814
+ if (isCanvasSwitch) {
815
+ undoRedo.reset()
816
+ }
709
817
  // Only reset viewport state when switching to a different canvas,
710
818
  // not when the same canvas refreshes with server data.
711
819
  if (isCanvasSwitch) {
@@ -725,7 +833,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
725
833
  queueWrite(() =>
726
834
  updateCanvas(canvasId, { widgets })
727
835
  .catch((err) => console.error('[canvas] Failed to save:', err))
728
- .finally(() => { dirtyRef.current = false })
729
836
  )
730
837
  }, 2000)
731
838
  ).current
@@ -773,7 +880,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
773
880
  queueWrite(() =>
774
881
  removeWidgetApi(canvasId, widgetId)
775
882
  .catch((err) => console.error('[canvas] Failed to remove widget:', err))
776
- .finally(() => { dirtyRef.current = false })
777
883
  )
778
884
  }, [canvasId, undoRedo, debouncedSave])
779
885
 
@@ -792,6 +898,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
792
898
  const handleConnectorRemove = useCallback((connectorId) => {
793
899
  undoRedo.snapshot(stateRef.current, 'connector-remove')
794
900
  setLocalConnectors((prev) => prev.filter((c) => c.id !== connectorId))
901
+ dirtyRef.current = true
795
902
  queueWrite(() =>
796
903
  removeConnectorApi(canvasId, connectorId).catch((err) =>
797
904
  console.error('[canvas] Failed to remove connector:', err)
@@ -963,10 +1070,16 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
963
1070
  n++
964
1071
  }
965
1072
  const position = { x: baseX + n * 40, y: baseY + n * 40 }
1073
+ const isTerminal = widget.type === 'terminal' || widget.type === 'agent'
966
1074
  try {
967
1075
  const copyProps = { ...widget.props }
968
1076
  // Terminal widgets must get unique names — strip prettyName so the server generates a fresh one
969
- if (widget.type === 'terminal' || widget.type === 'agent') delete copyProps.prettyName
1077
+ if (isTerminal) delete copyProps.prettyName
1078
+ // Image widgets: duplicate the asset file so each widget owns its own copy
1079
+ if (widget.type === 'image' && copyProps.src) {
1080
+ const dupResult = await duplicateImage(copyProps.src)
1081
+ if (dupResult.success) copyProps.src = dupResult.filename
1082
+ }
970
1083
 
971
1084
  undoRedo.snapshot(stateRef.current, 'add')
972
1085
  const result = await addWidgetApi(canvasId, {
@@ -983,6 +1096,293 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
983
1096
  }
984
1097
  }, [canvasId, localWidgets, undoRedo])
985
1098
 
1099
+ // Duplicate a single widget WITH its connectors (Alt+click on duplicate button)
1100
+ const handleWidgetCopyWithConnectors = useCallback(async (widget) => {
1101
+ if (!widget) return
1102
+ const widgets = [widget]
1103
+
1104
+ undoRedo.snapshot(stateRef.current, 'add')
1105
+
1106
+ const occupied = new Set(
1107
+ (localWidgets ?? []).map((w) => `${w.position?.x ?? 0},${w.position?.y ?? 0}`)
1108
+ )
1109
+ let offset = 1
1110
+ while (occupied.has(`${(widget.position?.x ?? 0) + offset * 40},${(widget.position?.y ?? 0) + offset * 40}`)) offset++
1111
+
1112
+ const imageOverrides = new Map()
1113
+ if (widget.type === 'image' && widget.props?.src) {
1114
+ try {
1115
+ const dupResult = await duplicateImage(widget.props.src)
1116
+ if (dupResult.success) imageOverrides.set(widget.id, dupResult.filename)
1117
+ } catch { /* use original src as fallback */ }
1118
+ }
1119
+
1120
+ const selectedIds = new Set([widget.id])
1121
+ const relevantConnectors = (localConnectors ?? []).filter(
1122
+ (c) => selectedIds.has(c.start?.widgetId) || selectedIds.has(c.end?.widgetId)
1123
+ )
1124
+
1125
+ const ops = []
1126
+ for (const w of widgets) {
1127
+ const copyProps = { ...w.props }
1128
+ const isTerminal = w.type === 'terminal' || w.type === 'agent'
1129
+ if (isTerminal) delete copyProps.prettyName
1130
+ if (imageOverrides.has(w.id)) copyProps.src = imageOverrides.get(w.id)
1131
+ ops.push({
1132
+ op: 'create-widget',
1133
+ ref: `clone-${w.id}`,
1134
+ type: w.type,
1135
+ props: copyProps,
1136
+ position: {
1137
+ x: (w.position?.x ?? 0) + offset * 40,
1138
+ y: (w.position?.y ?? 0) + offset * 40,
1139
+ },
1140
+ })
1141
+ }
1142
+
1143
+ for (const conn of relevantConnectors) {
1144
+ const startInSelection = selectedIds.has(conn.start?.widgetId)
1145
+ const endInSelection = selectedIds.has(conn.end?.widgetId)
1146
+ ops.push({
1147
+ op: 'create-connector',
1148
+ startWidgetId: startInSelection ? `$clone-${conn.start.widgetId}` : conn.start.widgetId,
1149
+ startAnchor: conn.start.anchor,
1150
+ endWidgetId: endInSelection ? `$clone-${conn.end.widgetId}` : conn.end.widgetId,
1151
+ endAnchor: conn.end.anchor,
1152
+ connectorType: conn.connectorType || 'default',
1153
+ })
1154
+ }
1155
+
1156
+ try {
1157
+ const response = await batchOperations(canvasId, ops)
1158
+ if (!response.success) {
1159
+ console.error('[canvas] Batch duplicate failed:', response.error)
1160
+ return
1161
+ }
1162
+
1163
+ const newWidgets = []
1164
+ const newConnectors = []
1165
+ const refMap = response.refs || {}
1166
+
1167
+ for (const result of response.results) {
1168
+ if (result.op === 'create-widget' && result.widget) {
1169
+ newWidgets.push(result.widget)
1170
+ }
1171
+ if (result.op === 'create-connector' && result.connectorId) {
1172
+ const origOp = ops[result.index]
1173
+ const resolveId = (val) => {
1174
+ if (typeof val === 'string' && val.startsWith('$')) {
1175
+ return refMap[val.slice(1)] ?? val
1176
+ }
1177
+ return val
1178
+ }
1179
+ newConnectors.push({
1180
+ id: result.connectorId,
1181
+ type: 'connector',
1182
+ connectorType: origOp.connectorType || 'default',
1183
+ start: { widgetId: resolveId(origOp.startWidgetId), anchor: origOp.startAnchor },
1184
+ end: { widgetId: resolveId(origOp.endWidgetId), anchor: origOp.endAnchor },
1185
+ meta: {},
1186
+ })
1187
+ }
1188
+ }
1189
+
1190
+ if (newWidgets.length > 0) {
1191
+ setLocalWidgets((prev) => [...(prev || []), ...newWidgets])
1192
+ setSelectedWidgetIds(new Set(newWidgets.map((w) => w.id)))
1193
+ }
1194
+ if (newConnectors.length > 0) {
1195
+ setLocalConnectors((prev) => [...prev, ...newConnectors])
1196
+ }
1197
+ } catch (err) {
1198
+ console.error('[canvas] Failed to duplicate with connectors:', err)
1199
+ }
1200
+ }, [canvasId, localWidgets, localConnectors, undoRedo])
1201
+
1202
+ // Duplicate all selected widgets in one undo step (Cmd+D)
1203
+ const handleDuplicateSelected = useCallback(async () => {
1204
+ const widgets = (localWidgets ?? []).filter((w) => selectedWidgetIds.has(w.id))
1205
+ if (widgets.length === 0) return
1206
+
1207
+ // Single undo snapshot for the entire batch
1208
+ undoRedo.snapshot(stateRef.current, 'add')
1209
+
1210
+ // Compute occupied positions to find free offset
1211
+ const occupied = new Set(
1212
+ (localWidgets ?? []).map((w) => `${w.position?.x ?? 0},${w.position?.y ?? 0}`)
1213
+ )
1214
+ let offset = 1
1215
+ const anyOccupied = () => widgets.some((w) => {
1216
+ const bx = (w.position?.x ?? 0) + offset * 40
1217
+ const by = (w.position?.y ?? 0) + offset * 40
1218
+ return occupied.has(`${bx},${by}`)
1219
+ })
1220
+ while (anyOccupied()) offset++
1221
+
1222
+ const newWidgets = []
1223
+ for (const widget of widgets) {
1224
+ const position = {
1225
+ x: (widget.position?.x ?? 0) + offset * 40,
1226
+ y: (widget.position?.y ?? 0) + offset * 40,
1227
+ }
1228
+ const isTerminal = widget.type === 'terminal' || widget.type === 'agent'
1229
+ try {
1230
+ const copyProps = { ...widget.props }
1231
+ if (isTerminal) delete copyProps.prettyName
1232
+ if (widget.type === 'image' && copyProps.src) {
1233
+ try {
1234
+ const dupResult = await duplicateImage(copyProps.src)
1235
+ if (dupResult.success) copyProps.src = dupResult.filename
1236
+ } catch { /* use original src as fallback */ }
1237
+ }
1238
+ const result = await addWidgetApi(canvasId, {
1239
+ type: widget.type,
1240
+ props: copyProps,
1241
+ position,
1242
+ })
1243
+ if (result.success && result.widget) {
1244
+ newWidgets.push(result.widget)
1245
+ }
1246
+ } catch (err) {
1247
+ console.error('[canvas] Failed to duplicate widget:', err)
1248
+ }
1249
+ }
1250
+
1251
+ if (newWidgets.length > 0) {
1252
+ setLocalWidgets((prev) => [...(prev || []), ...newWidgets])
1253
+ setSelectedWidgetIds(new Set(newWidgets.map((w) => w.id)))
1254
+ }
1255
+ }, [canvasId, localWidgets, selectedWidgetIds, undoRedo])
1256
+
1257
+ // Duplicate selected widgets WITH connectors (Cmd+Shift+D)
1258
+ // Uses the batch API for atomic operation — all widgets and connectors
1259
+ // are created in a single request with $ref resolution.
1260
+ const handleDuplicateWithConnectors = useCallback(async () => {
1261
+ const widgets = (localWidgets ?? []).filter((w) => selectedWidgetIds.has(w.id))
1262
+ if (widgets.length === 0) return
1263
+
1264
+ undoRedo.snapshot(stateRef.current, 'add')
1265
+
1266
+ // Compute offset — same logic as handleDuplicateSelected
1267
+ const occupied = new Set(
1268
+ (localWidgets ?? []).map((w) => `${w.position?.x ?? 0},${w.position?.y ?? 0}`)
1269
+ )
1270
+ let offset = 1
1271
+ const anyOccupied = () => widgets.some((w) => {
1272
+ const bx = (w.position?.x ?? 0) + offset * 40
1273
+ const by = (w.position?.y ?? 0) + offset * 40
1274
+ return occupied.has(`${bx},${by}`)
1275
+ })
1276
+ while (anyOccupied()) offset++
1277
+
1278
+ // Pre-process image widgets — duplicate asset files to get unique filenames
1279
+ const imageOverrides = new Map()
1280
+ for (const widget of widgets) {
1281
+ if (widget.type === 'image' && widget.props?.src) {
1282
+ try {
1283
+ const dupResult = await duplicateImage(widget.props.src)
1284
+ if (dupResult.success) imageOverrides.set(widget.id, dupResult.filename)
1285
+ } catch { /* use original src as fallback */ }
1286
+ }
1287
+ }
1288
+
1289
+ // Find all connectors touching at least one selected widget
1290
+ const selectedIds = new Set(widgets.map((w) => w.id))
1291
+ const relevantConnectors = (localConnectors ?? []).filter(
1292
+ (c) => selectedIds.has(c.start?.widgetId) || selectedIds.has(c.end?.widgetId)
1293
+ )
1294
+
1295
+ // Build batch operations
1296
+ const ops = []
1297
+
1298
+ // 1. Create-widget ops with ref names for $ref resolution
1299
+ for (const widget of widgets) {
1300
+ const copyProps = { ...widget.props }
1301
+ const isTerminal = widget.type === 'terminal' || widget.type === 'agent'
1302
+ if (isTerminal) delete copyProps.prettyName
1303
+ if (imageOverrides.has(widget.id)) copyProps.src = imageOverrides.get(widget.id)
1304
+
1305
+ ops.push({
1306
+ op: 'create-widget',
1307
+ ref: `clone-${widget.id}`,
1308
+ type: widget.type,
1309
+ props: copyProps,
1310
+ position: {
1311
+ x: (widget.position?.x ?? 0) + offset * 40,
1312
+ y: (widget.position?.y ?? 0) + offset * 40,
1313
+ },
1314
+ })
1315
+ }
1316
+
1317
+ // 2. Create-connector ops — remap selected endpoints to $ref clones
1318
+ for (const conn of relevantConnectors) {
1319
+ const startInSelection = selectedIds.has(conn.start?.widgetId)
1320
+ const endInSelection = selectedIds.has(conn.end?.widgetId)
1321
+
1322
+ ops.push({
1323
+ op: 'create-connector',
1324
+ startWidgetId: startInSelection ? `$clone-${conn.start.widgetId}` : conn.start.widgetId,
1325
+ startAnchor: conn.start.anchor,
1326
+ endWidgetId: endInSelection ? `$clone-${conn.end.widgetId}` : conn.end.widgetId,
1327
+ endAnchor: conn.end.anchor,
1328
+ connectorType: conn.connectorType || 'default',
1329
+ })
1330
+ }
1331
+
1332
+ try {
1333
+ const response = await batchOperations(canvasId, ops)
1334
+ if (!response.success) {
1335
+ console.error('[canvas] Batch duplicate failed:', response.error)
1336
+ return
1337
+ }
1338
+
1339
+ // Extract created widgets and connectors from results
1340
+ const newWidgets = []
1341
+ const newConnectors = []
1342
+ const refMap = response.refs || {}
1343
+
1344
+ for (const result of response.results) {
1345
+ if (result.op === 'create-widget' && result.widget) {
1346
+ newWidgets.push(result.widget)
1347
+ }
1348
+ if (result.op === 'create-connector' && result.connectorId) {
1349
+ // Reconstruct connector object from the operation + resolved refs
1350
+ const origOp = ops[result.index]
1351
+ const resolveId = (val) => {
1352
+ if (typeof val === 'string' && val.startsWith('$')) {
1353
+ return refMap[val.slice(1)] ?? val
1354
+ }
1355
+ return val
1356
+ }
1357
+ newConnectors.push({
1358
+ id: result.connectorId,
1359
+ type: 'connector',
1360
+ connectorType: origOp.connectorType || 'default',
1361
+ start: { widgetId: resolveId(origOp.startWidgetId), anchor: origOp.startAnchor },
1362
+ end: { widgetId: resolveId(origOp.endWidgetId), anchor: origOp.endAnchor },
1363
+ meta: {},
1364
+ })
1365
+ }
1366
+ }
1367
+
1368
+ if (newWidgets.length > 0) {
1369
+ setLocalWidgets((prev) => [...(prev || []), ...newWidgets])
1370
+ setSelectedWidgetIds(new Set(newWidgets.map((w) => w.id)))
1371
+ }
1372
+ if (newConnectors.length > 0) {
1373
+ setLocalConnectors((prev) => [...prev, ...newConnectors])
1374
+ }
1375
+ } catch (err) {
1376
+ console.error('[canvas] Failed to duplicate with connectors:', err)
1377
+ }
1378
+ }, [canvasId, localWidgets, localConnectors, selectedWidgetIds, undoRedo])
1379
+
1380
+ // Select all widgets (Cmd+A)
1381
+ const handleSelectAll = useCallback(() => {
1382
+ const allIds = (localWidgets ?? []).map((w) => w.id)
1383
+ if (allIds.length > 0) setSelectedWidgetIds(new Set(allIds))
1384
+ }, [localWidgets])
1385
+
986
1386
  const showMissingGhBanner = useCallback(() => {
987
1387
  setShowGhInstallBanner(true)
988
1388
  }, [])
@@ -1037,8 +1437,10 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1037
1437
 
1038
1438
  const debouncedSourceSave = useRef(
1039
1439
  debounce((canvasId, sources) => {
1040
- updateCanvas(canvasId, { sources }).catch((err) =>
1041
- console.error('[canvas] Failed to save sources:', err)
1440
+ queueWrite(() =>
1441
+ updateCanvas(canvasId, { sources }).catch((err) =>
1442
+ console.error('[canvas] Failed to save sources:', err)
1443
+ )
1042
1444
  )
1043
1445
  }, 2000)
1044
1446
  ).current
@@ -1055,6 +1457,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1055
1457
  const next = current.some((s) => s?.export === exportName)
1056
1458
  ? current.map((s) => (s?.export === exportName ? { ...s, ...snapped } : s))
1057
1459
  : [...current, { export: exportName, ...snapped }]
1460
+ dirtyRef.current = true
1058
1461
  debouncedSourceSave(canvasId, next)
1059
1462
  return next
1060
1463
  })
@@ -1114,7 +1517,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1114
1517
  queueWrite(() =>
1115
1518
  updateCanvas(canvasId, { widgets: next })
1116
1519
  .catch((err) => console.error('[canvas] Failed to save multi-move:', err))
1117
- .finally(() => { dirtyRef.current = false })
1118
1520
  )
1119
1521
  return next
1120
1522
  })
@@ -1147,7 +1549,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1147
1549
  queueWrite(() =>
1148
1550
  updateCanvas(canvasId, { sources: next })
1149
1551
  .catch((err) => console.error('[canvas] Failed to save multi-move sources:', err))
1150
- .finally(() => { dirtyRef.current = false })
1151
1552
  )
1152
1553
  }
1153
1554
  return changed ? next : current
@@ -1167,7 +1568,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1167
1568
  queueWrite(() =>
1168
1569
  updateCanvas(canvasId, { sources: next })
1169
1570
  .catch((err) => console.error('[canvas] Failed to save source position:', err))
1170
- .finally(() => { dirtyRef.current = false })
1171
1571
  )
1172
1572
  return next
1173
1573
  })
@@ -1185,7 +1585,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1185
1585
  queueWrite(() =>
1186
1586
  updateCanvas(canvasId, { widgets: next })
1187
1587
  .catch((err) => console.error('[canvas] Failed to save widget position:', err))
1188
- .finally(() => { dirtyRef.current = false })
1189
1588
  )
1190
1589
  return next
1191
1590
  })
@@ -1210,20 +1609,21 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1210
1609
  if (!el || loading) return
1211
1610
  const saved = pendingScrollRestore.current
1212
1611
  if (saved) {
1213
- console.log('[viewport] restoring saved viewport — zoom:', saved.zoom, 'scroll:', saved.scrollLeft, saved.scrollTop)
1612
+ if (getFlag('dev-logs')) console.log('[viewport] restoring saved viewport — zoom:', saved.zoom, 'scroll:', saved.scrollLeft, saved.scrollTop)
1214
1613
  // Fresh saved viewport — restore exactly
1215
1614
  if (saved.scrollLeft != null) el.scrollLeft = saved.scrollLeft
1216
1615
  if (saved.scrollTop != null) el.scrollTop = saved.scrollTop
1217
1616
  pendingScrollRestore.current = null
1218
1617
  } else {
1219
- console.log('[viewport] no saved viewport — fitting to objects')
1618
+ if (getFlag('dev-logs')) console.log('[viewport] no saved viewport — fitting to objects')
1220
1619
  // No saved state or stale — zoom-to-fit all objects
1221
1620
  const bounds = computeCanvasBounds(localWidgets, componentEntries)
1222
1621
  if (bounds && el.clientWidth > 0 && el.clientHeight > 0) {
1223
1622
  const boxW = bounds.maxX - bounds.minX + FIT_PADDING * 2
1224
1623
  const boxH = bounds.maxY - bounds.minY + FIT_PADDING * 2
1225
1624
  const fitScale = Math.min(el.clientWidth / boxW, el.clientHeight / boxH)
1226
- const fitZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(fitScale * 100)))
1625
+ const { ZOOM_MIN: zMin, ZOOM_MAX: zMax } = zoomLimits()
1626
+ const fitZoom = Math.min(zMax, Math.max(zMin, Math.round(fitScale * 100)))
1227
1627
  const newScale = fitZoom / 100
1228
1628
  zoomRef.current = fitZoom
1229
1629
  // Imperative DOM update for initial zoom-to-fit — same path as applyZoom
@@ -1300,7 +1700,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1300
1700
  useEffect(() => {
1301
1701
  if (viewportInitName.current !== canvasId) return
1302
1702
  const el = scrollRef.current
1303
- console.log('[viewport] saving — zoom:', zoom, 'scroll:', el?.scrollLeft, el?.scrollTop)
1703
+ if (getFlag('dev-logs')) console.log('[viewport] saving — zoom:', zoom, 'scroll:', el?.scrollLeft, el?.scrollTop)
1304
1704
  // Read current scroll so the zoom entry doesn't zero-out position,
1305
1705
  // but the authoritative scroll save comes from the scroll handler.
1306
1706
  saveViewportState(canvasId, {
@@ -1345,6 +1745,56 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1345
1745
  }
1346
1746
  }, [canvasId, loading])
1347
1747
 
1748
+ // Gather current viewport data from refs (safe for callbacks/timeouts)
1749
+ const getViewportData = useCallback(() => {
1750
+ const el = scrollRef.current
1751
+ if (!el) return null
1752
+ const scale = zoomRef.current / 100
1753
+ const scrollLeft = el.scrollLeft
1754
+ const scrollTop = el.scrollTop
1755
+ const cw = el.clientWidth
1756
+ const ch = el.clientHeight
1757
+ return {
1758
+ centerX: Math.round((scrollLeft + cw / 2) / scale),
1759
+ centerY: Math.round((scrollTop + ch / 2) / scale),
1760
+ zoom: zoomRef.current,
1761
+ topLeftX: Math.round(scrollLeft / scale),
1762
+ topLeftY: Math.round(scrollTop / scale),
1763
+ width: Math.round(cw / scale),
1764
+ height: Math.round(ch / scale),
1765
+ }
1766
+ }, [])
1767
+
1768
+ // Debounced viewport-changed HMR event — sends position/zoom to Vite server
1769
+ // so the selected-widgets bridge can write it to disk for agents.
1770
+ useEffect(() => {
1771
+ if (!import.meta.hot) return
1772
+ const el = scrollRef.current
1773
+ if (!el) return
1774
+
1775
+ const tabId = selectionTabIdRef.current
1776
+
1777
+ function sendViewport() {
1778
+ const viewport = getViewportData()
1779
+ if (viewport) {
1780
+ import.meta.hot.send('storyboard:viewport-changed', { tabId, canvasId, viewport })
1781
+ }
1782
+ }
1783
+
1784
+ const debouncedSend = debounce(sendViewport, 500)
1785
+
1786
+ function handleScroll() { debouncedSend() }
1787
+ el.addEventListener('scroll', handleScroll, { passive: true })
1788
+
1789
+ // Also send on zoom commits (zoom state changes trigger this effect)
1790
+ sendViewport()
1791
+
1792
+ return () => {
1793
+ debouncedSend.cancel()
1794
+ el.removeEventListener('scroll', handleScroll)
1795
+ }
1796
+ }, [canvasId, zoom, loading, getViewportData])
1797
+
1348
1798
  /**
1349
1799
  * Zoom to a new level, anchoring on an optional client-space point.
1350
1800
  * When a cursor position is provided (e.g. from a wheel event), the
@@ -1359,6 +1809,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1359
1809
  function applyZoom(newZoom, clientX, clientY) {
1360
1810
  const el = scrollRef.current
1361
1811
  const zoomEl = zoomElRef.current
1812
+ const { ZOOM_MIN, ZOOM_MAX } = zoomLimits()
1362
1813
  const clampedZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, newZoom))
1363
1814
 
1364
1815
  if (!el || !zoomEl) {
@@ -1405,7 +1856,11 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1405
1856
  if (!zoomEventTimer.current) {
1406
1857
  zoomEventTimer.current = setTimeout(() => {
1407
1858
  zoomEventTimer.current = null
1408
- window[CANVAS_BRIDGE_STATE_KEY] = { active: true, canvasId, zoom: zoomRef.current }
1859
+ const bridge = window[CANVAS_BRIDGE_STATE_KEY] || {}
1860
+ bridge.active = true
1861
+ bridge.canvasId = canvasId
1862
+ bridge.zoom = zoomRef.current
1863
+ window[CANVAS_BRIDGE_STATE_KEY] = bridge
1409
1864
  document.dispatchEvent(new CustomEvent('storyboard:canvas:zoom-changed', {
1410
1865
  detail: { zoom: zoomRef.current }
1411
1866
  }))
@@ -1415,7 +1870,11 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1415
1870
 
1416
1871
  // Signal canvas mount/unmount to CoreUIBar
1417
1872
  useEffect(() => {
1418
- window[CANVAS_BRIDGE_STATE_KEY] = { active: true, canvasId, zoom: zoomRef.current }
1873
+ const bridge = window[CANVAS_BRIDGE_STATE_KEY] || {}
1874
+ bridge.active = true
1875
+ bridge.canvasId = canvasId
1876
+ bridge.zoom = zoomRef.current
1877
+ window[CANVAS_BRIDGE_STATE_KEY] = bridge
1419
1878
  document.dispatchEvent(new CustomEvent('storyboard:canvas:mounted', {
1420
1879
  detail: { canvasId, zoom: zoomRef.current }
1421
1880
  }))
@@ -1435,14 +1894,15 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1435
1894
  }, [canvasId])
1436
1895
 
1437
1896
  // Tell the Vite dev server to suppress full-reloads while this canvas is active.
1438
- // The ?canvas-hmr URL param opts out of the guard for canvas UI development.
1897
+ // Controlled by the "canvas-auto-reload" feature flag (default: false = guard ON).
1898
+ // When the flag is true, the guard is skipped so canvas pages receive HMR updates.
1439
1899
  // Sends a heartbeat every 3s so the guard auto-expires if the tab closes.
1440
1900
  useEffect(() => {
1441
1901
  if (!import.meta.hot) return
1442
- const hmrEnabled = new URLSearchParams(window.location.search).has('canvas-hmr')
1443
- if (hmrEnabled) return
1902
+ const autoReload = getFlag('canvas-auto-reload')
1903
+ if (autoReload) return
1444
1904
 
1445
- const msg = { active: true, hmrEnabled: false }
1905
+ const msg = { active: true }
1446
1906
  import.meta.hot.send('storyboard:canvas-hmr-guard', msg)
1447
1907
  const interval = setInterval(() => {
1448
1908
  import.meta.hot.send('storyboard:canvas-hmr-guard', msg)
@@ -1450,7 +1910,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1450
1910
 
1451
1911
  return () => {
1452
1912
  clearInterval(interval)
1453
- import.meta.hot.send('storyboard:canvas-hmr-guard', { active: false, hmrEnabled: true })
1913
+ import.meta.hot.send('storyboard:canvas-hmr-guard', { active: false })
1454
1914
  }
1455
1915
  }, [canvasId])
1456
1916
 
@@ -1484,7 +1944,8 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1484
1944
 
1485
1945
  function sendFocus() {
1486
1946
  const { widgetIds, widgets } = getSelectedWidgetData()
1487
- import.meta.hot.send('storyboard:canvas-focused', { tabId, canvasId, widgetIds, widgets })
1947
+ const viewport = getViewportData()
1948
+ import.meta.hot.send('storyboard:canvas-focused', { tabId, canvasId, widgetIds, widgets, viewport })
1488
1949
  }
1489
1950
 
1490
1951
  sendFocus()
@@ -1511,7 +1972,8 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1511
1972
  const tabId = selectionTabIdRef.current
1512
1973
  const timer = setTimeout(() => {
1513
1974
  const { widgetIds, widgets } = getSelectedWidgetData()
1514
- import.meta.hot.send('storyboard:selection-changed', { tabId, canvasId, widgetIds: widgetIds, widgets })
1975
+ const viewport = getViewportData()
1976
+ import.meta.hot.send('storyboard:selection-changed', { tabId, canvasId, widgetIds: widgetIds, widgets, viewport })
1515
1977
  }, 500)
1516
1978
 
1517
1979
  return () => clearTimeout(timer)
@@ -1520,6 +1982,12 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1520
1982
  // Add a widget by type — used by CanvasControls and CoreUIBar event
1521
1983
  const addWidget = useCallback(async (type, extraProps = {}) => {
1522
1984
  const defaultProps = schemas[type] ? getDefaults(schemas[type]) : {}
1985
+ // For terminal/agent, apply config-based dimension defaults over schema defaults
1986
+ if (type === 'terminal' || type === 'agent') {
1987
+ const dims = getTerminalDimensions(extraProps.agentId, { width: defaultProps.width ?? 800, height: defaultProps.height ?? 450 })
1988
+ defaultProps.width = dims.width
1989
+ defaultProps.height = dims.height
1990
+ }
1523
1991
  const mergedProps = { ...defaultProps, ...extraProps }
1524
1992
  const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
1525
1993
  const pos = centerPositionForWidget(center, type, mergedProps)
@@ -1560,7 +2028,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1560
2028
  }
1561
2029
  }, [canvasId, undoRedo])
1562
2030
 
1563
- // Listen for CoreUIBar add-widget events
2031
+ // Listen for CoreUIBar add-widget and update-widget events
1564
2032
  useEffect(() => {
1565
2033
  function handleAddWidget(e) {
1566
2034
  addWidget(e.detail.type, e.detail.props)
@@ -1568,13 +2036,19 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1568
2036
  function handleAddStoryWidget(e) {
1569
2037
  addStoryWidget(e.detail.storyId)
1570
2038
  }
2039
+ function handleUpdateWidget(e) {
2040
+ const { widgetId, updates } = e.detail || {}
2041
+ if (widgetId && updates) handleWidgetUpdate(widgetId, updates)
2042
+ }
1571
2043
  document.addEventListener('storyboard:canvas:add-widget', handleAddWidget)
1572
2044
  document.addEventListener('storyboard:canvas:add-story-widget', handleAddStoryWidget)
2045
+ document.addEventListener('storyboard:canvas:update-widget', handleUpdateWidget)
1573
2046
  return () => {
1574
2047
  document.removeEventListener('storyboard:canvas:add-widget', handleAddWidget)
1575
2048
  document.removeEventListener('storyboard:canvas:add-story-widget', handleAddStoryWidget)
2049
+ document.removeEventListener('storyboard:canvas:update-widget', handleUpdateWidget)
1576
2050
  }
1577
- }, [addWidget, addStoryWidget])
2051
+ }, [addWidget, addStoryWidget, handleWidgetUpdate])
1578
2052
 
1579
2053
  // Listen for zoom changes from CoreUIBar
1580
2054
  useEffect(() => {
@@ -1654,7 +2128,8 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1654
2128
 
1655
2129
  // Find the zoom level that fits the bounding box in the viewport
1656
2130
  const fitScale = Math.min(viewW / boxW, viewH / boxH)
1657
- const fitZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(fitScale * 100)))
2131
+ const { ZOOM_MIN: zMin, ZOOM_MAX: zMax } = zoomLimits()
2132
+ const fitZoom = Math.min(zMax, Math.max(zMin, Math.round(fitScale * 100)))
1658
2133
  const newScale = fitZoom / 100
1659
2134
 
1660
2135
  // Imperative DOM update — same path as applyZoom
@@ -1697,14 +2172,20 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1697
2172
 
1698
2173
  // Broadcast zoom level to CoreUIBar whenever it changes
1699
2174
  useEffect(() => {
1700
- window[CANVAS_BRIDGE_STATE_KEY] = { active: true, canvasId, zoom }
2175
+ const bridge = window[CANVAS_BRIDGE_STATE_KEY] || {}
2176
+ bridge.active = true
2177
+ bridge.canvasId = canvasId
2178
+ bridge.zoom = zoom
2179
+ window[CANVAS_BRIDGE_STATE_KEY] = bridge
1701
2180
  document.dispatchEvent(new CustomEvent('storyboard:canvas:zoom-changed', {
1702
2181
  detail: { zoom }
1703
2182
  }))
1704
2183
  }, [canvasId, zoom])
1705
2184
 
1706
- // Keep bridge in sync with widgets/connectors for expand features
1707
- useEffect(() => {
2185
+ // Keep bridge in sync with widgets/connectors for expand features.
2186
+ // Child widgets now use props directly for split-screen gating, but
2187
+ // FigmaEmbed/PrototypeEmbed/etc. still read this bridge at expand time.
2188
+ useMemo(() => {
1708
2189
  const bridge = window[CANVAS_BRIDGE_STATE_KEY] || {}
1709
2190
  bridge.widgets = localWidgets
1710
2191
  bridge.connectors = localConnectors
@@ -1748,6 +2229,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1748
2229
  // Multi-delete — snapshot once, remove all, persist via updateCanvas
1749
2230
  undoRedo.snapshot(stateRef.current, 'multi-remove')
1750
2231
  debouncedSave.cancel()
2232
+ dirtyRef.current = true
1751
2233
  setLocalWidgets((prev) => {
1752
2234
  if (!prev) return prev
1753
2235
  const next = prev.filter(w => !selectedWidgetIds.has(w.id))
@@ -1934,6 +2416,13 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1934
2416
  const relY = (w.position?.y ?? 0) - minY
1935
2417
  const pasteProps = { ...w.props }
1936
2418
  if (w.type === 'terminal' || w.type === 'agent') delete pasteProps.prettyName
2419
+ // Image widgets: duplicate the asset so the paste owns its own copy
2420
+ if (w.type === 'image' && pasteProps.src) {
2421
+ try {
2422
+ const dupResult = await duplicateImage(pasteProps.src)
2423
+ if (dupResult.success) pasteProps.src = dupResult.filename
2424
+ } catch { /* use original src as fallback */ }
2425
+ }
1937
2426
  const result = await addWidgetApi(canvasId, {
1938
2427
  type: w.type,
1939
2428
  props: pasteProps,
@@ -1956,11 +2445,34 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1956
2445
  }
1957
2446
 
1958
2447
  e.preventDefault()
2448
+ await pasteTextAsWidget(text, pasteCtx)
2449
+ }
2450
+
2451
+ // Shared helper: resolve pasted text into a widget and add it to the canvas.
2452
+ // Used by both native paste and the programmatic paste-url event.
2453
+ async function pasteTextAsWidget(text, pasteCtx) {
1959
2454
  const resolved = resolvePaste(text, pasteCtx, getPasteRules())
1960
2455
  if (!resolved) return
1961
- const { type } = resolved
2456
+ let { type } = resolved
1962
2457
  let props = resolved.props
1963
2458
 
2459
+ // Component/story URLs → story widget (instead of prototype embed)
2460
+ if (type === 'prototype' && props?.src) {
2461
+ const srcPath = props.src.replace(/[?#].*$/, '').replace(/\/+$/, '')
2462
+ const storyId = storyRouteIndex.get(srcPath)
2463
+ if (storyId) {
2464
+ type = 'story'
2465
+ const parsed = pasteCtx.parseUrl(text)
2466
+ const searchParams = new URLSearchParams(parsed?.search || '')
2467
+ props = {
2468
+ storyId,
2469
+ exportName: searchParams.get('export') || '',
2470
+ width: 600,
2471
+ height: 400,
2472
+ }
2473
+ }
2474
+ }
2475
+
1964
2476
  if (type === 'link-preview' && isGitHubEmbedUrl(props?.url || text)) {
1965
2477
  const githubUpdates = await buildGitHubPreviewUpdates(props?.url || text)
1966
2478
  if (githubUpdates) props = { ...props, ...githubUpdates }
@@ -1984,8 +2496,19 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1984
2496
  }
1985
2497
  }
1986
2498
 
2499
+ // Listen for programmatic paste-url events from the command palette
2500
+ function handlePasteUrl(e) {
2501
+ const text = e.detail?.url?.trim()
2502
+ if (!text) return
2503
+ pasteTextAsWidget(text, pasteCtx)
2504
+ }
2505
+
1987
2506
  document.addEventListener('paste', handlePaste)
1988
- return () => document.removeEventListener('paste', handlePaste)
2507
+ document.addEventListener('storyboard:canvas:paste-url', handlePasteUrl)
2508
+ return () => {
2509
+ document.removeEventListener('paste', handlePaste)
2510
+ document.removeEventListener('storyboard:canvas:paste-url', handlePasteUrl)
2511
+ }
1989
2512
  // eslint-disable-next-line react-hooks/exhaustive-deps
1990
2513
  }, [canvasId, undoRedo, localWidgets])
1991
2514
 
@@ -2066,7 +2589,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2066
2589
  queueWrite(() =>
2067
2590
  updateCanvas(canvasId, { widgets: previous.widgets, sources: previous.sources, connectors: previous.connectors })
2068
2591
  .catch((err) => console.error('[canvas] Failed to persist undo:', err))
2069
- .finally(() => { dirtyRef.current = false })
2070
2592
  )
2071
2593
  }, [canvasId, debouncedSave, debouncedSourceSave, undoRedo])
2072
2594
 
@@ -2082,16 +2604,17 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2082
2604
  queueWrite(() =>
2083
2605
  updateCanvas(canvasId, { widgets: next.widgets, sources: next.sources, connectors: next.connectors })
2084
2606
  .catch((err) => console.error('[canvas] Failed to persist redo:', err))
2085
- .finally(() => { dirtyRef.current = false })
2086
2607
  )
2087
2608
  }, [canvasId, debouncedSave, debouncedSourceSave, undoRedo])
2088
2609
 
2089
- // Keyboard shortcuts — dev-only (Cmd+Z / Cmd+Shift+Z)
2610
+ // Keyboard shortcuts — dev-only (Cmd+Z / Cmd+Shift+Z / Cmd+D / Cmd+A)
2090
2611
  useEffect(() => {
2091
2612
  if (!import.meta.hot) return
2092
2613
  function handleKeyDown(e) {
2093
2614
  const tag = e.target.tagName
2094
2615
  if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return
2616
+ // Don't intercept shortcuts when the command palette is open
2617
+ if (e.target.closest?.('[cmdk-root]')) return
2095
2618
  const mod = e.metaKey || e.ctrlKey
2096
2619
  if (mod && e.key === 'z' && !e.shiftKey) {
2097
2620
  e.preventDefault()
@@ -2101,10 +2624,21 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2101
2624
  e.preventDefault()
2102
2625
  handleRedo()
2103
2626
  }
2627
+ if (mod && e.key.toLowerCase() === 'd' && e.shiftKey) {
2628
+ e.preventDefault()
2629
+ handleDuplicateWithConnectors()
2630
+ } else if (mod && e.key.toLowerCase() === 'd' && !e.shiftKey) {
2631
+ e.preventDefault()
2632
+ handleDuplicateSelected()
2633
+ }
2634
+ if (mod && e.key === 'a') {
2635
+ e.preventDefault()
2636
+ handleSelectAll()
2637
+ }
2104
2638
  }
2105
2639
  document.addEventListener('keydown', handleKeyDown)
2106
2640
  return () => document.removeEventListener('keydown', handleKeyDown)
2107
- }, [handleUndo, handleRedo])
2641
+ }, [handleUndo, handleRedo, handleDuplicateSelected, handleDuplicateWithConnectors, handleSelectAll])
2108
2642
 
2109
2643
  // Listen for undo/redo from CoreUIBar (Svelte toolbar)
2110
2644
  useEffect(() => {
@@ -2274,6 +2808,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2274
2808
  zoomRef: zoomRef,
2275
2809
  setSelectedWidgetIds,
2276
2810
  widgets: localWidgets,
2811
+ connectors: localConnectors,
2277
2812
  componentEntries,
2278
2813
  fallbackSizes: WIDGET_FALLBACK_SIZES,
2279
2814
  spaceHeld,
@@ -2331,6 +2866,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2331
2866
  id={`jsx-${exportName}`}
2332
2867
  data-tc-x={sourcePosition.x}
2333
2868
  data-tc-y={sourcePosition.y}
2869
+ data-widget-raised={selectedWidgetIds.has(`jsx-${exportName}`) || undefined}
2334
2870
  {...(isLocalDev ? { 'data-tc-handle': '.tc-drag-handle, .tc-drag-surface' } : {})}
2335
2871
  {...canvasPrimerAttrs}
2336
2872
  style={canvasThemeVars}
@@ -2367,13 +2903,12 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2367
2903
  }
2368
2904
 
2369
2905
  // 2. JSON-defined mutable widgets (selectable, wrapped in WidgetChrome)
2370
- // Sort so selected widgets render last (visually on top via DOM order)
2371
- const sortedWidgets = (localWidgets ?? []).slice().sort((a, b) => {
2372
- const aSelected = selectedWidgetIds.has(a.id) ? 1 : 0
2373
- const bSelected = selectedWidgetIds.has(b.id) ? 1 : 0
2374
- return aSelected - bSelected
2375
- })
2376
- for (const widget of sortedWidgets) {
2906
+ // Stable DOM order visual stacking is controlled by z-index on the
2907
+ // wrapper div (data-widget-raised), NOT by re-sorting the array.
2908
+ // Re-sorting caused iframe widgets (stories, embeds) to remount and
2909
+ // reload every time selection changed, because moving an iframe node
2910
+ // in the DOM destroys its browsing context.
2911
+ for (const widget of (localWidgets ?? [])) {
2377
2912
  // In production, render terminal widgets as read-only instead of hiding them
2378
2913
  const effectiveWidget = (!isLocalDev && (widget.type === 'terminal' || widget.type === 'agent'))
2379
2914
  ? { ...widget, type: 'terminal-read' }
@@ -2384,6 +2919,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2384
2919
  id={effectiveWidget.id}
2385
2920
  data-tc-x={effectiveWidget?.position?.x ?? 0}
2386
2921
  data-tc-y={effectiveWidget?.position?.y ?? 0}
2922
+ data-widget-raised={selectedWidgetIds.has(widget.id) || undefined}
2387
2923
  {...(isLocalDev ? { 'data-tc-handle': '.tc-drag-handle, .tc-drag-surface' } : {})}
2388
2924
  {...canvasPrimerAttrs}
2389
2925
  style={canvasThemeVars}
@@ -2398,11 +2934,13 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2398
2934
  widget={effectiveWidget}
2399
2935
  selected={selectedWidgetIds.has(widget.id)}
2400
2936
  multiSelected={isMultiSelected && selectedWidgetIds.has(widget.id)}
2401
- connectorCount={localConnectors.filter((c) => c.start?.widgetId === widget.id || c.end?.widgetId === widget.id).length}
2937
+ connectorCount={localConnectors.filter((c) => c.start?.widgetId === widget.id || c.end?.widgetId === widget.id)}
2938
+ allWidgets={localWidgets}
2402
2939
  onSelect={(shiftKey) => handleWidgetSelect(widget.id, shiftKey)}
2403
2940
  onDeselect={handleDeselectAll}
2404
2941
  onUpdate={isLocalDev ? handleWidgetUpdate : undefined}
2405
2942
  onCopy={isLocalDev ? handleWidgetCopy : undefined}
2943
+ onCopyWithConnectors={isLocalDev ? handleWidgetCopyWithConnectors : undefined}
2406
2944
  onRemove={isLocalDev ? handleWidgetRemoveAndDeselect : undefined}
2407
2945
  onRefreshGitHub={isLocalDev ? handleRefreshGitHubWidget : undefined}
2408
2946
  canRefreshGitHub={isLocalDev}
@@ -2460,6 +2998,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2460
2998
  <ConnectorLayer
2461
2999
  connectors={filteredConnectors}
2462
3000
  widgets={localWidgets ?? []}
3001
+ selectedWidgetIds={selectedWidgetIds}
2463
3002
  onRemove={isLocalDev ? handleConnectorRemove : undefined}
2464
3003
  onEndpointDrag={undefined}
2465
3004
  dragPreview={connectorDrag}