@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.
@@ -1,26 +0,0 @@
1
- .container {
2
- position: relative;
3
- overflow: hidden;
4
- min-width: 100px;
5
- min-height: 60px;
6
- background: var(--bgColor-default, #ffffff);
7
- width: 100%;
8
- height: 100%;
9
- }
10
-
11
- .content {
12
- width: 100%;
13
- height: 100%;
14
- }
15
-
16
- .iframe {
17
- display: block;
18
- width: 100%;
19
- height: 100%;
20
- border: none;
21
- }
22
-
23
- .placeholder {
24
- width: 100%;
25
- height: 100%;
26
- }
@@ -0,0 +1,151 @@
1
+ import { useState, useEffect, useRef } from 'react'
2
+ import styles from './FrozenTerminalOverlay.module.css'
3
+
4
+ /**
5
+ * Renders a frozen terminal preview from the latest server snapshot.
6
+ * Shown when a terminal widget loses its live WebGL slot but the
7
+ * server-side tmux session is still running.
8
+ *
9
+ * The snapshot text is rendered at a base font size then CSS-scaled to
10
+ * fill the widget width — matching the live ghostty terminal's sizing.
11
+ */
12
+
13
+ let Convert = null
14
+ let ansiLoadAttempted = false
15
+
16
+ async function getConverter() {
17
+ if (Convert) return new Convert({ fg: '#e6edf3', bg: '#0d1117', newline: true })
18
+ if (ansiLoadAttempted) return null
19
+ ansiLoadAttempted = true
20
+ try {
21
+ const mod = await import(/* @vite-ignore */ 'ansi-to-html')
22
+ Convert = mod.default || mod
23
+ return new Convert({ fg: '#e6edf3', bg: '#0d1117', newline: true })
24
+ } catch {
25
+ return null
26
+ }
27
+ }
28
+
29
+ function stripAnsi(text) {
30
+ // eslint-disable-next-line no-control-regex
31
+ return text.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
32
+ }
33
+
34
+ function getBaseUrl() {
35
+ const base = (typeof import.meta !== 'undefined' && import.meta.env?.BASE_URL) || '/'
36
+ return base.endsWith('/') ? base : base + '/'
37
+ }
38
+
39
+ export default function FrozenTerminalOverlay({ widgetId, onActivate }) {
40
+ const [html, setHtml] = useState(null)
41
+ const [plainText, setPlainText] = useState(null)
42
+ const [scale, setScale] = useState(1)
43
+ const containerRef = useRef(null)
44
+ const preRef = useRef(null)
45
+
46
+ useEffect(() => {
47
+ let cancelled = false
48
+
49
+ async function fetchSnapshot() {
50
+ const baseUrl = getBaseUrl()
51
+ const urls = [
52
+ `${baseUrl}_storyboard/canvas/terminal-snapshot/${widgetId}`,
53
+ `${baseUrl}_storyboard/terminal-snapshots/${widgetId}.snapshot.json`,
54
+ ]
55
+
56
+ for (const url of urls) {
57
+ try {
58
+ const res = await fetch(url)
59
+ if (!res.ok) continue
60
+ const data = await res.json()
61
+ if (cancelled) return
62
+ const text = data.paneContent || data.content || data.output || ''
63
+ if (!text) continue
64
+
65
+ const converter = await getConverter()
66
+ if (cancelled) return
67
+ if (converter) {
68
+ setHtml(converter.toHtml(text))
69
+ } else {
70
+ setPlainText(stripAnsi(text))
71
+ }
72
+ return
73
+ } catch {
74
+ continue
75
+ }
76
+ }
77
+ }
78
+
79
+ fetchSnapshot()
80
+ return () => { cancelled = true }
81
+ }, [widgetId])
82
+
83
+ // Scale the pre to fill the padded content area width
84
+ useEffect(() => {
85
+ const container = containerRef.current
86
+ const pre = preRef.current
87
+ if (!container || !pre) return
88
+
89
+ function updateScale() {
90
+ const style = getComputedStyle(container)
91
+ const padL = parseFloat(style.paddingLeft) || 0
92
+ const padR = parseFloat(style.paddingRight) || 0
93
+ const availableWidth = container.clientWidth - padL - padR
94
+ const naturalWidth = pre.scrollWidth
95
+ if (naturalWidth > 0 && availableWidth > 0) {
96
+ setScale(availableWidth / naturalWidth)
97
+ }
98
+ }
99
+
100
+ const observer = new ResizeObserver(updateScale)
101
+ observer.observe(container)
102
+ updateScale()
103
+
104
+ return () => observer.disconnect()
105
+ }, [html, plainText])
106
+
107
+ const hasSnapshot = !!(html || plainText)
108
+
109
+ return (
110
+ <div
111
+ className={styles.overlay}
112
+ onClick={onActivate}
113
+ role="button"
114
+ tabIndex={0}
115
+ onKeyDown={(e) => { if (e.key === 'Enter') onActivate?.() }}
116
+ aria-label="Click to resume terminal"
117
+ >
118
+ {/* Faded snapshot background — scaled to fill widget width */}
119
+ {hasSnapshot && (
120
+ <div ref={containerRef} className={styles.snapshotContent}>
121
+ {html && (
122
+ <pre
123
+ ref={preRef}
124
+ className={styles.snapshotPre}
125
+ style={{ transform: `scale(${scale})` }}
126
+ dangerouslySetInnerHTML={{ __html: html }}
127
+ />
128
+ )}
129
+ {!html && plainText && (
130
+ <pre
131
+ ref={preRef}
132
+ className={styles.snapshotPre}
133
+ style={{ transform: `scale(${scale})` }}
134
+ >
135
+ {plainText}
136
+ </pre>
137
+ )}
138
+ </div>
139
+ )}
140
+
141
+ {/* Status + action */}
142
+ <div className={styles.statusLayer}>
143
+ <span className={styles.actionButton}>Click to resume</span>
144
+ <span className={styles.statusBadge}>
145
+ Running in background
146
+ <span className={styles.orbitSpinner} />
147
+ </span>
148
+ </div>
149
+ </div>
150
+ )
151
+ }
@@ -0,0 +1,83 @@
1
+ .overlay {
2
+ position: absolute;
3
+ inset: 0;
4
+ background: var(--term-bg, #181b22);
5
+ overflow: hidden;
6
+ cursor: pointer;
7
+ }
8
+
9
+ /* ── Faded snapshot background ── */
10
+
11
+ .snapshotContent {
12
+ position: absolute;
13
+ inset: 0;
14
+ overflow: hidden;
15
+ padding: var(--base-size-16, 16px);
16
+ font-family: 'Ghostty', 'SF Mono', 'Menlo', 'Monaco', 'Courier New', monospace;
17
+ font-size: 12px;
18
+ line-height: 1.4;
19
+ color: #e6edf3;
20
+ opacity: 0.35;
21
+ pointer-events: none;
22
+ }
23
+
24
+ .snapshotPre {
25
+ margin: 0;
26
+ white-space: pre;
27
+ display: inline-block;
28
+ font-family: inherit;
29
+ font-size: inherit;
30
+ line-height: inherit;
31
+ transform-origin: top left;
32
+ }
33
+
34
+ /* ── Status + action layer ── */
35
+
36
+ .statusLayer {
37
+ position: absolute;
38
+ inset: 0;
39
+ display: flex;
40
+ flex-direction: column;
41
+ align-items: center;
42
+ justify-content: center;
43
+ gap: 12px;
44
+ pointer-events: none;
45
+ }
46
+
47
+ /* Matches interactHint from embedOverlay — solid pill button, always visible */
48
+ .actionButton {
49
+ color: var(--fgColor-onInverse, #fff);
50
+ background-color: var(--bgColor-inverse, #21262d);
51
+ padding: var(--base-size-12, 12px) var(--base-size-16, 16px);
52
+ border-radius: var(--base-size-6, 6px);
53
+ font-size: 14px;
54
+ font-weight: 600;
55
+ pointer-events: none;
56
+ }
57
+
58
+ /* Transparent badge — no opaque bg so snapshot text stays readable */
59
+ .statusBadge {
60
+ display: inline-flex;
61
+ align-items: center;
62
+ gap: 8px;
63
+ color: #8b949e;
64
+ font-size: 12px;
65
+ padding: 4px 0;
66
+ letter-spacing: 0.01em;
67
+ }
68
+
69
+ /* ── Orbit spinner ── */
70
+
71
+ .orbitSpinner {
72
+ display: inline-block;
73
+ width: 12px;
74
+ height: 12px;
75
+ border: 1.5px solid transparent;
76
+ border-top-color: #8b949e;
77
+ border-radius: 50%;
78
+ animation: orbitSpin 1s linear infinite;
79
+ }
80
+
81
+ @keyframes orbitSpin {
82
+ to { transform: rotate(360deg); }
83
+ }
@@ -2,6 +2,7 @@ import { useState, useCallback, useEffect, useRef, forwardRef, useImperativeHand
2
2
  import { readProp, promptSchema } from './widgetProps.js'
3
3
  import { CopilotIcon, SquareFillIcon, CheckCircleIcon, XIcon } from '@primer/octicons-react'
4
4
  import ResizeHandle from './ResizeHandle.jsx'
5
+ import { useWebGLSlot, Priority } from '../WebGLContextPool.jsx'
5
6
  import styles from './PromptWidget.module.css'
6
7
 
7
8
  function getBase() {
@@ -92,6 +93,18 @@ const PromptWidget = forwardRef(function PromptWidget({ id, props, onUpdate, res
92
93
  const termDisposedRef = useRef(false)
93
94
  const textareaRef = useRef(null)
94
95
 
96
+ // ── WebGL context pool integration ──
97
+ // Only request a live slot when the output terminal is actually shown
98
+ const { isLive, setPriority } = useWebGLSlot(id)
99
+
100
+ useEffect(() => {
101
+ if (showOutput && execStatus !== 'idle') {
102
+ setPriority(Priority.VISIBLE)
103
+ } else {
104
+ setPriority(Priority.OFFSCREEN)
105
+ }
106
+ }, [showOutput, execStatus, setPriority])
107
+
95
108
  const onUpdateRef = useRef(onUpdate)
96
109
  useEffect(() => { onUpdateRef.current = onUpdate }, [onUpdate])
97
110
 
@@ -206,8 +219,9 @@ const PromptWidget = forwardRef(function PromptWidget({ id, props, onUpdate, res
206
219
  }
207
220
  }, [onUpdate])
208
221
 
209
- // Embedded read-only terminal
222
+ // Embedded read-only terminal (only created when pool grants a live slot)
210
223
  useEffect(() => {
224
+ if (!isLive) return
211
225
  if (!showOutput || execStatus === 'idle') return
212
226
  if (!termContainerRef.current) return
213
227
 
@@ -279,7 +293,7 @@ const PromptWidget = forwardRef(function PromptWidget({ id, props, onUpdate, res
279
293
  termRef.current = null
280
294
  wsRef.current = null
281
295
  }
282
- }, [showOutput, execStatus, id, width, height])
296
+ }, [isLive, showOutput, execStatus, id, width, height])
283
297
 
284
298
  const isPending = execStatus === 'pending'
285
299
  const isDone = execStatus === 'done'
@@ -0,0 +1,208 @@
1
+ /**
2
+ * StorySetWidget — renders all exports from a story in a single iframe grid.
3
+ *
4
+ * Instead of N iframes (one per export), this widget loads one iframe pointing
5
+ * to the isolate-set endpoint. Each export renders in a grid cell inside
6
+ * that single page. The user can select a cell (via label click) which updates
7
+ * `props.selected` — visible to connected agents.
8
+ *
9
+ * User-facing label: "Component Set"
10
+ *
11
+ * Props: { storyId, layout, selected, width, height }
12
+ */
13
+ import { forwardRef, useImperativeHandle, useRef, useCallback, useState, useEffect, useMemo } from 'react'
14
+ import { getStoryData } from '@dfosco/storyboard-core'
15
+ import Icon from '../../Icon.jsx'
16
+ import WidgetWrapper from './WidgetWrapper.jsx'
17
+ import ResizeHandle from './ResizeHandle.jsx'
18
+ import { useIframeDevLogs } from './iframeDevLogs.js'
19
+ import styles from './StorySetWidget.module.css'
20
+ import overlayStyles from './embedOverlay.module.css'
21
+
22
+ function GridIcon({ size = 16 }) {
23
+ return <Icon name="iconoir/view-grid" size={size} />
24
+ }
25
+
26
+ function resolveStorySetUrl(storyId, layout, selected) {
27
+ const story = getStoryData(storyId)
28
+ if (!story?._storyModule) return ''
29
+ const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
30
+ const params = new URLSearchParams()
31
+ params.set('module', story._storyModule)
32
+ if (layout) params.set('layout', layout)
33
+ if (selected) params.set('selected', selected)
34
+ return `${base}/_storyboard/canvas/isolate-set?${params}`
35
+ }
36
+
37
+ export default forwardRef(function StorySetWidget({ id: widgetId, props, onUpdate, resizable }, ref) {
38
+ const storyId = props?.storyId || ''
39
+ const layout = props?.layout || 'horizontal'
40
+ const selected = props?.selected || ''
41
+ const width = props?.width
42
+ const height = props?.height
43
+
44
+ const containerRef = useRef(null)
45
+ const iframeRef = useRef(null)
46
+ const [interactive, setInteractive] = useState(false)
47
+ const [storyIndexKey, setStoryIndexKey] = useState(0)
48
+
49
+ // Re-resolve when story index is live-patched
50
+ useEffect(() => {
51
+ const handler = () => setStoryIndexKey((k) => k + 1)
52
+ document.addEventListener('storyboard:story-index-changed', handler)
53
+ return () => document.removeEventListener('storyboard:story-index-changed', handler)
54
+ }, [])
55
+
56
+ const enterInteractive = useCallback(() => setInteractive(true), [])
57
+
58
+ // Exit interactive mode when clicking outside
59
+ useEffect(() => {
60
+ if (!interactive) return
61
+ function handlePointerDown(e) {
62
+ if (containerRef.current && !containerRef.current.contains(e.target)) {
63
+ const chromeEl = e.target.closest(`[data-widget-id="${widgetId}"]`)
64
+ if (chromeEl) return
65
+ setInteractive(false)
66
+ }
67
+ }
68
+ document.addEventListener('pointerdown', handlePointerDown)
69
+ return () => document.removeEventListener('pointerdown', handlePointerDown)
70
+ }, [interactive, widgetId])
71
+
72
+ // Listen for selection messages from the embedded grid
73
+ useEffect(() => {
74
+ function handleMessage(e) {
75
+ if (e.source !== iframeRef.current?.contentWindow) return
76
+ if (e.data?.type === 'storyboard:component-set:select') {
77
+ const newSelected = e.data.exportName || ''
78
+ if (newSelected !== selected) {
79
+ onUpdate?.({ selected: newSelected })
80
+ }
81
+ } else if (e.data?.type === 'storyboard:component-set:resize') {
82
+ // Auto-size widget to fit the grid content (+ header height)
83
+ const headerH = 32
84
+ const newW = Math.max(200, Math.ceil(e.data.width))
85
+ const newH = Math.max(60, Math.ceil(e.data.height) + headerH)
86
+ if (newW !== width || newH !== height) {
87
+ onUpdate?.({ width: newW, height: newH })
88
+ }
89
+ }
90
+ }
91
+ window.addEventListener('message', handleMessage)
92
+ return () => window.removeEventListener('message', handleMessage)
93
+ }, [selected, width, height, onUpdate])
94
+
95
+ const handleResize = useCallback((w, h) => {
96
+ onUpdate?.({ width: w, height: h })
97
+ }, [onUpdate])
98
+
99
+ useImperativeHandle(ref, () => ({
100
+ handleAction(actionId) {
101
+ if (actionId === 'flip-layout') {
102
+ const next = layout === 'horizontal' ? 'vertical' : 'horizontal'
103
+ onUpdate?.({ layout: next })
104
+ return true
105
+ } else if (actionId === 'open-external') {
106
+ const story = getStoryData(storyId)
107
+ if (story?._route) {
108
+ const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
109
+ window.open(`${base}${story._route}`, '_blank', 'noopener')
110
+ }
111
+ return true
112
+ }
113
+ },
114
+ }), [storyId, layout, onUpdate])
115
+
116
+ const iframeSrc = useMemo(
117
+ () => resolveStorySetUrl(storyId, layout, selected),
118
+ // storyIndexKey forces re-evaluation when HMR mutates the story index
119
+ // eslint-disable-next-line react-hooks/exhaustive-deps
120
+ [storyId, layout, selected, storyIndexKey],
121
+ )
122
+
123
+ useIframeDevLogs({
124
+ widget: 'StorySetWidget',
125
+ loaded: interactive && Boolean(iframeSrc),
126
+ src: iframeSrc,
127
+ })
128
+
129
+ const displayName = storyId || 'Component Set'
130
+
131
+ if (!storyId) {
132
+ return (
133
+ <WidgetWrapper>
134
+ <div className={styles.container} ref={containerRef}>
135
+ <div className={styles.error}>
136
+ <span className={styles.errorIcon}><GridIcon size={20} /></span>
137
+ <span className={styles.errorText}>Missing story ID</span>
138
+ </div>
139
+ </div>
140
+ </WidgetWrapper>
141
+ )
142
+ }
143
+
144
+ if (!iframeSrc) {
145
+ return (
146
+ <WidgetWrapper>
147
+ <div className={styles.container} ref={containerRef}>
148
+ <div className={styles.error}>
149
+ <span className={styles.errorIcon}><GridIcon size={20} /></span>
150
+ <span className={styles.errorText}>Story &ldquo;{storyId}&rdquo; not found or has no route</span>
151
+ </div>
152
+ </div>
153
+ </WidgetWrapper>
154
+ )
155
+ }
156
+
157
+ const sizeStyle = {}
158
+ if (typeof width === 'number') sizeStyle.width = `${width}px`
159
+ if (typeof height === 'number') sizeStyle.height = `${height}px`
160
+
161
+ return (
162
+ <WidgetWrapper>
163
+ <div ref={containerRef} className={styles.container} style={sizeStyle}>
164
+ <div className={styles.header}>
165
+ <span className={styles.headerIcon}><GridIcon size={16} /></span>
166
+ <span className={styles.headerTitle}>{displayName}</span>
167
+ {selected && (
168
+ <span className={styles.headerSelected}>· {selected}</span>
169
+ )}
170
+ <span className={styles.headerLayout} title={`Layout: ${layout}`}>
171
+ {layout === 'horizontal' ? '⇔' : '⇕'}
172
+ </span>
173
+ </div>
174
+ <div className={styles.content}>
175
+ <iframe
176
+ ref={iframeRef}
177
+ src={iframeSrc}
178
+ className={styles.iframe}
179
+ title={`${displayName} component set`}
180
+ onLoad={(e) => e.target.blur()}
181
+ />
182
+ </div>
183
+ {!interactive && (
184
+ <div
185
+ className={overlayStyles.interactOverlay}
186
+ onClick={(e) => {
187
+ if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
188
+ enterInteractive()
189
+ }}
190
+ role="button"
191
+ tabIndex={0}
192
+ onKeyDown={(e) => {
193
+ if (e.key === 'Enter' || e.key === ' ') {
194
+ e.preventDefault()
195
+ e.stopPropagation()
196
+ enterInteractive()
197
+ }
198
+ }}
199
+ aria-label="Click to interact"
200
+ >
201
+ <span className={overlayStyles.interactHint}>Click to interact</span>
202
+ </div>
203
+ )}
204
+ </div>
205
+ {resizable && <ResizeHandle targetRef={containerRef} width={width} height={height} onResize={handleResize} />}
206
+ </WidgetWrapper>
207
+ )
208
+ })
@@ -0,0 +1,89 @@
1
+ /* StorySetWidget — canvas widget chrome */
2
+
3
+ .container {
4
+ position: relative;
5
+ overflow: hidden;
6
+ min-width: 200px;
7
+ min-height: 120px;
8
+ background: var(--bgColor-default, #ffffff);
9
+ border: 3px solid var(--borderColor-default, #d0d7de);
10
+ border-radius: 12px;
11
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
12
+ width: 100%;
13
+ height: 100%;
14
+ }
15
+
16
+ .header {
17
+ display: flex;
18
+ align-items: center;
19
+ gap: 6px;
20
+ padding: 10px 10px;
21
+ font-size: 12px;
22
+ font-weight: 500;
23
+ color: var(--fgColor-muted, #656d76);
24
+ background: var(--bgColor-muted, #f6f8fa);
25
+ border-bottom: 1px solid var(--borderColor-muted, #d8dee4);
26
+ white-space: nowrap;
27
+ overflow: hidden;
28
+ text-overflow: ellipsis;
29
+ user-select: none;
30
+ }
31
+
32
+ .headerIcon {
33
+ display: inline-flex;
34
+ flex-shrink: 0;
35
+ }
36
+
37
+ .headerTitle {
38
+ overflow: hidden;
39
+ text-overflow: ellipsis;
40
+ }
41
+
42
+ .headerSelected {
43
+ color: var(--fgColor-accent, #0969da);
44
+ font-weight: 600;
45
+ flex-shrink: 0;
46
+ }
47
+
48
+ .headerLayout {
49
+ margin-left: auto;
50
+ font-size: 14px;
51
+ opacity: 0.5;
52
+ flex-shrink: 0;
53
+ }
54
+
55
+ .content {
56
+ position: relative;
57
+ width: 100%;
58
+ height: calc(100% - 37px);
59
+ }
60
+
61
+ .iframe {
62
+ position: absolute;
63
+ inset: 0;
64
+ display: block;
65
+ width: 100%;
66
+ height: 100%;
67
+ border: none;
68
+ z-index: 1;
69
+ }
70
+
71
+ .error {
72
+ display: flex;
73
+ align-items: center;
74
+ gap: 8px;
75
+ padding: 16px;
76
+ color: var(--fgColor-danger, #cf222e);
77
+ font-family: system-ui, -apple-system, sans-serif;
78
+ font-size: 13px;
79
+ line-height: 1.5;
80
+ }
81
+
82
+ .errorIcon {
83
+ font-size: 20px;
84
+ flex-shrink: 0;
85
+ }
86
+
87
+ .errorText {
88
+ word-break: break-word;
89
+ }
@@ -26,13 +26,12 @@ function ComponentIcon({ size = 36 }) {
26
26
 
27
27
  function resolveStoryUrl(storyId, exportName) {
28
28
  const story = getStoryData(storyId)
29
- if (!story?._route) return ''
29
+ if (!story?._storyModule) return ''
30
30
  const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
31
31
  const params = new URLSearchParams()
32
+ params.set('module', story._storyModule)
32
33
  if (exportName) params.set('export', exportName)
33
- params.set('_sb_embed', '')
34
- params.set('_sb_hide_branch_bar', '')
35
- return `${base}${story._route}?${params}`
34
+ return `${base}/_storyboard/canvas/isolate?${params}`
36
35
  }
37
36
 
38
37
  const _storySourcesCache = {}