@dfosco/storyboard-react 4.0.0-beta.13 → 4.0.0-beta.15

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.
@@ -0,0 +1,438 @@
1
+ /**
2
+ * Renders a story at its route URL inside an iframe on canvas.
3
+ *
4
+ * Works like PrototypeEmbed: the story has its own route (e.g. /components/button-patterns)
5
+ * and this widget iframes that URL with ?export=ExportName&_sb_embed for single-export mode.
6
+ *
7
+ * Features:
8
+ * - Title bar showing story name + export (like Figma embed)
9
+ * - "Show code" action toggles between iframe and source view
10
+ * - "Copy code" action copies the story source to clipboard
11
+ *
12
+ * Props: { storyId, exportName, width, height }
13
+ */
14
+ import { forwardRef, useImperativeHandle, useRef, useCallback, useState, useEffect, useMemo } from 'react'
15
+ import { getStoryData } from '@dfosco/storyboard-core'
16
+ import { createInspectorHighlighter } from '@dfosco/storyboard-core/inspector/highlighter'
17
+ import WidgetWrapper from './WidgetWrapper.jsx'
18
+ import ResizeHandle from './ResizeHandle.jsx'
19
+ import { uploadImage } from '../canvasApi.js'
20
+ import styles from './StoryWidget.module.css'
21
+ import overlayStyles from './embedOverlay.module.css'
22
+
23
+ function resolveStoryUrl(storyId, exportName) {
24
+ const story = getStoryData(storyId)
25
+ if (!story?._route) return null
26
+
27
+ const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
28
+ const route = story._route
29
+ const params = new URLSearchParams({ _sb_embed: '1' })
30
+ if (exportName) params.set('export', exportName)
31
+
32
+ return `${base}${route}?${params}`
33
+ }
34
+
35
+ /** Resolve a module path with the app base URL for dynamic imports. */
36
+ function resolveModulePath(modulePath) {
37
+ if (!modulePath || !modulePath.startsWith('/')) return modulePath
38
+ const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
39
+ return base ? `${base}${modulePath}` : modulePath
40
+ }
41
+
42
+ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate, resizable }, ref) {
43
+ const storyId = props?.storyId || ''
44
+ const exportName = props?.exportName || ''
45
+ const width = props?.width
46
+ const height = props?.height
47
+
48
+ // Snapshot props for lazy loading
49
+ const snapshotLight = props?.snapshotLight || null
50
+ const snapshotDark = props?.snapshotDark || null
51
+
52
+ const containerRef = useRef(null)
53
+ const iframeRef = useRef(null)
54
+ const [interactive, setInteractive] = useState(false)
55
+ const [showCode, setShowCode] = useState(!!props?.showCode)
56
+ const [sourceCode, setSourceCode] = useState(null)
57
+ const [highlightedHtml, setHighlightedHtml] = useState(null)
58
+ const [sourceLoading, setSourceLoading] = useState(false)
59
+
60
+ // Theme tracking for snapshot selection
61
+ const [canvasTheme, setCanvasTheme] = useState(() => {
62
+ if (typeof localStorage === 'undefined') return 'light'
63
+ const stored = localStorage.getItem('sb-color-scheme') || 'system'
64
+ if (stored !== 'system') return stored
65
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
66
+ })
67
+
68
+ useEffect(() => {
69
+ function onThemeChanged() {
70
+ const stored = localStorage.getItem('sb-color-scheme') || 'system'
71
+ if (stored !== 'system') { setCanvasTheme(stored); return }
72
+ setCanvasTheme(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
73
+ }
74
+ document.addEventListener('storyboard:theme:changed', onThemeChanged)
75
+ return () => document.removeEventListener('storyboard:theme:changed', onThemeChanged)
76
+ }, [])
77
+
78
+ // Lazy loading state — only use snapshots that match this widget's ID
79
+ const isDark = canvasTheme?.startsWith('dark')
80
+ const snapshotMatchesWidget = (url) => url && widgetId && url.includes(widgetId)
81
+ const validSnapshotLight = snapshotMatchesWidget(snapshotLight) ? snapshotLight : null
82
+ const validSnapshotDark = snapshotMatchesWidget(snapshotDark) ? snapshotDark : null
83
+ const currentSnapshot = isDark ? validSnapshotDark : validSnapshotLight
84
+ const hasSnapshot = !!currentSnapshot
85
+ const [preloadIframe, setPreloadIframe] = useState(!hasSnapshot)
86
+ const [iframeLoaded, setIframeLoaded] = useState(false)
87
+ const [showIframe, setShowIframe] = useState(!hasSnapshot)
88
+ const [showSpinner, setShowSpinner] = useState(false)
89
+ const capturingRef = useRef(false)
90
+
91
+ // Show spinner only after 500ms of loading
92
+ useEffect(() => {
93
+ if (showIframe && !iframeLoaded && hasSnapshot) {
94
+ const timer = setTimeout(() => setShowSpinner(true), 500)
95
+ return () => clearTimeout(timer)
96
+ }
97
+ setShowSpinner(false)
98
+ }, [showIframe, iframeLoaded, hasSnapshot])
99
+ const [storyIndexKey, setStoryIndexKey] = useState(0)
100
+
101
+ // Re-resolve story URL when the story index is live-patched (new story added)
102
+ useEffect(() => {
103
+ const handler = () => setStoryIndexKey((k) => k + 1)
104
+ document.addEventListener('storyboard:story-index-changed', handler)
105
+ return () => document.removeEventListener('storyboard:story-index-changed', handler)
106
+ }, [])
107
+
108
+ const toggleShowCode = useCallback(() => {
109
+ setShowCode((v) => {
110
+ const next = !v
111
+ // Persist to canvas JSONL in dev
112
+ if (onUpdate) {
113
+ onUpdate({ showCode: next })
114
+ }
115
+ return next
116
+ })
117
+ }, [onUpdate])
118
+
119
+ const enterInteractive = useCallback(() => setInteractive(true), [])
120
+
121
+ useEffect(() => {
122
+ if (!interactive) return
123
+ function handlePointerDown(e) {
124
+ if (containerRef.current && !containerRef.current.contains(e.target)) {
125
+ setInteractive(false)
126
+ }
127
+ }
128
+ document.addEventListener('pointerdown', handlePointerDown)
129
+ return () => document.removeEventListener('pointerdown', handlePointerDown)
130
+ }, [interactive])
131
+
132
+ // Listen for snapshot messages from the iframe
133
+ useEffect(() => {
134
+ function handleMessage(e) {
135
+ if (!iframeRef.current?.contentWindow) return
136
+ if (e.source !== iframeRef.current.contentWindow) return
137
+
138
+ if (e.data?.type === 'storyboard:embed:snapshot') {
139
+ if (e.data.error) {
140
+ console.warn('[canvas] Story snapshot capture failed:', e.data.error)
141
+ return
142
+ }
143
+ handleSnapshotResult(e.data.dataUrl)
144
+ return
145
+ }
146
+
147
+ // snapshot-ready means the iframe content has fully rendered
148
+ if (e.data?.type === 'storyboard:embed:snapshot-ready') {
149
+ setIframeLoaded(true)
150
+ if (onUpdate) requestSnapshotCapture()
151
+ }
152
+ }
153
+ window.addEventListener('message', handleMessage)
154
+ return () => window.removeEventListener('message', handleMessage)
155
+ }, [onUpdate, canvasTheme])
156
+
157
+ const requestSnapshotCapture = useCallback(() => {
158
+ if (!iframeRef.current?.contentWindow || capturingRef.current) return
159
+ capturingRef.current = true
160
+ iframeRef.current.contentWindow.postMessage({
161
+ type: 'storyboard:embed:capture',
162
+ requestId: `story-snap-${Date.now()}`,
163
+ }, '*')
164
+ }, [])
165
+
166
+ const handleSnapshotResult = useCallback(async (dataUrl) => {
167
+ if (!dataUrl || !onUpdate || !widgetId) return
168
+ capturingRef.current = false
169
+ try {
170
+ const result = await uploadImage(dataUrl, `snapshot-${widgetId}`)
171
+ if (!result?.success || !result?.filename) return
172
+ const imageUrl = `/_storyboard/canvas/images/${result.filename}`
173
+ const themeKey = isDark ? 'snapshotDark' : 'snapshotLight'
174
+ onUpdate?.({ [themeKey]: imageUrl })
175
+ } catch (err) {
176
+ console.warn('[canvas] Failed to upload story snapshot:', err)
177
+ }
178
+ }, [onUpdate, isDark, widgetId])
179
+
180
+ // Re-capture after resize
181
+ const resizeCaptureTimer = useRef(null)
182
+ const triggerResizeCapture = useCallback(() => {
183
+ if (!onUpdate) return
184
+ clearTimeout(resizeCaptureTimer.current)
185
+ resizeCaptureTimer.current = setTimeout(() => requestSnapshotCapture(), 2000)
186
+ }, [requestSnapshotCapture, onUpdate])
187
+
188
+ const handleResize = useCallback((w, h) => {
189
+ onUpdate?.({ width: w, height: h })
190
+ triggerResizeCapture()
191
+ }, [onUpdate, triggerResizeCapture])
192
+
193
+ // Re-capture for alternate theme variant when theme changes
194
+ const prevThemeRef = useRef(canvasTheme)
195
+ useEffect(() => {
196
+ if (canvasTheme !== prevThemeRef.current && onUpdate && showIframe) {
197
+ prevThemeRef.current = canvasTheme
198
+ const timer = setTimeout(() => requestSnapshotCapture(), 3000)
199
+ return () => clearTimeout(timer)
200
+ }
201
+ prevThemeRef.current = canvasTheme
202
+ }, [canvasTheme, onUpdate, showIframe, requestSnapshotCapture])
203
+
204
+ // Load source code when show-code is toggled on
205
+ useEffect(() => {
206
+ if (!showCode || sourceCode !== null) return
207
+ const story = getStoryData(storyId)
208
+ if (!story?._storyModule) {
209
+ Promise.resolve().then(() => setSourceCode('// Source not available'))
210
+ return
211
+ }
212
+
213
+ let cancelled = false
214
+ Promise.resolve().then(() => { if (!cancelled) setSourceLoading(true) })
215
+
216
+ // Use dynamic import with ?raw to get the file contents as a string.
217
+ // Vite's ?raw suffix returns a module whose default export is the raw text.
218
+ import(/* @vite-ignore */ `${resolveModulePath(story._storyModule)}?raw`)
219
+ .then((mod) => {
220
+ if (cancelled) return
221
+ setSourceCode(mod.default || '// Empty file')
222
+ })
223
+ .catch(() => { if (!cancelled) setSourceCode('// Failed to load source') })
224
+ .finally(() => { if (!cancelled) setSourceLoading(false) })
225
+
226
+ return () => { cancelled = true }
227
+ }, [showCode, sourceCode, storyId])
228
+
229
+ // Re-highlight when the code-box theme changes (storyboard:theme:changed event).
230
+ const [codeThemeKey, setCodeThemeKey] = useState(0)
231
+ useEffect(() => {
232
+ function onThemeChanged() {
233
+ setCodeThemeKey((k) => k + 1)
234
+ }
235
+ document.addEventListener('storyboard:theme:changed', onThemeChanged)
236
+ return () => document.removeEventListener('storyboard:theme:changed', onThemeChanged)
237
+ }, [])
238
+
239
+ // Syntax-highlight source code using the inspector highlighter.
240
+ // Uses the current code-box theme (data-sb-code-theme) set by the theme store.
241
+ useEffect(() => {
242
+ if (!sourceCode) return
243
+ let cancelled = false
244
+ createInspectorHighlighter().then((hl) => {
245
+ if (cancelled) return
246
+ const lang = storyId.endsWith('.tsx') ? 'tsx' : 'jsx'
247
+ const html = hl.codeToHtml(sourceCode, { lang })
248
+ setHighlightedHtml(html)
249
+ })
250
+ return () => { cancelled = true }
251
+ }, [sourceCode, storyId, codeThemeKey])
252
+
253
+ const copyCode = useCallback(async () => {
254
+ if (sourceCode) {
255
+ await navigator.clipboard?.writeText(sourceCode)
256
+ return
257
+ }
258
+ // Load source on demand if not already loaded
259
+ const story = getStoryData(storyId)
260
+ if (!story?._storyModule) return
261
+ try {
262
+ const mod = await import(/* @vite-ignore */ `${resolveModulePath(story._storyModule)}?raw`)
263
+ const code = mod.default || ''
264
+ setSourceCode(code)
265
+ await navigator.clipboard?.writeText(code)
266
+ } catch { /* ignore */ }
267
+ }, [sourceCode, storyId])
268
+
269
+ useImperativeHandle(ref, () => ({
270
+ getState(key) {
271
+ if (key === 'showCode') return showCode
272
+ return undefined
273
+ },
274
+ handleAction(actionId) {
275
+ if (actionId === 'show-code') {
276
+ toggleShowCode()
277
+ } else if (actionId === 'copy-code') {
278
+ copyCode()
279
+ } else if (actionId === 'open-external') {
280
+ const story = getStoryData(storyId)
281
+ if (story?._route) {
282
+ const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
283
+ window.open(`${base}${story._route}`, '_blank', 'noopener')
284
+ }
285
+ }
286
+ },
287
+ }), [storyId, showCode, toggleShowCode, copyCode])
288
+
289
+ const iframeSrc = useMemo(
290
+ () => resolveStoryUrl(storyId, exportName),
291
+ [storyId, exportName, storyIndexKey],
292
+ )
293
+
294
+ const displayName = exportName ? `${storyId} / ${exportName}` : storyId
295
+
296
+ // Error state — missing story or no route
297
+ if (!storyId) {
298
+ return (
299
+ <WidgetWrapper>
300
+ <div className={styles.container} ref={containerRef}>
301
+ <div className={styles.error}>
302
+ <span className={styles.errorIcon}>📖</span>
303
+ <span className={styles.errorText}>Missing story ID</span>
304
+ </div>
305
+ </div>
306
+ </WidgetWrapper>
307
+ )
308
+ }
309
+
310
+ if (!iframeSrc) {
311
+ return (
312
+ <WidgetWrapper>
313
+ <div className={styles.container} ref={containerRef}>
314
+ <div className={styles.error}>
315
+ <span className={styles.errorIcon}>📖</span>
316
+ <span className={styles.errorText}>Story &ldquo;{storyId}&rdquo; not found or has no route</span>
317
+ </div>
318
+ </div>
319
+ </WidgetWrapper>
320
+ )
321
+ }
322
+
323
+ const sizeStyle = {}
324
+ if (typeof width === 'number') sizeStyle.width = `${width}px`
325
+ if (typeof height === 'number') sizeStyle.height = `${height}px`
326
+
327
+ return (
328
+ <WidgetWrapper>
329
+ <div ref={containerRef} className={styles.container} style={sizeStyle}>
330
+ <div className={styles.header}>
331
+ <span className={styles.headerIcon}>📖</span>
332
+ <span className={styles.headerTitle}>{displayName}</span>
333
+ </div>
334
+ {showCode ? (
335
+ <div
336
+ className={styles.codeView}
337
+ data-canvas-allow-text-selection
338
+ onPointerDown={(e) => e.stopPropagation()}
339
+ onMouseDown={(e) => e.stopPropagation()}
340
+ onClick={(e) => e.stopPropagation()}
341
+ >
342
+ <div className={styles.codeHeader}>
343
+ <span className={styles.codeLabel}>{storyId}.story.jsx</span>
344
+ <button
345
+ className={styles.codeCloseBtn}
346
+ onClick={() => setShowCode(false)}
347
+ aria-label="Close code view"
348
+ >×</button>
349
+ </div>
350
+ {sourceLoading ? (
351
+ <div className={styles.codeLoading}>Loading…</div>
352
+ ) : highlightedHtml ? (
353
+ <div
354
+ className={styles.codeBlock}
355
+ dangerouslySetInnerHTML={{ __html: highlightedHtml }}
356
+ />
357
+ ) : (
358
+ <pre className={styles.codeBlock}>
359
+ <code>{sourceCode || ''}</code>
360
+ </pre>
361
+ )}
362
+ </div>
363
+ ) : (
364
+ <>
365
+ {/* Snapshot image — shown until iframe is fully loaded */}
366
+ {hasSnapshot && !(showIframe && iframeLoaded) && (
367
+ <div className={styles.content}>
368
+ <img
369
+ src={(import.meta.env.BASE_URL || '/').replace(/\/$/, '') + currentSnapshot}
370
+ alt={displayName}
371
+ className={styles.snapshotImage}
372
+ draggable={false}
373
+ />
374
+ {showIframe && !iframeLoaded && showSpinner && (
375
+ <div className={styles.snapshotSpinner}>
376
+ <div className={styles.spinner} />
377
+ </div>
378
+ )}
379
+ </div>
380
+ )}
381
+
382
+ {/* Iframe — preloaded on hover, revealed after load */}
383
+ {(preloadIframe || showIframe) && (
384
+ <div
385
+ className={styles.content}
386
+ style={hasSnapshot && !(showIframe && iframeLoaded) ? { position: 'absolute', top: 31, left: 0, right: 0, bottom: 0, opacity: 0, pointerEvents: 'none' } : undefined}
387
+ >
388
+ <iframe
389
+ ref={iframeRef}
390
+ src={iframeSrc}
391
+ className={styles.iframe}
392
+ title={displayName}
393
+ />
394
+ </div>
395
+ )}
396
+
397
+ {!interactive && (
398
+ <div
399
+ className={overlayStyles.interactOverlay}
400
+ onPointerEnter={() => {
401
+ if (!preloadIframe) setPreloadIframe(true)
402
+ }}
403
+ onClick={(e) => {
404
+ if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
405
+ setShowIframe(true)
406
+ setPreloadIframe(true)
407
+ enterInteractive()
408
+ }}
409
+ role="button"
410
+ tabIndex={0}
411
+ onKeyDown={(e) => {
412
+ if (e.key === 'Enter' || e.key === ' ') {
413
+ e.preventDefault()
414
+ e.stopPropagation()
415
+ setShowIframe(true)
416
+ setPreloadIframe(true)
417
+ enterInteractive()
418
+ }
419
+ }}
420
+ aria-label="Click to interact with story component"
421
+ >
422
+ <span className={overlayStyles.interactHint}>Click to interact</span>
423
+ </div>
424
+ )}
425
+ </>
426
+ )}
427
+ {resizable && (
428
+ <ResizeHandle
429
+ targetRef={containerRef}
430
+ minWidth={100}
431
+ minHeight={60}
432
+ onResize={handleResize}
433
+ />
434
+ )}
435
+ </div>
436
+ </WidgetWrapper>
437
+ )
438
+ })
@@ -0,0 +1,200 @@
1
+ .container {
2
+ position: relative;
3
+ overflow: hidden;
4
+ min-width: 100px;
5
+ min-height: 60px;
6
+ background: var(--bgColor-default, #ffffff);
7
+ border: 3px solid var(--borderColor-default, #d0d7de);
8
+ border-radius: 12px;
9
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
10
+ width: 100%;
11
+ height: 100%;
12
+ }
13
+
14
+ .header {
15
+ display: flex;
16
+ align-items: center;
17
+ gap: 6px;
18
+ padding: 6px 10px;
19
+ font-size: 12px;
20
+ font-weight: 500;
21
+ color: var(--fgColor-muted, #656d76);
22
+ background: var(--bgColor-muted, #f6f8fa);
23
+ border-bottom: 1px solid var(--borderColor-muted, #d8dee4);
24
+ white-space: nowrap;
25
+ overflow: hidden;
26
+ text-overflow: ellipsis;
27
+ user-select: none;
28
+ }
29
+
30
+ .headerIcon {
31
+ font-size: 13px;
32
+ flex-shrink: 0;
33
+ }
34
+
35
+ .headerTitle {
36
+ overflow: hidden;
37
+ text-overflow: ellipsis;
38
+ }
39
+
40
+ .content {
41
+ width: 100%;
42
+ height: calc(100% - 31px);
43
+ }
44
+
45
+ .iframe {
46
+ display: block;
47
+ width: 100%;
48
+ height: 100%;
49
+ border: none;
50
+ }
51
+
52
+ .snapshotImage {
53
+ display: block;
54
+ width: 100%;
55
+ height: 100%;
56
+ object-fit: cover;
57
+ object-position: top left;
58
+ }
59
+
60
+ .snapshotSpinner {
61
+ position: absolute;
62
+ inset: 0;
63
+ display: flex;
64
+ align-items: center;
65
+ justify-content: center;
66
+ background: rgba(0, 0, 0, 0.08);
67
+ animation: fadeIn 150ms ease;
68
+ }
69
+
70
+ .spinner {
71
+ width: 24px;
72
+ height: 24px;
73
+ border: 2.5px solid var(--borderColor-default, #d0d7de);
74
+ border-top-color: var(--fgColor-accent, #0969da);
75
+ border-radius: 50%;
76
+ animation: spin 0.6s linear infinite;
77
+ }
78
+
79
+ @keyframes spin {
80
+ to { transform: rotate(360deg); }
81
+ }
82
+
83
+ @keyframes fadeIn {
84
+ from { opacity: 0; }
85
+ to { opacity: 1; }
86
+ }
87
+
88
+ .codeView {
89
+ display: flex;
90
+ flex-direction: column;
91
+ height: calc(100% - 31px);
92
+ overflow: hidden;
93
+ }
94
+
95
+ .codeHeader {
96
+ display: flex;
97
+ align-items: center;
98
+ justify-content: space-between;
99
+ padding: 4px 10px;
100
+ font-size: 11px;
101
+ font-weight: 500;
102
+ color: var(--fgColor-muted, #656d76);
103
+ background: var(--bgColor-inset, #eff2f5);
104
+ border-bottom: 1px solid var(--borderColor-muted, #d8dee4);
105
+ user-select: none;
106
+ flex-shrink: 0;
107
+ }
108
+
109
+ .codeLabel {
110
+ font-family: "Ioskeley Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
111
+ }
112
+
113
+ .codeCloseBtn {
114
+ all: unset;
115
+ cursor: pointer;
116
+ display: flex;
117
+ align-items: center;
118
+ justify-content: center;
119
+ width: 20px;
120
+ height: 20px;
121
+ border-radius: 4px;
122
+ font-size: 14px;
123
+ color: var(--fgColor-muted, #656d76);
124
+ }
125
+
126
+ .codeCloseBtn:hover {
127
+ background: var(--bgColor-neutral-muted, #eaeef2);
128
+ color: var(--fgColor-default, #1f2328);
129
+ }
130
+
131
+ .codeBlock {
132
+ flex: 1;
133
+ margin: 0;
134
+ overflow: auto;
135
+ user-select: text;
136
+ cursor: text;
137
+ }
138
+
139
+ /* Style the highlighted pre from the inspector highlighter.
140
+ padding uses !important to override the inline padding:0 from codeToHtml. */
141
+ .codeBlock pre {
142
+ margin: 0;
143
+ padding: var(--base-size-8, 8px) !important;
144
+ font-family: "Ioskeley Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
145
+ font-size: 12px;
146
+ font-weight: 400;
147
+ line-height: 1.6;
148
+ tab-size: 2;
149
+ min-height: 100%;
150
+ box-sizing: border-box;
151
+ }
152
+
153
+ /* Fallback when no highlighted HTML (plain pre/code) */
154
+ .codeBlock > code {
155
+ font-family: "Ioskeley Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
156
+ font-size: 12px;
157
+ font-weight: 400;
158
+ line-height: 1.6;
159
+ white-space: pre;
160
+ tab-size: 2;
161
+ }
162
+
163
+ .codeLoading {
164
+ flex: 1;
165
+ display: flex;
166
+ align-items: center;
167
+ justify-content: center;
168
+ font-size: 12px;
169
+ color: var(--fgColor-muted, #656d76);
170
+ }
171
+
172
+ .error {
173
+ display: flex;
174
+ align-items: center;
175
+ gap: 8px;
176
+ padding: 16px;
177
+ color: var(--fgColor-danger, #cf222e);
178
+ font-family: system-ui, -apple-system, sans-serif;
179
+ font-size: 13px;
180
+ line-height: 1.5;
181
+ }
182
+
183
+ .errorIcon {
184
+ font-size: 20px;
185
+ flex-shrink: 0;
186
+ }
187
+
188
+ .errorText {
189
+ word-break: break-word;
190
+ }
191
+
192
+ .loading {
193
+ display: flex;
194
+ align-items: center;
195
+ justify-content: center;
196
+ padding: 16px;
197
+ color: var(--fgColor-muted, #656d76);
198
+ font-family: system-ui, -apple-system, sans-serif;
199
+ font-size: 13px;
200
+ }
@@ -1,6 +1,6 @@
1
1
  import { useState, useCallback, useRef, useEffect, useSyncExternalStore } from 'react'
2
2
  import { Tooltip } from '@primer/react'
3
- import { EyeIcon as OcticonEye, EyeClosedIcon as OcticonEyeClosed } from '@primer/octicons-react'
3
+ import { EyeIcon as OcticonEye, EyeClosedIcon as OcticonEyeClosed, CodeIcon as OcticonCode, UnwrapIcon as OcticonUnwrap, ImageIcon as OcticonImage } from '@primer/octicons-react'
4
4
  import styles from './WidgetChrome.module.css'
5
5
 
6
6
  const STICKY_NOTE_COLORS = {
@@ -60,6 +60,18 @@ function EyeClosedIcon() {
60
60
  return <OcticonEyeClosed size={12} />
61
61
  }
62
62
 
63
+ function CodeIcon() {
64
+ return <OcticonCode size={12} />
65
+ }
66
+
67
+ function UnwrapIcon() {
68
+ return <OcticonUnwrap size={12} />
69
+ }
70
+
71
+ function ImageIcon() {
72
+ return <OcticonImage size={12} />
73
+ }
74
+
63
75
  function CopyIcon() {
64
76
  return (
65
77
  <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
@@ -119,6 +131,9 @@ const ICON_REGISTRY = {
119
131
  'open-external': OpenExternalIcon,
120
132
  'eye': EyeIcon,
121
133
  'eye-closed': EyeClosedIcon,
134
+ 'code': CodeIcon,
135
+ 'unwrap': UnwrapIcon,
136
+ 'image': ImageIcon,
122
137
  'copy': CopyIcon,
123
138
  'link': LinkIcon,
124
139
  'more': MoreIcon,
@@ -425,7 +440,7 @@ export default function WidgetChrome({
425
440
  onMouseEnter={(readOnly && !hasFeatures) ? undefined : handleMouseEnter}
426
441
  onMouseLeave={(readOnly && !hasFeatures) ? undefined : handleMouseLeave}
427
442
  >
428
- <div className={`tc-drag-surface ${styles.widgetSlot} ${selected ? styles.widgetSlotSelected : ''} ${multiSelected ? styles.widgetSlotMultiSelected : ''}`}>
443
+ <div className={`tc-drag-surface ${styles.widgetSlot} ${selected ? styles.widgetSlotSelected : ''} ${multiSelected ? styles.widgetSlotMultiSelected : ''}`} data-widget-selected={selected || undefined}>
429
444
  {children}
430
445
  </div>
431
446
  <div
@@ -469,6 +484,11 @@ export default function WidgetChrome({
469
484
  }
470
485
  }
471
486
 
487
+ // Show-code toggle: swap label based on widget state
488
+ if (feature.action === 'show-code' && widgetRef?.current?.getState?.('showCode')) {
489
+ label = 'Show component'
490
+ }
491
+
472
492
  return (
473
493
  <Tooltip key={feature.id} text={label} direction="n">
474
494
  <button
@@ -504,7 +524,14 @@ export default function WidgetChrome({
504
524
  <WidgetOverflowMenu
505
525
  widgetId={widgetId}
506
526
  menuFeatures={menuFeatures}
507
- onAction={onAction}
527
+ onAction={(actionId) => {
528
+ // Route overflow menu actions through the widget ref first
529
+ if (actionId !== 'delete' && actionId !== 'copy' && widgetRef?.current?.handleAction) {
530
+ widgetRef.current.handleAction(actionId)
531
+ } else {
532
+ onAction?.(actionId)
533
+ }
534
+ }}
508
535
  />
509
536
  )}
510
537
  </div>