@dfosco/storyboard-react 4.0.0-beta.3 → 4.0.0-beta.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/package.json +7 -4
  2. package/src/canvas/CanvasControls.jsx +51 -2
  3. package/src/canvas/CanvasControls.module.css +31 -0
  4. package/src/canvas/CanvasPage.bridge.test.jsx +95 -10
  5. package/src/canvas/CanvasPage.dragdrop.test.jsx +346 -0
  6. package/src/canvas/CanvasPage.jsx +790 -302
  7. package/src/canvas/CanvasPage.module.css +70 -47
  8. package/src/canvas/CanvasPage.multiselect.test.jsx +13 -11
  9. package/src/canvas/CanvasToolbar.jsx +2 -2
  10. package/src/canvas/ComponentErrorBoundary.jsx +50 -0
  11. package/src/canvas/PageSelector.jsx +102 -0
  12. package/src/canvas/PageSelector.module.css +93 -0
  13. package/src/canvas/PageSelector.test.jsx +104 -0
  14. package/src/canvas/canvasApi.js +22 -8
  15. package/src/canvas/canvasReloadGuard.js +37 -0
  16. package/src/canvas/canvasReloadGuard.test.js +27 -0
  17. package/src/canvas/componentIsolate.jsx +135 -0
  18. package/src/canvas/useCanvas.js +15 -10
  19. package/src/canvas/widgets/CodePenEmbed.jsx +292 -0
  20. package/src/canvas/widgets/CodePenEmbed.module.css +161 -0
  21. package/src/canvas/widgets/ComponentWidget.jsx +82 -9
  22. package/src/canvas/widgets/ComponentWidget.module.css +14 -6
  23. package/src/canvas/widgets/FigmaEmbed.jsx +110 -24
  24. package/src/canvas/widgets/FigmaEmbed.module.css +21 -7
  25. package/src/canvas/widgets/LinkPreview.jsx +247 -18
  26. package/src/canvas/widgets/LinkPreview.module.css +349 -8
  27. package/src/canvas/widgets/LinkPreview.test.jsx +71 -0
  28. package/src/canvas/widgets/MarkdownBlock.jsx +95 -21
  29. package/src/canvas/widgets/MarkdownBlock.module.css +133 -2
  30. package/src/canvas/widgets/MarkdownBlock.test.jsx +39 -0
  31. package/src/canvas/widgets/PrototypeEmbed.jsx +319 -70
  32. package/src/canvas/widgets/PrototypeEmbed.module.css +74 -4
  33. package/src/canvas/widgets/StickyNote.module.css +5 -0
  34. package/src/canvas/widgets/StickyNote.test.jsx +9 -9
  35. package/src/canvas/widgets/StoryWidget.jsx +512 -0
  36. package/src/canvas/widgets/StoryWidget.module.css +211 -0
  37. package/src/canvas/widgets/WidgetChrome.jsx +76 -20
  38. package/src/canvas/widgets/WidgetChrome.module.css +4 -7
  39. package/src/canvas/widgets/WidgetWrapper.module.css +2 -0
  40. package/src/canvas/widgets/codepenUrl.js +75 -0
  41. package/src/canvas/widgets/codepenUrl.test.js +76 -0
  42. package/src/canvas/widgets/embedInteraction.test.jsx +235 -0
  43. package/src/canvas/widgets/embedOverlay.module.css +35 -0
  44. package/src/canvas/widgets/embedTheme.js +56 -0
  45. package/src/canvas/widgets/githubUrl.js +82 -0
  46. package/src/canvas/widgets/githubUrl.test.js +74 -0
  47. package/src/canvas/widgets/iframeDevLogs.js +49 -0
  48. package/src/canvas/widgets/iframeDevLogs.test.jsx +81 -0
  49. package/src/canvas/widgets/index.js +4 -0
  50. package/src/canvas/widgets/pasteRules.js +295 -0
  51. package/src/canvas/widgets/pasteRules.test.js +474 -0
  52. package/src/canvas/widgets/refreshQueue.js +108 -0
  53. package/src/canvas/widgets/snapshotDisplay.test.jsx +259 -0
  54. package/src/canvas/widgets/useSnapshotCapture.js +157 -0
  55. package/src/canvas/widgets/useSnapshotCapture.test.jsx +164 -0
  56. package/src/canvas/widgets/widgetConfig.js +16 -5
  57. package/src/canvas/widgets/widgetConfig.test.js +34 -12
  58. package/src/context.jsx +141 -16
  59. package/src/hooks/useSceneData.js +4 -2
  60. package/src/story/StoryPage.jsx +117 -0
  61. package/src/story/StoryPage.module.css +18 -0
  62. package/src/vite/data-plugin.js +458 -71
  63. package/src/vite/data-plugin.test.js +405 -5
@@ -0,0 +1,512 @@
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 { useIframeDevLogs } from './iframeDevLogs.js'
20
+ import { useSnapshotCapture } from './useSnapshotCapture.js'
21
+ import { subscribeCanvasTheme } from './embedTheme.js'
22
+ import { enqueueRefresh, cancelRefresh, REVEAL_INTERVAL } from './refreshQueue.js'
23
+ import styles from './StoryWidget.module.css'
24
+ import overlayStyles from './embedOverlay.module.css'
25
+
26
+ function ComponentIcon({ size = 36 }) {
27
+ return (
28
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
29
+ <path d="M5.21173 15.1113L2.52473 12.4243C2.29041 12.1899 2.29041 11.8101 2.52473 11.5757L5.21173 8.88873C5.44605 8.65442 5.82595 8.65442 6.06026 8.88873L8.74727 11.5757C8.98158 11.8101 8.98158 12.1899 8.74727 12.4243L6.06026 15.1113C5.82595 15.3456 5.44605 15.3456 5.21173 15.1113Z" />
30
+ <path d="M11.5757 21.475L8.88874 18.788C8.65443 18.5537 8.65443 18.1738 8.88874 17.9395L11.5757 15.2525C11.8101 15.0182 12.19 15.0182 12.4243 15.2525L15.1113 17.9395C15.3456 18.1738 15.3456 18.5537 15.1113 18.788L12.4243 21.475C12.19 21.7094 11.8101 21.7094 11.5757 21.475Z" />
31
+ <path d="M11.5757 8.7475L8.88874 6.06049C8.65443 5.82618 8.65443 5.44628 8.88874 5.21197L11.5757 2.52496C11.8101 2.29065 12.19 2.29065 12.4243 2.52496L15.1113 5.21197C15.3456 5.44628 15.3456 5.82618 15.1113 6.06049L12.4243 8.7475C12.19 8.98181 11.8101 8.98181 11.5757 8.7475Z" />
32
+ <path d="M17.9396 15.1113L15.2526 12.4243C15.0183 12.1899 15.0183 11.8101 15.2526 11.5757L17.9396 8.88873C18.174 8.65442 18.5539 8.65442 18.7882 8.88873L21.4752 11.5757C21.7095 11.8101 21.7095 12.1899 21.4752 12.4243L18.7882 15.1113C18.5539 15.3456 18.174 15.3456 17.9396 15.1113Z" />
33
+ </svg>
34
+ )
35
+ }
36
+
37
+ function resolveStoryUrl(storyId, exportName) {
38
+ const story = getStoryData(storyId)
39
+ if (!story?._route) return null
40
+
41
+ const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
42
+ const route = story._route
43
+ const params = new URLSearchParams({ _sb_embed: '1', _sb_theme_target: 'prototype' })
44
+ if (exportName) params.set('export', exportName)
45
+
46
+ return `${base}${route}?${params}`
47
+ }
48
+
49
+ /** Resolve a module path with the app base URL for dynamic imports. */
50
+ function resolveModulePath(modulePath) {
51
+ if (!modulePath || !modulePath.startsWith('/')) return modulePath
52
+ const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
53
+ return base ? `${base}${modulePath}` : modulePath
54
+ }
55
+
56
+ /** Cache for the static story sources JSON fetched in prod builds. */
57
+ let _storySourcesCache = null
58
+
59
+ /**
60
+ * Fetch story source code. In dev, uses Vite's ?raw dynamic import.
61
+ * In prod, fetches from the build-time _storyboard/stories/sources.json.
62
+ */
63
+ async function fetchStorySource(modulePath) {
64
+ // Dev: use Vite's ?raw import for live source
65
+ if (import.meta.env.DEV) {
66
+ const mod = await import(/* @vite-ignore */ `${resolveModulePath(modulePath)}?raw`)
67
+ return mod.default || ''
68
+ }
69
+
70
+ // Prod: load from static JSON endpoint (same pattern as inspector.json)
71
+ if (!_storySourcesCache) {
72
+ const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
73
+ const res = await fetch(`${base}/_storyboard/stories/sources.json`)
74
+ if (!res.ok) throw new Error(`Story sources not available (${res.status})`)
75
+ _storySourcesCache = await res.json()
76
+ }
77
+
78
+ // _storyModule is like "/src/canvas/stories/foo.story.jsx" — strip leading /
79
+ const key = modulePath.startsWith('/') ? modulePath.slice(1) : modulePath
80
+ const source = _storySourcesCache[key]
81
+ if (source == null) throw new Error(`Source not found for ${key}`)
82
+ return source
83
+ }
84
+
85
+ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate, resizable }, ref) {
86
+ const storyId = props?.storyId || ''
87
+ const exportName = props?.exportName || ''
88
+ const width = props?.width
89
+ const height = props?.height
90
+ const snapshot = props?.snapshot || props?.snapshotLight || props?.snapshotDark || ''
91
+
92
+ const containerRef = useRef(null)
93
+ const iframeRef = useRef(null)
94
+ const resizeTimerRef = useRef(null)
95
+ const captureOnReadyRef = useRef(false)
96
+ const exitSessionRef = useRef(0)
97
+ const refreshMetaRef = useRef(null)
98
+ const [interactive, setInteractive] = useState(false)
99
+ const [showIframe, setShowIframe] = useState(false)
100
+ const [iframeLoaded, setIframeLoaded] = useState(false)
101
+ const [showCode, setShowCode] = useState(!!props?.showCode)
102
+ const [sourceCode, setSourceCode] = useState(null)
103
+ const [highlightedHtml, setHighlightedHtml] = useState(null)
104
+ const [sourceLoading, setSourceLoading] = useState(false)
105
+ const [storyIndexKey, setStoryIndexKey] = useState(0)
106
+ const [brokenSnaps, setBrokenSnaps] = useState({})
107
+
108
+ // Resolve canvas theme — reactive to theme changes
109
+ const [canvasTheme, setCanvasTheme] = useState('light')
110
+
111
+ useEffect(() => subscribeCanvasTheme({
112
+ anchorRef: containerRef,
113
+ onTheme: setCanvasTheme,
114
+ }), [])
115
+
116
+ // On canvas theme change, enqueue a background snapshot refresh
117
+ const canvasThemeInitRef = useRef(true)
118
+ useEffect(() => {
119
+ if (canvasThemeInitRef.current) { canvasThemeInitRef.current = false; return }
120
+ if (!onUpdate || interactive) return
121
+ const rect = containerRef.current?.getBoundingClientRect()
122
+ enqueueRefresh(widgetId, ({ revealOrder, batchStart }) => {
123
+ return new Promise((resolve) => {
124
+ refreshMetaRef.current = { revealOrder, batchStart, resolve }
125
+ captureOnReadyRef.current = true
126
+ setShowIframe(true)
127
+ setTimeout(() => { refreshMetaRef.current = null; resolve(false) }, 10000)
128
+ })
129
+ }, rect ? { x: rect.left, y: rect.top } : undefined)
130
+ }, [canvasTheme]) // eslint-disable-line react-hooks/exhaustive-deps
131
+
132
+ // Snapshot capture hook
133
+ const { iframeReady, requestCapture } = useSnapshotCapture({
134
+ iframeRef,
135
+ widgetId,
136
+ onUpdate,
137
+ showIframe,
138
+ })
139
+
140
+ // Single snapshot
141
+ const hasSnap = !!(snapshot && snapshot.includes(widgetId) && !brokenSnaps[snapshot])
142
+
143
+ // Re-resolve story URL when the story index is live-patched (new story added)
144
+ useEffect(() => {
145
+ const handler = () => setStoryIndexKey((k) => k + 1)
146
+ document.addEventListener('storyboard:story-index-changed', handler)
147
+ return () => document.removeEventListener('storyboard:story-index-changed', handler)
148
+ }, [])
149
+
150
+ const toggleShowCode = useCallback(() => {
151
+ setShowCode((v) => {
152
+ const next = !v
153
+ // Persist to canvas JSONL in dev
154
+ if (onUpdate) {
155
+ onUpdate({ showCode: next })
156
+ }
157
+ return next
158
+ })
159
+ }, [onUpdate])
160
+
161
+ const enterInteractive = useCallback(() => {
162
+ exitSessionRef.current++
163
+ cancelRefresh(widgetId)
164
+ setShowIframe(true)
165
+ setInteractive(true)
166
+ }, [widgetId])
167
+
168
+ useEffect(() => {
169
+ if (!showIframe) setIframeLoaded(false)
170
+ }, [showIframe])
171
+
172
+ // Exit interactive mode when clicking outside.
173
+ // Hides iframe immediately for a responsive feel, then captures
174
+ // snapshots in the background with the iframe hidden but still mounted.
175
+ useEffect(() => {
176
+ if (!interactive) return
177
+ function handlePointerDown(e) {
178
+ if (containerRef.current && !containerRef.current.contains(e.target)) {
179
+ const chromeEl = e.target.closest(`[data-widget-id="${widgetId}"]`)
180
+ if (chromeEl) return
181
+
182
+ setInteractive(false)
183
+ if (onUpdate && iframeLoaded && iframeRef.current?.contentWindow) {
184
+ if (iframeRef.current) iframeRef.current.style.visibility = 'hidden'
185
+ const session = ++exitSessionRef.current
186
+ setTimeout(() => {
187
+ if (exitSessionRef.current !== session) return
188
+ requestCapture({ force: true }).then((updates) => {
189
+ if (exitSessionRef.current !== session) return
190
+ const snap = updates?.snapshot
191
+ if (snap) {
192
+ const img = new Image()
193
+ const done = () => {
194
+ if (exitSessionRef.current === session) setShowIframe(false)
195
+ }
196
+ img.onload = done
197
+ img.onerror = done
198
+ img.src = snap
199
+ setTimeout(done, 2000)
200
+ } else {
201
+ setShowIframe(false)
202
+ }
203
+ })
204
+ }, 0)
205
+ } else {
206
+ setShowIframe(false)
207
+ }
208
+ }
209
+ }
210
+ document.addEventListener('pointerdown', handlePointerDown)
211
+ return () => document.removeEventListener('pointerdown', handlePointerDown)
212
+ }, [interactive, onUpdate, iframeLoaded, requestCapture])
213
+
214
+ const handleResize = useCallback((w, h) => {
215
+ onUpdate?.({ width: w, height: h })
216
+ // Recapture snapshot after resize (debounced)
217
+ clearTimeout(resizeTimerRef.current)
218
+ resizeTimerRef.current = setTimeout(() => requestCapture(), 1500)
219
+ }, [onUpdate, requestCapture])
220
+
221
+ // Capture snapshot on first iframe ready (when no existing snapshot)
222
+ useEffect(() => {
223
+ if (!iframeReady || !onUpdate) return
224
+ if (!hasSnap) {
225
+ requestCapture()
226
+ }
227
+ }, [iframeReady]) // eslint-disable-line react-hooks/exhaustive-deps
228
+
229
+ // Capture when iframe becomes ready after refresh-thumbnail requested it
230
+ useEffect(() => {
231
+ if (iframeReady && captureOnReadyRef.current) {
232
+ captureOnReadyRef.current = false
233
+ requestCapture().then((updates) => {
234
+ const meta = refreshMetaRef.current
235
+ if (meta) {
236
+ refreshMetaRef.current = null
237
+ const snap = updates?.snapshot
238
+ const reveal = () => {
239
+ if (snap) {
240
+ const img = new Image()
241
+ const done = () => setShowIframe(false)
242
+ img.onload = done
243
+ img.onerror = done
244
+ img.src = snap
245
+ setTimeout(done, 2000)
246
+ } else {
247
+ setShowIframe(false)
248
+ }
249
+ meta.resolve(!!snap)
250
+ }
251
+ // Wait for our reveal slot in the wave
252
+ const elapsed = Date.now() - meta.batchStart
253
+ const targetTime = meta.revealOrder * REVEAL_INTERVAL
254
+ const wait = Math.max(0, targetTime - elapsed)
255
+ setTimeout(reveal, wait)
256
+ }
257
+ })
258
+ }
259
+ }, [iframeReady, requestCapture])
260
+
261
+ // Cleanup resize timer on unmount
262
+ useEffect(() => () => clearTimeout(resizeTimerRef.current), [])
263
+
264
+ // Load source code when show-code is toggled on
265
+ useEffect(() => {
266
+ if (!showCode || sourceCode !== null) return
267
+ const story = getStoryData(storyId)
268
+ if (!story?._storyModule) {
269
+ setSourceCode('// Source not available')
270
+ return
271
+ }
272
+
273
+ let cancelled = false
274
+ setSourceLoading(true)
275
+
276
+ fetchStorySource(story._storyModule)
277
+ .then((code) => {
278
+ if (cancelled) return
279
+ setSourceCode(code || '// Empty file')
280
+ setSourceLoading(false)
281
+ })
282
+ .catch(() => {
283
+ if (cancelled) return
284
+ setSourceCode('// Failed to load source')
285
+ setSourceLoading(false)
286
+ })
287
+
288
+ return () => {
289
+ cancelled = true
290
+ setSourceLoading(false)
291
+ }
292
+ }, [showCode, sourceCode, storyId])
293
+
294
+ // Re-highlight when the code-box theme changes (storyboard:theme:changed event).
295
+ const [codeThemeKey, setCodeThemeKey] = useState(0)
296
+ useEffect(() => {
297
+ function onThemeChanged() {
298
+ setCodeThemeKey((k) => k + 1)
299
+ }
300
+ document.addEventListener('storyboard:theme:changed', onThemeChanged)
301
+ return () => document.removeEventListener('storyboard:theme:changed', onThemeChanged)
302
+ }, [])
303
+
304
+ // Syntax-highlight source code using the inspector highlighter.
305
+ // Uses the current code-box theme (data-sb-code-theme) set by the theme store.
306
+ useEffect(() => {
307
+ if (!sourceCode) return
308
+ let cancelled = false
309
+ createInspectorHighlighter().then((hl) => {
310
+ if (cancelled) return
311
+ const lang = storyId.endsWith('.tsx') ? 'tsx' : 'jsx'
312
+ const html = hl.codeToHtml(sourceCode, { lang })
313
+ setHighlightedHtml(html)
314
+ })
315
+ return () => { cancelled = true }
316
+ }, [sourceCode, storyId, codeThemeKey])
317
+
318
+ const copyCode = useCallback(async () => {
319
+ if (sourceCode) {
320
+ await navigator.clipboard?.writeText(sourceCode)
321
+ return
322
+ }
323
+ // Load source on demand if not already loaded
324
+ const story = getStoryData(storyId)
325
+ if (!story?._storyModule) return
326
+ try {
327
+ const code = await fetchStorySource(story._storyModule)
328
+ setSourceCode(code)
329
+ await navigator.clipboard?.writeText(code)
330
+ } catch { /* ignore */ }
331
+ }, [sourceCode, storyId])
332
+
333
+ useImperativeHandle(ref, () => ({
334
+ getState(key) {
335
+ if (key === 'showCode') return showCode
336
+ return undefined
337
+ },
338
+ handleAction(actionId) {
339
+ if (actionId === 'show-code') {
340
+ toggleShowCode()
341
+ } else if (actionId === 'copy-code') {
342
+ copyCode()
343
+ } else if (actionId === 'open-external') {
344
+ const story = getStoryData(storyId)
345
+ if (story?._route) {
346
+ const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
347
+ window.open(`${base}${story._route}`, '_blank', 'noopener')
348
+ }
349
+ } else if (actionId === 'refresh-thumbnail') {
350
+ if (iframeReady && iframeRef.current?.contentWindow) {
351
+ requestCapture()
352
+ } else {
353
+ captureOnReadyRef.current = true
354
+ setShowIframe(true)
355
+ }
356
+ }
357
+ },
358
+ }), [storyId, showCode, toggleShowCode, copyCode, iframeReady, requestCapture])
359
+
360
+ const iframeSrc = useMemo(
361
+ () => resolveStoryUrl(storyId, exportName),
362
+ [storyId, exportName, storyIndexKey],
363
+ )
364
+
365
+ useIframeDevLogs({
366
+ widget: 'StoryWidget',
367
+ loaded: showIframe && !showCode && Boolean(iframeSrc),
368
+ src: iframeSrc,
369
+ })
370
+
371
+ const displayName = exportName ? `${storyId} / ${exportName}` : storyId
372
+
373
+ // Error state — missing story or no route
374
+ if (!storyId) {
375
+ return (
376
+ <WidgetWrapper>
377
+ <div className={styles.container} ref={containerRef}>
378
+ <div className={styles.error}>
379
+ <span className={styles.errorIcon}><ComponentIcon size={20} /></span>
380
+ <span className={styles.errorText}>Missing story ID</span>
381
+ </div>
382
+ </div>
383
+ </WidgetWrapper>
384
+ )
385
+ }
386
+
387
+ if (!iframeSrc) {
388
+ return (
389
+ <WidgetWrapper>
390
+ <div className={styles.container} ref={containerRef}>
391
+ <div className={styles.error}>
392
+ <span className={styles.errorIcon}><ComponentIcon size={20} /></span>
393
+ <span className={styles.errorText}>Story &ldquo;{storyId}&rdquo; not found or has no route</span>
394
+ </div>
395
+ </div>
396
+ </WidgetWrapper>
397
+ )
398
+ }
399
+
400
+ const sizeStyle = {}
401
+ if (typeof width === 'number') sizeStyle.width = `${width}px`
402
+ if (typeof height === 'number') sizeStyle.height = `${height}px`
403
+
404
+ return (
405
+ <WidgetWrapper>
406
+ <div ref={containerRef} className={styles.container} style={sizeStyle}>
407
+ <div className={styles.header}>
408
+ <span className={styles.headerIcon}><ComponentIcon size={16} /></span>
409
+ <span className={styles.headerTitle}>{displayName}</span>
410
+ </div>
411
+ {showCode ? (
412
+ <div
413
+ className={styles.codeView}
414
+ data-canvas-allow-text-selection
415
+ onPointerDown={(e) => e.stopPropagation()}
416
+ onMouseDown={(e) => e.stopPropagation()}
417
+ onClick={(e) => e.stopPropagation()}
418
+ >
419
+ <div className={styles.codeHeader}>
420
+ <span className={styles.codeLabel}>{storyId}.story.jsx</span>
421
+ <button
422
+ className={styles.codeCloseBtn}
423
+ onClick={() => setShowCode(false)}
424
+ aria-label="Close code view"
425
+ >×</button>
426
+ </div>
427
+ {sourceLoading ? (
428
+ <div className={styles.codeLoading}>Loading…</div>
429
+ ) : highlightedHtml ? (
430
+ <div
431
+ className={styles.codeBlock}
432
+ dangerouslySetInnerHTML={{ __html: highlightedHtml }}
433
+ />
434
+ ) : (
435
+ <pre className={styles.codeBlock}>
436
+ <code>{sourceCode || ''}</code>
437
+ </pre>
438
+ )}
439
+ </div>
440
+ ) : (
441
+ <>
442
+ <div className={styles.content}>
443
+ {/* Snapshot layer — single image */}
444
+ {hasSnap && (
445
+ <img
446
+ src={snapshot}
447
+ className={styles.snapshotImage}
448
+ alt={`${displayName} snapshot`}
449
+ draggable={false}
450
+ onError={() => setBrokenSnaps(prev => ({ ...prev, [snapshot]: true }))}
451
+ />
452
+ )}
453
+
454
+ {/* Iframe layer — on top, transparent until loaded */}
455
+ {showIframe && (
456
+ <iframe
457
+ ref={iframeRef}
458
+ src={iframeSrc}
459
+ className={styles.iframe}
460
+ style={{
461
+ ...(iframeLoaded ? undefined : { opacity: 0 }),
462
+ transition: 'opacity 150ms ease',
463
+ }}
464
+ onLoad={() => setIframeLoaded(true)}
465
+ title={displayName}
466
+ />
467
+ )}
468
+
469
+ {/* Placeholder — only when no snapshot and no iframe */}
470
+ {!hasSnap && !showIframe && (
471
+ <div className={styles.placeholder}>
472
+ <ComponentIcon size={36} />
473
+ <span className={styles.placeholderLabel}>{displayName}</span>
474
+ </div>
475
+ )}
476
+ </div>
477
+
478
+ {!interactive && (
479
+ <div
480
+ className={overlayStyles.interactOverlay}
481
+ onClick={(e) => {
482
+ if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
483
+ enterInteractive()
484
+ }}
485
+ role="button"
486
+ tabIndex={0}
487
+ onKeyDown={(e) => {
488
+ if (e.key === 'Enter' || e.key === ' ') {
489
+ e.preventDefault()
490
+ e.stopPropagation()
491
+ enterInteractive()
492
+ }
493
+ }}
494
+ aria-label={hasSnap ? 'Click to interact with story component' : 'Click to open story component'}
495
+ >
496
+ <span className={overlayStyles.interactHint}>{hasSnap ? 'Click to interact' : 'Click to open'}</span>
497
+ </div>
498
+ )}
499
+ </>
500
+ )}
501
+ {resizable && (
502
+ <ResizeHandle
503
+ targetRef={containerRef}
504
+ minWidth={100}
505
+ minHeight={60}
506
+ onResize={handleResize}
507
+ />
508
+ )}
509
+ </div>
510
+ </WidgetWrapper>
511
+ )
512
+ })