@dfosco/storyboard-react 4.2.0-alpha.11 → 4.2.0-alpha.13

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.11",
3
+ "version": "4.2.0-alpha.13",
4
4
  "type": "module",
5
5
  "dependencies": {
6
6
  "@base-ui/react": "^1.4.0",
7
- "@dfosco/storyboard-core": "4.2.0-alpha.11",
8
- "@dfosco/tiny-canvas": "4.2.0-alpha.11",
7
+ "@dfosco/storyboard-core": "4.2.0-alpha.13",
8
+ "@dfosco/tiny-canvas": "4.2.0-alpha.13",
9
9
  "@neodrag/react": "^2.3.1",
10
10
  "glob": "^11.0.0",
11
11
  "jsonc-parser": "^3.3.1",
@@ -12,6 +12,7 @@ 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'
15
16
  import ComponentWidget from './widgets/ComponentWidget.jsx'
16
17
  import useUndoRedo from './useUndoRedo.js'
17
18
  import useMarqueeSelect from './useMarqueeSelect.js'
@@ -530,6 +531,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
530
531
  const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasThemeFromStorage())
531
532
  const [snapEnabled, setSnapEnabled] = useState(canvas?.snapToGrid ?? false)
532
533
  const [snapGridSize, setSnapGridSize] = useState(canvas?.gridSize || 40)
534
+ const [perfMode, setPerfMode] = useState(canvas?.performanceMode ?? false)
533
535
  const [showGhInstallBanner, setShowGhInstallBanner] = useState(false)
534
536
 
535
537
  // Refs for snap settings (used by drop handler inside effect closure)
@@ -670,6 +672,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
670
672
  setLocalSources(canvas?.sources ?? [])
671
673
  setSnapEnabled(canvas?.snapToGrid ?? false)
672
674
  setSnapGridSize(canvas?.gridSize || 40)
675
+ setPerfMode(canvas?.performanceMode ?? false)
673
676
  undoRedo.reset()
674
677
  // Only reset viewport state when switching to a different canvas,
675
678
  // not when the same canvas refreshes with server data.
@@ -1549,6 +1552,21 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1549
1552
  return () => document.removeEventListener('storyboard:canvas:toggle-snap', handleSnapToggle)
1550
1553
  }, [canvasId])
1551
1554
 
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
+
1552
1570
  // Broadcast snap state to Svelte toolbar
1553
1571
  useEffect(() => {
1554
1572
  document.dispatchEvent(new CustomEvent('storyboard:canvas:snap-state', {
@@ -1649,6 +1667,14 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1649
1667
  }))
1650
1668
  }, [canvasId, zoom])
1651
1669
 
1670
+ // Keep bridge in sync with widgets/connectors for expand features
1671
+ useEffect(() => {
1672
+ const bridge = window[CANVAS_BRIDGE_STATE_KEY] || {}
1673
+ bridge.widgets = localWidgets
1674
+ bridge.connectors = localConnectors
1675
+ window[CANVAS_BRIDGE_STATE_KEY] = bridge
1676
+ }, [localWidgets, localConnectors])
1677
+
1652
1678
  // Delete selected widget on Delete/Backspace key
1653
1679
  useEffect(() => {
1654
1680
  function handleSelectStart(e) {
@@ -2403,9 +2429,11 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2403
2429
  dragPreview={connectorDrag}
2404
2430
  hidden={widgetDragging}
2405
2431
  />
2432
+ <EmbedControllerProvider performanceMode={perfMode} scrollRef={scrollRef}>
2406
2433
  <Canvas {...canvasProps} onDragStart={isLocalDev ? handleItemDragStart : undefined} onDrag={isLocalDev ? handleItemDrag : undefined} onDragEnd={isLocalDev ? handleItemDragEnd : undefined}>
2407
2434
  {allChildren}
2408
2435
  </Canvas>
2436
+ </EmbedControllerProvider>
2409
2437
  </div>
2410
2438
  </div>
2411
2439
  {showGhInstallBanner && (
@@ -6,6 +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
10
  import styles from './PrototypeEmbed.module.css'
10
11
  import overlayStyles from './embedOverlay.module.css'
11
12
 
@@ -67,6 +68,7 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
67
68
  const [expanded, setExpanded] = useState(false)
68
69
  const [filter, setFilter] = useState('')
69
70
  const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasThemeFromStorage())
71
+ const { active: embedActive, activate: activateEmbed, performanceMode, tooMany } = useEmbedActive(widgetId, embedRef)
70
72
  const inputRef = useRef(null)
71
73
  const filterRef = useRef(null)
72
74
  const embedRef = useRef(null)
@@ -84,6 +86,8 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
84
86
  return `${base}${sep}_sb_embed&_sb_hide_branch_bar&_sb_theme_target=prototype&_sb_canvas_theme=${canvasTheme}${hash}`
85
87
  }, [rawSrc, canvasTheme])
86
88
 
89
+ const inactive = !embedActive
90
+
87
91
  const prototypeIndex = useMemo(() => {
88
92
  try { return buildPrototypeIndex() }
89
93
  catch { return { folders: [], prototypes: [], globalFlows: [], sorted: { title: { prototypes: [], folders: [] } } } }
@@ -295,7 +299,7 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
295
299
  className={styles.embed}
296
300
  style={{ width, height, ...chromeVars }}
297
301
  >
298
- <div className={styles.header}>
302
+ <div className={`${styles.header}${inactive ? ` ${styles.headerPaused}` : ''}`}>
299
303
  <span className={styles.headerIcon}><CollageFrameIcon size={16} /></span>
300
304
  <span className={styles.headerTitle}>{prototypeTitle}</span>
301
305
  </div>
@@ -353,6 +357,17 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
353
357
  </div>
354
358
  </form>
355
359
  </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>
356
371
  ) : iframeSrc ? (
357
372
  <>
358
373
  <div
@@ -33,6 +33,12 @@
33
33
  text-overflow: ellipsis;
34
34
  }
35
35
 
36
+ .headerPaused {
37
+ background: var(--bgColor-attention-muted, #fff8c5);
38
+ color: var(--fgColor-attention, #9a6700);
39
+ border-bottom-color: var(--borderColor-attention-muted, #d4a72c66);
40
+ }
41
+
36
42
  .iframeContainer {
37
43
  position: relative;
38
44
  width: 100%;
@@ -10,6 +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
14
  import { createInspectorHighlighter } from '@dfosco/storyboard-core/inspector/highlighter'
14
15
  import WidgetWrapper from './WidgetWrapper.jsx'
15
16
  import ResizeHandle from './ResizeHandle.jsx'
@@ -65,6 +66,7 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
65
66
  const [highlightedHtml, setHighlightedHtml] = useState(null)
66
67
  const [sourceLoading, setSourceLoading] = useState(false)
67
68
  const [storyIndexKey, setStoryIndexKey] = useState(0)
69
+ const { active: embedActive, activate: activateEmbed, performanceMode, tooMany } = useEmbedActive(widgetId, containerRef)
68
70
 
69
71
  // Re-resolve story URL when the story index is live-patched
70
72
  useEffect(() => {
@@ -171,6 +173,9 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
171
173
  [storyId, exportName, storyIndexKey],
172
174
  )
173
175
 
176
+ // Only render iframe when embed is active (controlled by EmbedController)
177
+ const shouldRenderIframe = embedActive && iframeSrc
178
+
174
179
  useIframeDevLogs({
175
180
  widget: 'StoryWidget',
176
181
  loaded: interactive && !showCode && Boolean(iframeSrc),
@@ -178,6 +183,7 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
178
183
  })
179
184
 
180
185
  const displayName = exportName ? `${storyId} / ${exportName}` : storyId
186
+ const inactive = !embedActive
181
187
 
182
188
  if (!storyId) {
183
189
  return (
@@ -212,7 +218,7 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
212
218
  return (
213
219
  <WidgetWrapper>
214
220
  <div ref={containerRef} className={styles.container} style={sizeStyle}>
215
- <div className={styles.header}>
221
+ <div className={`${styles.header}${inactive ? ` ${styles.headerPaused}` : ''}`}>
216
222
  <span className={styles.headerIcon}><ComponentIcon size={16} /></span>
217
223
  <span className={styles.headerTitle}>{displayName}</span>
218
224
  </div>
@@ -236,6 +242,17 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
236
242
  <pre className={styles.codeBlock}><code>{sourceCode || ''}</code></pre>
237
243
  )}
238
244
  </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>
239
256
  ) : (
240
257
  <>
241
258
  <div className={styles.content}>
@@ -37,6 +37,12 @@
37
37
  text-overflow: ellipsis;
38
38
  }
39
39
 
40
+ .headerPaused {
41
+ background: var(--bgColor-attention-muted, #fff8c5);
42
+ color: var(--fgColor-attention, #9a6700);
43
+ border-bottom-color: var(--borderColor-attention-muted, #d4a72c66);
44
+ }
45
+
40
46
  .content {
41
47
  position: relative;
42
48
  width: 100%;
@@ -1,4 +1,5 @@
1
- import { useRef, useEffect, useCallback, useState } from 'react'
1
+ import { useRef, useEffect, useCallback, useState, forwardRef, useImperativeHandle } from 'react'
2
+ import { createPortal } from 'react-dom'
2
3
  import { readProp } from './widgetProps.js'
3
4
  import { schemas } from './widgetProps.js'
4
5
  import { getTerminalConfig } from '@dfosco/storyboard-core'
@@ -56,6 +57,52 @@ function calcDimensions(widthPx, heightPx) {
56
57
  return { cols, rows }
57
58
  }
58
59
 
60
+ const EMBED_TYPES = new Set(['prototype', 'story'])
61
+
62
+ /**
63
+ * Find the first connected embed (prototype or story) widget via the canvas bridge.
64
+ */
65
+ function findConnectedEmbed(widgetId) {
66
+ const bridge = window.__storyboardCanvasBridgeState
67
+ if (!bridge?.connectors || !bridge?.widgets) return null
68
+ const connectedIds = new Set()
69
+ for (const c of bridge.connectors) {
70
+ if (c.startWidgetId === widgetId) connectedIds.add(c.endWidgetId)
71
+ if (c.endWidgetId === widgetId) connectedIds.add(c.startWidgetId)
72
+ }
73
+ for (const w of bridge.widgets) {
74
+ if (connectedIds.has(w.id) && EMBED_TYPES.has(w.type)) return w
75
+ }
76
+ return null
77
+ }
78
+
79
+ /**
80
+ * Build an iframe URL for a connected embed widget.
81
+ */
82
+ function buildEmbedUrl(widget) {
83
+ if (!widget) return null
84
+ const base = (typeof import.meta !== 'undefined' && import.meta.env?.BASE_URL) || '/'
85
+ const baseClean = base.endsWith('/') ? base.slice(0, -1) : base
86
+ if (widget.type === 'prototype') {
87
+ const src = widget.props?.src
88
+ if (!src) return null
89
+ if (/^https?:\/\//.test(src)) return src
90
+ return `${baseClean}${src.startsWith('/') ? '' : '/'}${src}?_sb_embed&_sb_hide_branch_bar`
91
+ }
92
+ if (widget.type === 'story') {
93
+ const storyId = widget.props?.storyId
94
+ const exportName = widget.props?.exportName
95
+ if (!storyId) return null
96
+ const storyData = typeof window !== 'undefined' && window.__storyboardStoryIndex?.[storyId]
97
+ if (storyData?._route) {
98
+ const route = exportName ? `${storyData._route}?export=${exportName}` : storyData._route
99
+ return `${baseClean}${route}`
100
+ }
101
+ return null
102
+ }
103
+ return null
104
+ }
105
+
59
106
  const DEFAULT_THEME = {
60
107
  background: '#0d1117',
61
108
  foreground: '#e6edf3',
@@ -79,7 +126,7 @@ const DEFAULT_THEME = {
79
126
  brightWhite: '#f0f6fc',
80
127
  }
81
128
 
82
- export default function TerminalWidget({ id, props, onUpdate, resizable }) {
129
+ export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizable }, ref) {
83
130
  const width = readProp(props, 'width', terminalSchema)
84
131
  const height = readProp(props, 'height', terminalSchema)
85
132
  const prettyName = props?.prettyName || null
@@ -88,18 +135,59 @@ export default function TerminalWidget({ id, props, onUpdate, resizable }) {
88
135
  const termRef = useRef(null)
89
136
  const terminalRef = useRef(null)
90
137
  const wsRef = useRef(null)
91
- const [ready, setReady] = useState(false)
92
- const [error, setError] = useState(null)
93
- const [sessionEnded, setSessionEnded] = useState(false)
138
+
139
+ // State machine: dormant → connecting → live → ended
140
+ // ↘ error
141
+ const [phase, setPhase] = useState('dormant') // dormant | connecting | live | error | ended
142
+ const [errorMsg, setErrorMsg] = useState(null)
143
+ const [interactive, setInteractive] = useState(false)
94
144
  const [connectAttempt, setConnectAttempt] = useState(0)
145
+ const [expanded, setExpanded] = useState(false)
146
+ const [waking, setWaking] = useState(false)
147
+ const expandContainerRef = useRef(null)
148
+
149
+ // Activate: transition from dormant to connecting
150
+ const activate = useCallback(() => {
151
+ if (phase === 'dormant') setPhase('connecting')
152
+ }, [phase])
153
+
154
+ const enterInteractive = useCallback(() => {
155
+ if (phase === 'dormant') {
156
+ setPhase('connecting')
157
+ }
158
+ setInteractive(true)
159
+ }, [phase])
160
+
161
+ // Exit interactive on click outside
162
+ useEffect(() => {
163
+ if (!interactive) return
164
+ function handlePointerDown(e) {
165
+ if (terminalRef.current && !terminalRef.current.contains(e.target)) {
166
+ const chromeEl = e.target.closest(`[data-widget-id="${id}"]`)
167
+ if (chromeEl) return
168
+ setInteractive(false)
169
+ }
170
+ }
171
+ document.addEventListener('pointerdown', handlePointerDown)
172
+ return () => document.removeEventListener('pointerdown', handlePointerDown)
173
+ }, [interactive, id])
174
+
175
+ useImperativeHandle(ref, () => ({
176
+ handleAction(actionId) {
177
+ if (actionId === 'expand') {
178
+ if (phase === 'dormant') setPhase('connecting')
179
+ setExpanded(true)
180
+ }
181
+ },
182
+ }), [phase])
95
183
 
96
184
  const handleResize = useCallback((w, h) => {
97
185
  onUpdate?.({ width: w, height: h })
98
186
  }, [onUpdate])
99
187
 
100
- // Initialize terminal
188
+ // Connect terminal + WebSocket only when phase is 'connecting'
101
189
  useEffect(() => {
102
- if (!containerRef.current) return
190
+ if (phase !== 'connecting' || !containerRef.current) return
103
191
 
104
192
  let disposed = false
105
193
  let term = null
@@ -126,55 +214,46 @@ export default function TerminalWidget({ id, props, onUpdate, resizable }) {
126
214
  term.open(containerRef.current)
127
215
  termRef.current = term
128
216
 
129
- // Connect WebSocket
130
217
  const url = getWsUrl(id, prettyName)
131
218
  ws = new WebSocket(url)
132
219
  wsRef.current = ws
133
220
 
134
221
  ws.onopen = () => {
135
222
  if (disposed) return
136
- setReady(true)
223
+ setPhase('live')
137
224
  ws.send(JSON.stringify({ type: 'resize', cols: dims.cols, rows: dims.rows }))
138
225
  }
139
226
 
140
227
  ws.onmessage = (e) => {
141
228
  if (disposed) return
142
229
  const data = typeof e.data === 'string' ? e.data : null
143
- // Intercept JSON control messages from the server
144
230
  if (data && data.startsWith('{')) {
145
231
  try {
146
232
  const msg = JSON.parse(data)
147
- if (msg.type === 'session-info' || msg.type === 'conflict' || msg.type === 'detached') {
148
- // Control message don't render to terminal
149
- return
150
- }
151
- } catch {
152
- // Not valid JSON — pass through as terminal data
153
- }
233
+ if (msg.type === 'session-info' || msg.type === 'conflict' || msg.type === 'detached') return
234
+ } catch { /* not JSON */ }
154
235
  }
155
236
  term.write(typeof e.data === 'string' ? e.data : new Uint8Array(e.data))
156
237
  }
157
238
 
158
239
  ws.onclose = () => {
159
240
  if (disposed) return
160
- setReady(false)
161
- setSessionEnded(true)
241
+ setPhase('ended')
162
242
  }
163
243
 
164
244
  ws.onerror = () => {
165
245
  if (disposed) return
166
- setReady(false)
167
- setSessionEnded(true)
246
+ setPhase('ended')
168
247
  }
169
248
 
170
- // Terminal input → WebSocket
171
249
  term.onData((data) => {
172
- if (ws.readyState === WebSocket.OPEN) {
173
- ws.send(data)
174
- }
250
+ if (ws.readyState === WebSocket.OPEN) ws.send(data)
175
251
  })
176
252
  } catch (err) {
177
- if (!disposed) setError(err.message || 'Failed to load terminal')
253
+ if (!disposed) {
254
+ setErrorMsg(err.message || 'Failed to load terminal')
255
+ setPhase('error')
256
+ }
178
257
  }
179
258
  }
180
259
 
@@ -187,7 +266,7 @@ export default function TerminalWidget({ id, props, onUpdate, resizable }) {
187
266
  termRef.current = null
188
267
  wsRef.current = null
189
268
  }
190
- }, [id, connectAttempt])
269
+ }, [id, phase === 'connecting', connectAttempt])
191
270
 
192
271
  // Resize terminal on dimension changes
193
272
  useEffect(() => {
@@ -202,19 +281,57 @@ export default function TerminalWidget({ id, props, onUpdate, resizable }) {
202
281
  return () => clearTimeout(timer)
203
282
  }, [width, height])
204
283
 
205
- const handleClick = useCallback(() => {
206
- if (sessionEnded) return
207
- termRef.current?.focus()
208
- }, [sessionEnded])
284
+ // Resize terminal to fill the expand container
285
+ useEffect(() => {
286
+ if (!expanded || !termRef.current || !expandContainerRef.current) return
287
+ const timer = setTimeout(() => {
288
+ const el = expandContainerRef.current
289
+ if (!el) return
290
+ const dims = calcDimensions(el.clientWidth, el.clientHeight - 40)
291
+ termRef.current?.resize?.(dims.cols, dims.rows)
292
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
293
+ wsRef.current.send(JSON.stringify({ type: 'resize', cols: dims.cols, rows: dims.rows }))
294
+ }
295
+ }, 100)
296
+ return () => clearTimeout(timer)
297
+ }, [expanded])
209
298
 
210
- const [waking, setWaking] = useState(false)
299
+ // Restore terminal size when collapsing
300
+ useEffect(() => {
301
+ if (expanded) return
302
+ if (!termRef.current) return
303
+ const timer = setTimeout(() => {
304
+ const dims = calcDimensions(width, height)
305
+ termRef.current?.resize?.(dims.cols, dims.rows)
306
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
307
+ wsRef.current.send(JSON.stringify({ type: 'resize', cols: dims.cols, rows: dims.rows }))
308
+ }
309
+ }, 100)
310
+ return () => clearTimeout(timer)
311
+ }, [expanded, width, height])
312
+
313
+ // Reparent terminal DOM node between inline and expand
314
+ useEffect(() => {
315
+ const xtermEl = containerRef.current
316
+ if (!xtermEl) return
317
+ if (expanded && expandContainerRef.current) {
318
+ expandContainerRef.current.appendChild(xtermEl)
319
+ } else if (!expanded && terminalRef.current) {
320
+ terminalRef.current.appendChild(xtermEl)
321
+ }
322
+ }, [expanded])
323
+
324
+ const handleClick = useCallback(() => {
325
+ if (phase === 'ended') return
326
+ if (phase === 'live') termRef.current?.focus()
327
+ }, [phase])
211
328
 
212
329
  const handleStartSession = useCallback(() => {
213
330
  setWaking(true)
214
331
  setTimeout(() => {
215
332
  setWaking(false)
216
- setSessionEnded(false)
217
- setError(null)
333
+ setErrorMsg(null)
334
+ setPhase('connecting')
218
335
  setConnectAttempt(c => c + 1)
219
336
  }, 1500)
220
337
  }, [])
@@ -222,8 +339,13 @@ export default function TerminalWidget({ id, props, onUpdate, resizable }) {
222
339
  // Show interact gate when session is ready but not interacting
223
340
 
224
341
  const titleLabel = `terminal · ${prettyName || '...'}`
342
+ const connectedEmbed = expanded ? findConnectedEmbed(id) : null
343
+ const embedUrl = expanded ? buildEmbedUrl(connectedEmbed) : null
344
+ const hasSplit = Boolean(embedUrl)
345
+ const isDormant = phase === 'dormant'
225
346
 
226
347
  return (
348
+ <>
227
349
  <div className={styles.container}>
228
350
  <div className={styles.titleBar}>{titleLabel}</div>
229
351
  <div
@@ -235,13 +357,51 @@ export default function TerminalWidget({ id, props, onUpdate, resizable }) {
235
357
  }}
236
358
  onClick={handleClick}
237
359
  >
238
- {error && !sessionEnded && (
360
+ {phase === 'error' && (
239
361
  <div className={styles.error}>
240
- <span>⚠ {error}</span>
362
+ <span>⚠ {errorMsg}</span>
363
+ </div>
364
+ )}
365
+ {!expanded && <div ref={containerRef} className={styles.xtermContainer} />}
366
+
367
+ {/* Dormant: not yet activated */}
368
+ {isDormant && (
369
+ <div
370
+ className={overlayStyles.interactOverlay}
371
+ style={{ backgroundColor: '#0d1117' }}
372
+ onClick={(e) => {
373
+ if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
374
+ enterInteractive()
375
+ }}
376
+ role="button"
377
+ tabIndex={0}
378
+ onKeyDown={(e) => { if (e.key === 'Enter') enterInteractive() }}
379
+ aria-label="Click to interact"
380
+ >
381
+ <span className={overlayStyles.interactHint}>Click to interact</span>
382
+ </div>
383
+ )}
384
+
385
+ {/* Live but not interactive: gated overlay */}
386
+ {phase === 'live' && !interactive && (
387
+ <div
388
+ className={overlayStyles.interactOverlay}
389
+ style={{ backgroundColor: 'transparent' }}
390
+ onClick={(e) => {
391
+ if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
392
+ enterInteractive()
393
+ }}
394
+ role="button"
395
+ tabIndex={0}
396
+ onKeyDown={(e) => { if (e.key === 'Enter') enterInteractive() }}
397
+ aria-label="Click to interact"
398
+ >
399
+ <span className={overlayStyles.interactHint}>Click to interact</span>
241
400
  </div>
242
401
  )}
243
- <div ref={containerRef} className={styles.xtermContainer} />
244
- {sessionEnded && (
402
+
403
+ {/* Session ended */}
404
+ {phase === 'ended' && (
245
405
  <div
246
406
  className={overlayStyles.interactOverlay}
247
407
  style={{ backgroundColor: '#0d1117', flexDirection: 'column', gap: 0 }}
@@ -263,7 +423,9 @@ export default function TerminalWidget({ id, props, onUpdate, resizable }) {
263
423
  </span>
264
424
  </div>
265
425
  )}
266
- {!ready && !error && !sessionEnded && (
426
+
427
+ {/* Connecting */}
428
+ {phase === 'connecting' && (
267
429
  <div className={styles.loading}>Connecting…</div>
268
430
  )}
269
431
  </div>
@@ -276,5 +438,34 @@ export default function TerminalWidget({ id, props, onUpdate, resizable }) {
276
438
  />
277
439
  )}
278
440
  </div>
441
+ {createPortal(
442
+ <div
443
+ className={styles.expandBackdrop}
444
+ style={expanded ? undefined : { display: 'none' }}
445
+ onPointerDown={(e) => e.stopPropagation()}
446
+ onKeyDown={(e) => { if (e.key === 'Escape') setExpanded(false) }}
447
+ onWheel={(e) => e.stopPropagation()}
448
+ >
449
+ <div className={styles.expandTopBar}>
450
+ <span className={styles.expandTitle}>{titleLabel}</span>
451
+ {hasSplit && connectedEmbed && (
452
+ <span className={styles.expandEmbedLabel}>
453
+ {connectedEmbed.type === 'story' ? connectedEmbed.props?.storyId : connectedEmbed.props?.src || 'Prototype'}
454
+ </span>
455
+ )}
456
+ <button className={styles.expandClose} onClick={() => setExpanded(false)} aria-label="Close expanded view" autoFocus>✕</button>
457
+ </div>
458
+ <div className={`${styles.expandBody}${hasSplit ? ` ${styles.expandSplit}` : ''}`}>
459
+ <div ref={expandContainerRef} className={styles.expandTerminal} />
460
+ {hasSplit && (
461
+ <div className={styles.expandEmbed}>
462
+ <iframe src={embedUrl} className={styles.expandIframe} title="Connected embed" />
463
+ </div>
464
+ )}
465
+ </div>
466
+ </div>,
467
+ document.body
468
+ )}
469
+ </>
279
470
  )
280
- }
471
+ })
@@ -156,3 +156,108 @@
156
156
  transform: translateY(-24px);
157
157
  }
158
158
  }
159
+
160
+ /* Fullscreen expand */
161
+ .expandBackdrop {
162
+ position: fixed;
163
+ inset: 0;
164
+ z-index: 100000;
165
+ background: #0d1117;
166
+ display: flex;
167
+ flex-direction: column;
168
+ animation: expandFadeIn 0.15s ease;
169
+ }
170
+
171
+ @keyframes expandFadeIn {
172
+ from { opacity: 0; }
173
+ to { opacity: 1; }
174
+ }
175
+
176
+ .expandTopBar {
177
+ display: flex;
178
+ align-items: center;
179
+ height: 40px;
180
+ padding: 0 12px;
181
+ background: #161b22;
182
+ border-bottom: 1px solid #30363d;
183
+ flex-shrink: 0;
184
+ }
185
+
186
+ .expandTitle {
187
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
188
+ font-size: 12px;
189
+ font-weight: 500;
190
+ color: #e6edf3;
191
+ }
192
+
193
+ .expandEmbedLabel {
194
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
195
+ font-size: 12px;
196
+ color: #8b949e;
197
+ margin-left: auto;
198
+ margin-right: 12px;
199
+ overflow: hidden;
200
+ text-overflow: ellipsis;
201
+ white-space: nowrap;
202
+ }
203
+
204
+ .expandClose {
205
+ all: unset;
206
+ cursor: pointer;
207
+ margin-left: auto;
208
+ width: 28px;
209
+ height: 28px;
210
+ display: flex;
211
+ align-items: center;
212
+ justify-content: center;
213
+ border-radius: 6px;
214
+ color: #8b949e;
215
+ font-size: 14px;
216
+ transition: background 100ms, color 100ms;
217
+ }
218
+
219
+ .expandClose:hover {
220
+ background: #30363d;
221
+ color: #e6edf3;
222
+ }
223
+
224
+ .expandEmbedLabel + .expandClose {
225
+ margin-left: 0;
226
+ }
227
+
228
+ .expandBody {
229
+ flex: 1;
230
+ min-height: 0;
231
+ display: flex;
232
+ }
233
+
234
+ .expandTerminal {
235
+ flex: 1;
236
+ min-width: 0;
237
+ overflow: hidden;
238
+ background: #0d1117;
239
+ }
240
+
241
+ .expandTerminal :global(.xterm) {
242
+ height: 100%;
243
+ }
244
+
245
+ .expandSplit .expandTerminal {
246
+ flex: 1;
247
+ border-right: 1px solid #30363d;
248
+ }
249
+
250
+ .expandSplit .expandEmbed {
251
+ flex: 1;
252
+ min-width: 0;
253
+ }
254
+
255
+ .expandEmbed {
256
+ display: flex;
257
+ }
258
+
259
+ .expandIframe {
260
+ border: none;
261
+ width: 100%;
262
+ height: 100%;
263
+ }
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Embed Controller — manages iframe lifecycle for canvas embed widgets.
3
+ *
4
+ * Behaviors:
5
+ * - Performance mode (per-canvas setting): embeds don't render until clicked
6
+ * - Viewport threshold: if >7 embeds visible, none render (zoom in to reduce)
7
+ * - Viewport exit: embeds deactivate 5s after leaving the viewport
8
+ *
9
+ * Usage:
10
+ * <EmbedControllerProvider performanceMode={bool} scrollRef={ref}>
11
+ * <StoryWidget ... />
12
+ * </EmbedControllerProvider>
13
+ *
14
+ * // Inside a widget:
15
+ * const { active, activate } = useEmbedActive(widgetId, containerRef)
16
+ */
17
+ import { createContext, useContext, useCallback, useEffect, useRef, useSyncExternalStore } from 'react'
18
+
19
+ const DEACTIVATE_DELAY = 5000
20
+ const MAX_VISIBLE_EMBEDS = 7
21
+
22
+ // ── Shared state (module-level, one per page) ──────────────────────────
23
+
24
+ let performanceMode = false
25
+ let visibleEmbedIds = new Set()
26
+ let activeEmbedIds = new Set()
27
+ let manuallyActivatedIds = new Set()
28
+ let listeners = new Set()
29
+
30
+ function notify() {
31
+ for (const fn of listeners) fn()
32
+ }
33
+
34
+ function subscribe(fn) {
35
+ listeners.add(fn)
36
+ return () => listeners.delete(fn)
37
+ }
38
+
39
+ function setPerformanceMode(value) {
40
+ performanceMode = value
41
+ if (value) {
42
+ // Entering perf mode: deactivate all non-manually-activated embeds
43
+ activeEmbedIds = new Set(manuallyActivatedIds)
44
+ }
45
+ notify()
46
+ }
47
+
48
+ function getPerformanceMode() {
49
+ return performanceMode
50
+ }
51
+
52
+ function registerEmbed(id) {
53
+ // In normal mode with few embeds, auto-activate
54
+ if (!performanceMode && visibleEmbedIds.size <= MAX_VISIBLE_EMBEDS) {
55
+ activeEmbedIds.add(id)
56
+ }
57
+ notify()
58
+ }
59
+
60
+ function unregisterEmbed(id) {
61
+ visibleEmbedIds.delete(id)
62
+ activeEmbedIds.delete(id)
63
+ manuallyActivatedIds.delete(id)
64
+ notify()
65
+ }
66
+
67
+ function markVisible(id) {
68
+ visibleEmbedIds.add(id)
69
+ // Auto-activate if not in perf mode and under threshold
70
+ if (!performanceMode && visibleEmbedIds.size <= MAX_VISIBLE_EMBEDS) {
71
+ activeEmbedIds.add(id)
72
+ }
73
+ // If was manually activated, keep it active
74
+ if (manuallyActivatedIds.has(id)) {
75
+ activeEmbedIds.add(id)
76
+ }
77
+ notify()
78
+ }
79
+
80
+ function markHidden(id) {
81
+ visibleEmbedIds.delete(id)
82
+ // Check if other embeds should now activate (dropped below threshold)
83
+ if (!performanceMode && visibleEmbedIds.size <= MAX_VISIBLE_EMBEDS) {
84
+ for (const vid of visibleEmbedIds) {
85
+ activeEmbedIds.add(vid)
86
+ }
87
+ }
88
+ notify()
89
+ }
90
+
91
+ function deactivateEmbed(id) {
92
+ activeEmbedIds.delete(id)
93
+ manuallyActivatedIds.delete(id)
94
+ notify()
95
+ }
96
+
97
+ function activateEmbed(id) {
98
+ activeEmbedIds.add(id)
99
+ manuallyActivatedIds.add(id)
100
+ notify()
101
+ }
102
+
103
+ function isActive(id) {
104
+ return activeEmbedIds.has(id)
105
+ }
106
+
107
+ function isTooManyVisible() {
108
+ return visibleEmbedIds.size > MAX_VISIBLE_EMBEDS
109
+ }
110
+
111
+ // ── React context ──────────────────────────────────────────────────────
112
+
113
+ const EmbedControllerContext = createContext(null)
114
+
115
+ export function EmbedControllerProvider({ performanceMode: perfModeProp, scrollRef, children }) {
116
+ // Sync prop to module state
117
+ useEffect(() => {
118
+ setPerformanceMode(perfModeProp)
119
+ }, [perfModeProp])
120
+
121
+ // Reset on unmount
122
+ useEffect(() => {
123
+ return () => {
124
+ visibleEmbedIds = new Set()
125
+ activeEmbedIds = new Set()
126
+ manuallyActivatedIds = new Set()
127
+ performanceMode = false
128
+ notify()
129
+ }
130
+ }, [])
131
+
132
+ return (
133
+ <EmbedControllerContext.Provider value={scrollRef}>
134
+ {children}
135
+ </EmbedControllerContext.Provider>
136
+ )
137
+ }
138
+
139
+ /**
140
+ * Hook for embed widgets. Returns { active, activate, performanceMode, tooMany }.
141
+ * - active: whether the iframe should be rendered
142
+ * - activate: call to manually activate (user clicked)
143
+ * - performanceMode: whether perf mode is on
144
+ * - tooMany: whether there are too many visible embeds
145
+ */
146
+ export function useEmbedActive(widgetId, containerRef) {
147
+ const scrollRef = useContext(EmbedControllerContext)
148
+ const deactivateTimerRef = useRef(null)
149
+
150
+ // Subscribe to state changes
151
+ const snapshot = useSyncExternalStore(subscribe, () => ({
152
+ active: isActive(widgetId),
153
+ performanceMode: getPerformanceMode(),
154
+ tooMany: isTooManyVisible(),
155
+ }), () => ({
156
+ active: false,
157
+ performanceMode: false,
158
+ tooMany: false,
159
+ }))
160
+
161
+ // Need a stable reference check since useSyncExternalStore compares by reference
162
+ const activeRef = useRef(false)
163
+ const perfRef = useRef(false)
164
+ const tooManyRef = useRef(false)
165
+
166
+ const active = isActive(widgetId)
167
+ const perf = getPerformanceMode()
168
+ const tooMany = isTooManyVisible()
169
+
170
+ if (activeRef.current !== active || perfRef.current !== perf || tooManyRef.current !== tooMany) {
171
+ activeRef.current = active
172
+ perfRef.current = perf
173
+ tooManyRef.current = tooMany
174
+ }
175
+
176
+ // Register/unregister
177
+ useEffect(() => {
178
+ registerEmbed(widgetId)
179
+ return () => {
180
+ unregisterEmbed(widgetId)
181
+ if (deactivateTimerRef.current) clearTimeout(deactivateTimerRef.current)
182
+ }
183
+ }, [widgetId])
184
+
185
+ // IntersectionObserver for viewport tracking
186
+ useEffect(() => {
187
+ const el = containerRef?.current
188
+ if (!el) return
189
+
190
+ const root = scrollRef?.current || null
191
+
192
+ const observer = new IntersectionObserver(
193
+ ([entry]) => {
194
+ if (entry.isIntersecting) {
195
+ // Entered viewport
196
+ if (deactivateTimerRef.current) {
197
+ clearTimeout(deactivateTimerRef.current)
198
+ deactivateTimerRef.current = null
199
+ }
200
+ markVisible(widgetId)
201
+ } else {
202
+ // Left viewport — start deactivation timer
203
+ markHidden(widgetId)
204
+ if (deactivateTimerRef.current) clearTimeout(deactivateTimerRef.current)
205
+ deactivateTimerRef.current = setTimeout(() => {
206
+ deactivateEmbed(widgetId)
207
+ deactivateTimerRef.current = null
208
+ }, DEACTIVATE_DELAY)
209
+ }
210
+ },
211
+ { root, threshold: 0 }
212
+ )
213
+
214
+ observer.observe(el)
215
+ return () => observer.disconnect()
216
+ }, [widgetId, containerRef, scrollRef])
217
+
218
+ const activate = useCallback(() => {
219
+ activateEmbed(widgetId)
220
+ }, [widgetId])
221
+
222
+ return { active, activate, performanceMode: perf, tooMany }
223
+ }