@dfosco/storyboard-react 4.2.4 → 4.2.5
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 +3 -3
- package/src/CommandPalette/CommandPalette.jsx +4 -6
- package/src/Viewfinder.jsx +7 -4
- package/src/canvas/CanvasPage.jsx +60 -3
- package/src/canvas/WebGLContextPool.jsx +292 -0
- package/src/canvas/WebGLContextPool.test.jsx +165 -0
- package/src/canvas/componentIsolate.jsx +45 -15
- package/src/canvas/componentSetIsolate.jsx +257 -0
- package/src/canvas/widgets/ComponentSetWidget.jsx +2 -208
- package/src/canvas/widgets/ComponentWidget.jsx +0 -139
- package/src/canvas/widgets/ComponentWidget.module.css +0 -26
- package/src/canvas/widgets/FrozenTerminalOverlay.jsx +151 -0
- package/src/canvas/widgets/FrozenTerminalOverlay.module.css +83 -0
- package/src/canvas/widgets/PromptWidget.jsx +16 -2
- package/src/canvas/widgets/StorySetWidget.jsx +208 -0
- package/src/canvas/widgets/StorySetWidget.module.css +89 -0
- package/src/canvas/widgets/StoryWidget.jsx +3 -4
- package/src/canvas/widgets/TerminalWidget.jsx +146 -100
- package/src/canvas/widgets/TerminalWidget.module.css +23 -0
- package/src/canvas/widgets/embedInteraction.test.jsx +1 -61
- package/src/canvas/widgets/expandUtils.js +3 -4
- package/src/canvas/widgets/index.js +2 -2
- package/src/canvas/widgets/snapshotDisplay.test.jsx +1 -1
- package/src/context.jsx +70 -7
- package/src/vite/data-plugin.js +8 -2
|
@@ -5,6 +5,8 @@ import { getTerminalConfig, getTerminalDimensions } from '@dfosco/storyboard-cor
|
|
|
5
5
|
import { useOverride } from '../../hooks/useOverride.js'
|
|
6
6
|
import { getSplitPaneLabel, findAllConnectedSplitTargets, buildPaneForWidget, buildSplitLayout } from './expandUtils.js'
|
|
7
7
|
import ExpandedPane from './ExpandedPane.jsx'
|
|
8
|
+
import { useWebGLSlot, Priority } from '../WebGLContextPool.jsx'
|
|
9
|
+
import FrozenTerminalOverlay from './FrozenTerminalOverlay.jsx'
|
|
8
10
|
import styles from './TerminalWidget.module.css'
|
|
9
11
|
import overlayStyles from './embedOverlay.module.css'
|
|
10
12
|
|
|
@@ -106,17 +108,16 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, multiSe
|
|
|
106
108
|
const cfg = getTerminalConfig()
|
|
107
109
|
const fontSize = cfg.fontSize ?? 13
|
|
108
110
|
const agentId = props?.agentId || null
|
|
111
|
+
// Config dimensions are authoritative — always use them as the base
|
|
109
112
|
const dims = getTerminalDimensions(agentId, {
|
|
110
113
|
width: readProp(props, 'width', terminalSchema),
|
|
111
114
|
height: readProp(props, 'height', terminalSchema),
|
|
112
115
|
})
|
|
113
|
-
const
|
|
114
|
-
const
|
|
116
|
+
const width = dims.width
|
|
117
|
+
const height = dims.height
|
|
115
118
|
const prettyName = props?.prettyName || null
|
|
116
119
|
const startupCommand = props?.startupCommand || null
|
|
117
120
|
|
|
118
|
-
const width = rawWidth
|
|
119
|
-
const height = rawHeight
|
|
120
121
|
// Snapped dimensions computed from ghostty's actual cell metrics (set after open)
|
|
121
122
|
const [snappedHeight, setSnappedHeight] = useState(null)
|
|
122
123
|
const [snappedWidth, setSnappedWidth] = useState(null)
|
|
@@ -145,6 +146,25 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, multiSe
|
|
|
145
146
|
const expandContainerRef = useRef(null)
|
|
146
147
|
const dragHintTimer = useRef(null)
|
|
147
148
|
|
|
149
|
+
// ── WebGL context pool integration ──
|
|
150
|
+
// webglReady: PINNED (bypass cap, guaranteed live — no frozen flash)
|
|
151
|
+
// All others: VISIBLE (auto-requests a live slot — no manual click needed)
|
|
152
|
+
const initialPriority = props?.webglReady ? Priority.PINNED : Priority.VISIBLE
|
|
153
|
+
const { isLive, generation, setPriority } = useWebGLSlot(id, initialPriority)
|
|
154
|
+
|
|
155
|
+
// Update pool priority based on widget state
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
if (expanded || interactive) {
|
|
158
|
+
setPriority(Priority.PINNED)
|
|
159
|
+
}
|
|
160
|
+
// Priority for VISIBLE/NEAR/OFFSCREEN is set by CanvasPage via usePoolVisibilityUpdater
|
|
161
|
+
}, [expanded, interactive, setPriority])
|
|
162
|
+
|
|
163
|
+
// Request activation when user clicks a frozen terminal
|
|
164
|
+
const handleFrozenActivate = useCallback(() => {
|
|
165
|
+
setPriority(Priority.PINNED)
|
|
166
|
+
}, [setPriority])
|
|
167
|
+
|
|
148
168
|
useImperativeHandle(ref, () => ({
|
|
149
169
|
handleAction(actionId) {
|
|
150
170
|
if (actionId === 'expand') { setExpanded('single'); return true }
|
|
@@ -179,8 +199,9 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, multiSe
|
|
|
179
199
|
if (multiSelected && interactive) setInteractive(false)
|
|
180
200
|
}, [multiSelected])
|
|
181
201
|
|
|
182
|
-
// Connect terminal + WebSocket
|
|
202
|
+
// Connect terminal + WebSocket (only when pool grants a live slot)
|
|
183
203
|
useEffect(() => {
|
|
204
|
+
if (!isLive) return
|
|
184
205
|
if (!containerRef.current) return
|
|
185
206
|
|
|
186
207
|
let disposed = false
|
|
@@ -308,8 +329,10 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, multiSe
|
|
|
308
329
|
if (term) term.dispose()
|
|
309
330
|
termRef.current = null
|
|
310
331
|
wsRef.current = null
|
|
332
|
+
setReady(false)
|
|
333
|
+
setRevealed(false)
|
|
311
334
|
}
|
|
312
|
-
}, [id, connectAttempt])
|
|
335
|
+
}, [id, isLive, generation, connectAttempt])
|
|
313
336
|
|
|
314
337
|
// Resize terminal on dimension changes
|
|
315
338
|
useEffect(() => {
|
|
@@ -472,112 +495,135 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, multiSe
|
|
|
472
495
|
ref={terminalRef}
|
|
473
496
|
className={styles.terminal}
|
|
474
497
|
style={{
|
|
475
|
-
...(typeof (snappedWidth ?? width) === 'number'
|
|
476
|
-
|
|
498
|
+
...(typeof (isLive ? (snappedWidth ?? width) : width) === 'number'
|
|
499
|
+
? { width: `${isLive ? (snappedWidth ?? width) : width}px` }
|
|
500
|
+
: undefined),
|
|
501
|
+
...(typeof (isLive ? (snappedHeight ?? height) : height) === 'number'
|
|
502
|
+
? { height: `${isLive ? (snappedHeight ?? height) : height}px` }
|
|
503
|
+
: undefined),
|
|
477
504
|
}}
|
|
478
505
|
onClick={handleClick}
|
|
479
506
|
onPointerDown={handleTerminalPointerDown}
|
|
480
507
|
onKeyDown={interactive ? (e) => e.stopPropagation() : undefined}
|
|
481
508
|
>
|
|
482
|
-
{
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
<div className={styles.error}>
|
|
489
|
-
<span>⚠ {error}</span>
|
|
490
|
-
</div>
|
|
491
|
-
)}
|
|
492
|
-
<div ref={containerRef} className={styles.xtermContainer} style={{ opacity: revealed ? 1 : 0 }} />
|
|
493
|
-
|
|
494
|
-
{/* Live but not interactive */}
|
|
495
|
-
{revealed && !interactive && !sessionEnded && (
|
|
496
|
-
<div
|
|
497
|
-
className={overlayStyles.interactOverlay}
|
|
498
|
-
style={{ backgroundColor: 'transparent' }}
|
|
499
|
-
onClick={(e) => {
|
|
500
|
-
if (multiSelected || e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
|
|
501
|
-
setInteractive(true)
|
|
502
|
-
termRef.current?.focus({ preventScroll: true })
|
|
503
|
-
}}
|
|
504
|
-
role="button"
|
|
505
|
-
tabIndex={0}
|
|
506
|
-
onKeyDown={(e) => { if (!multiSelected && e.key === 'Enter') { setInteractive(true); termRef.current?.focus({ preventScroll: true }) } }}
|
|
507
|
-
aria-label="Click to interact"
|
|
508
|
-
>
|
|
509
|
-
{!multiSelected && <span className={overlayStyles.interactHint}>Click to interact</span>}
|
|
510
|
-
</div>
|
|
509
|
+
{/* ── Frozen state: WebGL context released, show snapshot ── */}
|
|
510
|
+
{!isLive && (
|
|
511
|
+
<FrozenTerminalOverlay
|
|
512
|
+
widgetId={id}
|
|
513
|
+
onActivate={handleFrozenActivate}
|
|
514
|
+
/>
|
|
511
515
|
)}
|
|
512
516
|
|
|
513
|
-
{/*
|
|
514
|
-
{
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
517
|
+
{/* ── Live state: ghostty WebGL terminal ── */}
|
|
518
|
+
{isLive && (
|
|
519
|
+
<>
|
|
520
|
+
{showDragHint && (
|
|
521
|
+
<div className={styles.dragHint}>
|
|
522
|
+
<span className={styles.dragHintArrow}>←</span> Drag here to move widget
|
|
523
|
+
</div>
|
|
524
|
+
)}
|
|
525
|
+
{error && !sessionEnded && (
|
|
526
|
+
<div className={styles.error}>
|
|
527
|
+
<span>⚠ {error}</span>
|
|
528
|
+
</div>
|
|
529
|
+
)}
|
|
530
|
+
<div ref={containerRef} className={styles.xtermContainer} style={{ opacity: revealed ? 1 : 0 }} />
|
|
531
|
+
|
|
532
|
+
{/* Live but not interactive */}
|
|
533
|
+
{revealed && !interactive && !sessionEnded && (
|
|
534
|
+
<div
|
|
535
|
+
className={overlayStyles.interactOverlay}
|
|
536
|
+
style={{ backgroundColor: 'transparent' }}
|
|
537
|
+
onClick={(e) => {
|
|
538
|
+
if (multiSelected || e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
|
|
539
|
+
setInteractive(true)
|
|
540
|
+
termRef.current?.focus({ preventScroll: true })
|
|
541
|
+
}}
|
|
542
|
+
role="button"
|
|
543
|
+
tabIndex={0}
|
|
544
|
+
onKeyDown={(e) => { if (!multiSelected && e.key === 'Enter') { setInteractive(true); termRef.current?.focus({ preventScroll: true }) } }}
|
|
545
|
+
aria-label="Click to interact"
|
|
546
|
+
>
|
|
547
|
+
{!multiSelected && <span className={overlayStyles.interactHint}>Click to interact</span>}
|
|
548
|
+
</div>
|
|
549
|
+
)}
|
|
550
|
+
|
|
551
|
+
{/* Session ended — resource limited */}
|
|
552
|
+
{sessionEnded && resourceLimited && (
|
|
553
|
+
<div
|
|
554
|
+
className={overlayStyles.interactOverlay}
|
|
555
|
+
style={{ backgroundColor: 'var(--term-bg, #0d1117)', flexDirection: 'column', gap: '8px', padding: '24px' }}
|
|
556
|
+
>
|
|
557
|
+
<span className={styles.resourceIcon}>⚠</span>
|
|
558
|
+
<span className={styles.resourceTitle}>No terminal devices available</span>
|
|
559
|
+
<span className={styles.resourceMessage}>
|
|
560
|
+
Too many terminal sessions are open.
|
|
561
|
+
{resourceLimited.counts && (
|
|
562
|
+
<span className={styles.resourceCounts}>
|
|
563
|
+
{resourceLimited.counts.live} live · {resourceLimited.counts.background} background · {resourceLimited.counts.archived} archived
|
|
564
|
+
</span>
|
|
565
|
+
)}
|
|
543
566
|
</span>
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
567
|
+
<div className={styles.resourceActions}>
|
|
568
|
+
{!resourceLimited.cleanupResult && (resourceLimited.counts?.background > 0 || resourceLimited.counts?.archived > 0) && (
|
|
569
|
+
<button className={styles.resourceBtn} onClick={handleCleanupAndRetry}>
|
|
570
|
+
Close background sessions
|
|
571
|
+
</button>
|
|
572
|
+
)}
|
|
573
|
+
{resourceLimited.cleanupResult === 'nothing-to-clean' && (
|
|
574
|
+
<span className={styles.resourceMuted}>
|
|
575
|
+
All background sessions already cleaned. Close some live terminals to free resources.
|
|
576
|
+
</span>
|
|
577
|
+
)}
|
|
578
|
+
{resourceLimited.cleanupResult === 'failed' && (
|
|
579
|
+
<span className={styles.resourceMuted}>
|
|
580
|
+
Cleanup failed — could not reach dev server.
|
|
581
|
+
</span>
|
|
582
|
+
)}
|
|
583
|
+
<button className={styles.resourceBtnSecondary} onClick={handleStartSession}>
|
|
584
|
+
Retry
|
|
585
|
+
</button>
|
|
586
|
+
</div>
|
|
587
|
+
</div>
|
|
588
|
+
)}
|
|
551
589
|
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
590
|
+
{/* Session ended — normal */}
|
|
591
|
+
{sessionEnded && !resourceLimited && (
|
|
592
|
+
<div
|
|
593
|
+
className={overlayStyles.interactOverlay}
|
|
594
|
+
style={{ backgroundColor: 'var(--term-bg, #0d1117)', flexDirection: 'column', gap: 0 }}
|
|
595
|
+
onClick={handleStartSession}
|
|
596
|
+
role="button"
|
|
597
|
+
tabIndex={0}
|
|
598
|
+
aria-label="Start terminal session"
|
|
599
|
+
onKeyDown={(e) => { if (e.key === 'Enter') handleStartSession() }}
|
|
600
|
+
>
|
|
601
|
+
{!waking && (
|
|
602
|
+
<>
|
|
603
|
+
<div className={styles.buddyZzz}>
|
|
604
|
+
<span className={styles.z1}>z</span>
|
|
605
|
+
<span className={styles.z2}>z</span>
|
|
606
|
+
<span className={styles.z3}>z</span>
|
|
607
|
+
</div>
|
|
608
|
+
<span className={styles.sessionEndedBadge}>Session ended</span>
|
|
609
|
+
<span className={styles.sessionEndedAction}>Click to start</span>
|
|
610
|
+
</>
|
|
611
|
+
)}
|
|
612
|
+
{waking && (
|
|
613
|
+
<span className={overlayStyles.interactHint} style={{ opacity: 1 }}>
|
|
614
|
+
Waking up...
|
|
615
|
+
</span>
|
|
616
|
+
)}
|
|
568
617
|
</div>
|
|
569
618
|
)}
|
|
570
|
-
<span className={overlayStyles.interactHint}>
|
|
571
|
-
{waking ? 'Waking up...' : connectAttempt > 0 ? 'Continue terminal session' : 'Start terminal session'}
|
|
572
|
-
</span>
|
|
573
|
-
</div>
|
|
574
|
-
)}
|
|
575
619
|
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
620
|
+
{/* Connecting / reveal mask */}
|
|
621
|
+
{!revealed && !error && !sessionEnded && (
|
|
622
|
+
<div className={styles.loading}>
|
|
623
|
+
<div className={styles.spinner} />
|
|
624
|
+
</div>
|
|
625
|
+
)}
|
|
626
|
+
</>
|
|
581
627
|
)}
|
|
582
628
|
</div>
|
|
583
629
|
</div>
|
|
@@ -204,6 +204,29 @@
|
|
|
204
204
|
}
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
/* ── Session-ended badge + action ── */
|
|
208
|
+
|
|
209
|
+
.sessionEndedBadge {
|
|
210
|
+
display: inline-flex;
|
|
211
|
+
align-items: center;
|
|
212
|
+
background: rgba(110, 118, 129, 0.25);
|
|
213
|
+
color: #8b949e;
|
|
214
|
+
font-size: 12px;
|
|
215
|
+
padding: 5px 14px;
|
|
216
|
+
border-radius: 999px;
|
|
217
|
+
letter-spacing: 0.01em;
|
|
218
|
+
pointer-events: none;
|
|
219
|
+
margin-bottom: 12px;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
.sessionEndedAction {
|
|
223
|
+
color: #e6edf3;
|
|
224
|
+
font-size: 14px;
|
|
225
|
+
font-weight: 600;
|
|
226
|
+
opacity: 0.7;
|
|
227
|
+
pointer-events: none;
|
|
228
|
+
}
|
|
229
|
+
|
|
207
230
|
/* ── Resource-limited overlay ── */
|
|
208
231
|
|
|
209
232
|
.resourceIcon {
|
|
@@ -5,7 +5,6 @@ import { describe, it, expect, vi } from 'vitest'
|
|
|
5
5
|
import { render, fireEvent, screen } from '@testing-library/react'
|
|
6
6
|
import PrototypeEmbed from './PrototypeEmbed.jsx'
|
|
7
7
|
import FigmaEmbed from './FigmaEmbed.jsx'
|
|
8
|
-
import ComponentWidget from './ComponentWidget.jsx'
|
|
9
8
|
import StoryWidget from './StoryWidget.jsx'
|
|
10
9
|
|
|
11
10
|
// Mock buildPrototypeIndex for PrototypeEmbed
|
|
@@ -24,7 +23,7 @@ vi.mock('@dfosco/storyboard-core', () => ({
|
|
|
24
23
|
globalFlows: [],
|
|
25
24
|
sorted: { title: { prototypes: [], folders: [] } },
|
|
26
25
|
}),
|
|
27
|
-
getStoryData: (storyId) => ({ _route: `/components/${storyId}` }),
|
|
26
|
+
getStoryData: (storyId) => ({ _storyModule: `/src/canvas/${storyId}.story.jsx`, _route: `/components/${storyId}` }),
|
|
28
27
|
}))
|
|
29
28
|
|
|
30
29
|
// Simple mock wrapper for WidgetWrapper
|
|
@@ -43,11 +42,6 @@ vi.mock('./ResizeHandle.jsx', () => ({
|
|
|
43
42
|
default: () => <div data-testid="resize-handle" />,
|
|
44
43
|
}))
|
|
45
44
|
|
|
46
|
-
// Mock ComponentErrorBoundary
|
|
47
|
-
vi.mock('../ComponentErrorBoundary.jsx', () => ({
|
|
48
|
-
default: ({ children }) => <div data-testid="error-boundary">{children}</div>,
|
|
49
|
-
}))
|
|
50
|
-
|
|
51
45
|
describe('Embed interaction overlay', () => {
|
|
52
46
|
describe('PrototypeEmbed', () => {
|
|
53
47
|
const defaultProps = {
|
|
@@ -176,58 +170,4 @@ describe('Embed interaction overlay', () => {
|
|
|
176
170
|
expect(container.querySelector('iframe')).toBeInTheDocument()
|
|
177
171
|
})
|
|
178
172
|
})
|
|
179
|
-
|
|
180
|
-
describe('ComponentWidget', () => {
|
|
181
|
-
const MockComponent = () => <div>Mock Component</div>
|
|
182
|
-
|
|
183
|
-
const defaultProps = {
|
|
184
|
-
component: MockComponent,
|
|
185
|
-
jsxModule: null,
|
|
186
|
-
exportName: 'MockComponent',
|
|
187
|
-
canvasTheme: 'light',
|
|
188
|
-
isLocalDev: false,
|
|
189
|
-
width: 200,
|
|
190
|
-
height: 150,
|
|
191
|
-
onUpdate: vi.fn(),
|
|
192
|
-
resizable: false,
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
it('renders "Click to interact" hint', () => {
|
|
196
|
-
render(<ComponentWidget {...defaultProps} />)
|
|
197
|
-
|
|
198
|
-
const hint = screen.getByText('Click to interact')
|
|
199
|
-
expect(hint).toBeInTheDocument()
|
|
200
|
-
})
|
|
201
|
-
|
|
202
|
-
it('enters interactive mode on single click', () => {
|
|
203
|
-
render(<ComponentWidget {...defaultProps} />)
|
|
204
|
-
|
|
205
|
-
const overlay = screen.getByRole('button', { name: /click to interact/i })
|
|
206
|
-
fireEvent.click(overlay)
|
|
207
|
-
|
|
208
|
-
expect(screen.queryByRole('button', { name: /click to interact/i })).not.toBeInTheDocument()
|
|
209
|
-
})
|
|
210
|
-
|
|
211
|
-
it('mounts dev iframe only after user activation', () => {
|
|
212
|
-
const { container } = render(
|
|
213
|
-
<ComponentWidget
|
|
214
|
-
{...defaultProps}
|
|
215
|
-
isLocalDev
|
|
216
|
-
jsxModule="/src/canvas/mock.story.jsx"
|
|
217
|
-
exportName="MockComponent"
|
|
218
|
-
/>
|
|
219
|
-
)
|
|
220
|
-
|
|
221
|
-
const overlay = screen.getByRole('button', { name: /click to interact with component/i })
|
|
222
|
-
expect(container.querySelector('iframe')).not.toBeInTheDocument()
|
|
223
|
-
|
|
224
|
-
fireEvent.click(overlay)
|
|
225
|
-
|
|
226
|
-
expect(container.querySelector('iframe')).toBeInTheDocument()
|
|
227
|
-
|
|
228
|
-
fireEvent.pointerDown(document.body)
|
|
229
|
-
expect(screen.getByRole('button', { name: /click to interact with component/i })).toBeInTheDocument()
|
|
230
|
-
expect(container.querySelector('iframe')).not.toBeInTheDocument()
|
|
231
|
-
})
|
|
232
|
-
})
|
|
233
173
|
})
|
|
@@ -470,12 +470,11 @@ export function buildSecondaryIframeUrl(widget) {
|
|
|
470
470
|
const exportName = widget.props?.exportName
|
|
471
471
|
if (!storyId) return null
|
|
472
472
|
const storyData = getStoryData(storyId)
|
|
473
|
-
if (storyData?.
|
|
473
|
+
if (storyData?._storyModule) {
|
|
474
474
|
const params = new URLSearchParams()
|
|
475
|
+
params.set('module', storyData._storyModule)
|
|
475
476
|
if (exportName) params.set('export', exportName)
|
|
476
|
-
params
|
|
477
|
-
params.set('_sb_hide_branch_bar', '')
|
|
478
|
-
return `${baseClean}${storyData._route}?${params}`
|
|
477
|
+
return `${baseClean}/_storyboard/canvas/isolate?${params}`
|
|
479
478
|
}
|
|
480
479
|
return null
|
|
481
480
|
}
|
|
@@ -6,7 +6,7 @@ import ImageWidget from './ImageWidget.jsx'
|
|
|
6
6
|
import FigmaEmbed from './FigmaEmbed.jsx'
|
|
7
7
|
import CodePenEmbed from './CodePenEmbed.jsx'
|
|
8
8
|
import StoryWidget from './StoryWidget.jsx'
|
|
9
|
-
import
|
|
9
|
+
import StorySetWidget from './StorySetWidget.jsx'
|
|
10
10
|
import TerminalWidget from './TerminalWidget.jsx'
|
|
11
11
|
import TerminalReadWidget from './TerminalReadWidget.jsx'
|
|
12
12
|
import PromptWidget from './PromptWidget.jsx'
|
|
@@ -25,7 +25,7 @@ export const widgetRegistry = {
|
|
|
25
25
|
'figma-embed': FigmaEmbed,
|
|
26
26
|
'codepen-embed': CodePenEmbed,
|
|
27
27
|
'story': StoryWidget,
|
|
28
|
-
'component-set':
|
|
28
|
+
'component-set': StorySetWidget,
|
|
29
29
|
'terminal': TerminalWidget,
|
|
30
30
|
'terminal-read': TerminalReadWidget,
|
|
31
31
|
'agent': TerminalWidget,
|
|
@@ -21,7 +21,7 @@ vi.mock('@dfosco/storyboard-core', () => ({
|
|
|
21
21
|
globalFlows: [],
|
|
22
22
|
sorted: { title: { prototypes: [], folders: [] } },
|
|
23
23
|
}),
|
|
24
|
-
getStoryData: (storyId) => ({ _route: `/components/${storyId}` }),
|
|
24
|
+
getStoryData: (storyId) => ({ _storyModule: `/src/canvas/${storyId}.story.jsx`, _route: `/components/${storyId}` }),
|
|
25
25
|
}))
|
|
26
26
|
|
|
27
27
|
vi.mock('./WidgetWrapper.jsx', () => ({
|
package/src/context.jsx
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { useState, useEffect, useMemo, Suspense, lazy } from 'react'
|
|
1
|
+
import { useState, useEffect, useMemo, useRef, Suspense, lazy } from 'react'
|
|
2
2
|
import { useParams, useLocation } from 'react-router-dom'
|
|
3
3
|
// Named import seeds the core data index via init() AND provides canvas/story route data
|
|
4
4
|
import { canvases, stories } from 'virtual:storyboard-data-index'
|
|
5
|
-
import { loadFlow, flowExists, findRecord, deepMerge, setFlowClass, installBodyClassSync, resolveFlowName, resolveRecordName, isModesEnabled } from '@dfosco/storyboard-core'
|
|
5
|
+
import { loadFlow, flowExists, findRecord, deepMerge, setFlowClass, installBodyClassSync, resolveFlowName, resolveRecordName, isModesEnabled, getPrototypeMetadata } from '@dfosco/storyboard-core'
|
|
6
6
|
import { StoryboardContext } from './StoryboardContext.js'
|
|
7
7
|
import usePrototypeReloadGuard from './hooks/usePrototypeReloadGuard.js'
|
|
8
8
|
import styles from './FlowError.module.css'
|
|
@@ -270,12 +270,19 @@ function StoryboardProviderInner({ flowName, sceneName, recordName, recordParam,
|
|
|
270
270
|
}, [])
|
|
271
271
|
|
|
272
272
|
// Skip flow loading for canvas/story pages and flow-less pages
|
|
273
|
-
const { data, error } = useMemo(() => {
|
|
274
|
-
if (canvasId || isMissingCanvasRoute || storyName || isMissingStoryRoute) return { data: null, error: null }
|
|
275
|
-
if (!activeFlowName) return { data: {}, error: null }
|
|
273
|
+
const { data, error, flowTokens } = useMemo(() => {
|
|
274
|
+
if (canvasId || isMissingCanvasRoute || storyName || isMissingStoryRoute) return { data: null, error: null, flowTokens: null }
|
|
275
|
+
if (!activeFlowName) return { data: {}, error: null, flowTokens: null }
|
|
276
276
|
try {
|
|
277
277
|
let flowData = loadFlow(activeFlowName)
|
|
278
278
|
|
|
279
|
+
// Extract tokens before passing data to consumers (reserved metadata key)
|
|
280
|
+
const extractedTokens = flowData?.tokens || null
|
|
281
|
+
if (flowData?.tokens) {
|
|
282
|
+
flowData = { ...flowData }
|
|
283
|
+
delete flowData.tokens
|
|
284
|
+
}
|
|
285
|
+
|
|
279
286
|
// Merge record data if configured (with scoped resolution)
|
|
280
287
|
if (recordName && recordParam && params[recordParam]) {
|
|
281
288
|
const resolvedRecord = resolveRecordName(prototypeName, recordName)
|
|
@@ -286,12 +293,68 @@ function StoryboardProviderInner({ flowName, sceneName, recordName, recordParam,
|
|
|
286
293
|
}
|
|
287
294
|
|
|
288
295
|
setFlowClass(activeFlowName)
|
|
289
|
-
return { data: flowData, error: null }
|
|
296
|
+
return { data: flowData, error: null, flowTokens: extractedTokens }
|
|
290
297
|
} catch (err) {
|
|
291
|
-
return { data: null, error: err.message }
|
|
298
|
+
return { data: null, error: err.message, flowTokens: null }
|
|
292
299
|
}
|
|
293
300
|
}, [canvasId, isMissingCanvasRoute, storyName, isMissingStoryRoute, activeFlowName, recordName, recordParam, params, prototypeName])
|
|
294
301
|
|
|
302
|
+
// Resolve prototype-level tokens from .prototype.json metadata
|
|
303
|
+
const protoTokens = useMemo(() => {
|
|
304
|
+
if (!prototypeName) return null
|
|
305
|
+
const meta = getPrototypeMetadata(prototypeName)
|
|
306
|
+
return meta?.tokens || null
|
|
307
|
+
}, [prototypeName])
|
|
308
|
+
|
|
309
|
+
// Merge prototype + flow tokens (flow wins). Stable reference when tokens don't change.
|
|
310
|
+
const mergedTokens = useMemo(() => {
|
|
311
|
+
if (!protoTokens && !flowTokens) return null
|
|
312
|
+
return { ...(protoTokens || {}), ...(flowTokens || {}) }
|
|
313
|
+
}, [protoTokens, flowTokens])
|
|
314
|
+
|
|
315
|
+
// Track which URL params were set by tokens (vs. user-explicit params)
|
|
316
|
+
const managedParamsRef = useRef({})
|
|
317
|
+
|
|
318
|
+
// Apply merged tokens to URL search params via replaceState.
|
|
319
|
+
// Only sets params not already present (user-explicit wins on first load).
|
|
320
|
+
// Cleans up stale managed params when flow/prototype tokens change.
|
|
321
|
+
useEffect(() => {
|
|
322
|
+
const url = new URL(window.location.href)
|
|
323
|
+
const managed = managedParamsRef.current
|
|
324
|
+
const nextManaged = {}
|
|
325
|
+
let changed = false
|
|
326
|
+
|
|
327
|
+
// Remove stale managed params no longer in merged tokens
|
|
328
|
+
for (const key of Object.keys(managed)) {
|
|
329
|
+
if (!mergedTokens || !(key in mergedTokens)) {
|
|
330
|
+
url.searchParams.delete(key)
|
|
331
|
+
changed = true
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Apply current tokens
|
|
336
|
+
if (mergedTokens) {
|
|
337
|
+
const reserved = new Set(['flow', 'scene'])
|
|
338
|
+
for (const [key, value] of Object.entries(mergedTokens)) {
|
|
339
|
+
if (value == null || typeof value === 'object' || reserved.has(key)) continue
|
|
340
|
+
const strValue = String(value)
|
|
341
|
+
if (!url.searchParams.has(key) || (key in managed && managed[key] !== strValue)) {
|
|
342
|
+
url.searchParams.set(key, strValue)
|
|
343
|
+
nextManaged[key] = strValue
|
|
344
|
+
changed = true
|
|
345
|
+
} else if (key in managed) {
|
|
346
|
+
nextManaged[key] = strValue
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
managedParamsRef.current = nextManaged
|
|
352
|
+
|
|
353
|
+
if (changed) {
|
|
354
|
+
window.history.replaceState(window.history.state, '', url.toString())
|
|
355
|
+
}
|
|
356
|
+
}, [mergedTokens])
|
|
357
|
+
|
|
295
358
|
// Canvas pages get their own rendering path — no flow data needed
|
|
296
359
|
if (canvasId) {
|
|
297
360
|
const canvasData = canvases?.[canvasId]
|
package/src/vite/data-plugin.js
CHANGED
|
@@ -1038,6 +1038,8 @@ export default function storyboardDataPlugin() {
|
|
|
1038
1038
|
// The iframe loads componentIsolate.jsx which reads query params
|
|
1039
1039
|
// (module, export, theme) and renders a single story export.
|
|
1040
1040
|
const isolateEntryPath = new URL('../canvas/componentIsolate.jsx', import.meta.url).pathname
|
|
1041
|
+
// Component-set isolate — renders all exports in a grid, bypassing the full SPA.
|
|
1042
|
+
const componentSetIsolateEntryPath = new URL('../canvas/componentSetIsolate.jsx', import.meta.url).pathname
|
|
1041
1043
|
server.middlewares.use(async (req, res, next) => {
|
|
1042
1044
|
if (!req.url) return next()
|
|
1043
1045
|
let url = req.url
|
|
@@ -1045,15 +1047,19 @@ export default function storyboardDataPlugin() {
|
|
|
1045
1047
|
if (baseNoTrail && url.startsWith(baseNoTrail)) {
|
|
1046
1048
|
url = url.slice(baseNoTrail.length) || '/'
|
|
1047
1049
|
}
|
|
1048
|
-
|
|
1050
|
+
// Match both single-component and component-set isolate routes
|
|
1051
|
+
const isComponentSet = url.startsWith('/_storyboard/canvas/isolate-set')
|
|
1052
|
+
const isSingle = !isComponentSet && url.startsWith('/_storyboard/canvas/isolate')
|
|
1053
|
+
if (!isSingle && !isComponentSet) return next()
|
|
1049
1054
|
|
|
1055
|
+
const entryPath = isComponentSet ? componentSetIsolateEntryPath : isolateEntryPath
|
|
1050
1056
|
const rawHtml = [
|
|
1051
1057
|
'<!DOCTYPE html>',
|
|
1052
1058
|
'<html><head>',
|
|
1053
1059
|
'<style>html,body{margin:0;padding:0;width:100%;height:100%;background:var(--bgColor-default,transparent)}#root{width:100%;height:100%}</style>',
|
|
1054
1060
|
'</head><body>',
|
|
1055
1061
|
'<div id="root"></div>',
|
|
1056
|
-
`<script type="module" src="/@fs${
|
|
1062
|
+
`<script type="module" src="/@fs${entryPath}"></script>`,
|
|
1057
1063
|
'</body></html>',
|
|
1058
1064
|
].join('\n')
|
|
1059
1065
|
|