@dfosco/storyboard-react 4.0.0-beta.2 → 4.0.0-beta.21

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 (44) 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.dragdrop.test.jsx +346 -0
  5. package/src/canvas/CanvasPage.jsx +512 -235
  6. package/src/canvas/CanvasPage.module.css +9 -47
  7. package/src/canvas/ComponentErrorBoundary.jsx +50 -0
  8. package/src/canvas/PageSelector.jsx +102 -0
  9. package/src/canvas/PageSelector.module.css +93 -0
  10. package/src/canvas/PageSelector.test.jsx +104 -0
  11. package/src/canvas/canvasApi.js +4 -0
  12. package/src/canvas/canvasReloadGuard.js +37 -0
  13. package/src/canvas/canvasReloadGuard.test.js +27 -0
  14. package/src/canvas/componentIsolate.jsx +135 -0
  15. package/src/canvas/useCanvas.js +6 -2
  16. package/src/canvas/widgets/ComponentWidget.jsx +67 -9
  17. package/src/canvas/widgets/ComponentWidget.module.css +9 -6
  18. package/src/canvas/widgets/FigmaEmbed.jsx +26 -4
  19. package/src/canvas/widgets/FigmaEmbed.module.css +0 -7
  20. package/src/canvas/widgets/MarkdownBlock.jsx +94 -21
  21. package/src/canvas/widgets/MarkdownBlock.module.css +110 -2
  22. package/src/canvas/widgets/MarkdownBlock.test.jsx +39 -0
  23. package/src/canvas/widgets/PrototypeEmbed.jsx +196 -40
  24. package/src/canvas/widgets/PrototypeEmbed.module.css +30 -3
  25. package/src/canvas/widgets/StickyNote.module.css +5 -0
  26. package/src/canvas/widgets/StickyNote.test.jsx +9 -9
  27. package/src/canvas/widgets/StoryWidget.jsx +471 -0
  28. package/src/canvas/widgets/StoryWidget.module.css +200 -0
  29. package/src/canvas/widgets/WidgetChrome.jsx +54 -18
  30. package/src/canvas/widgets/WidgetChrome.module.css +4 -7
  31. package/src/canvas/widgets/WidgetWrapper.module.css +2 -0
  32. package/src/canvas/widgets/embedInteraction.test.jsx +155 -0
  33. package/src/canvas/widgets/embedOverlay.module.css +35 -0
  34. package/src/canvas/widgets/index.js +2 -0
  35. package/src/canvas/widgets/pasteRules.js +295 -0
  36. package/src/canvas/widgets/pasteRules.test.js +474 -0
  37. package/src/canvas/widgets/widgetConfig.js +16 -5
  38. package/src/canvas/widgets/widgetConfig.test.js +31 -9
  39. package/src/context.jsx +138 -13
  40. package/src/hooks/useSceneData.js +4 -2
  41. package/src/story/StoryPage.jsx +152 -0
  42. package/src/story/StoryPage.module.css +73 -0
  43. package/src/vite/data-plugin.js +441 -58
  44. package/src/vite/data-plugin.test.js +405 -5
@@ -0,0 +1,471 @@
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
+ /** Cache for the static story sources JSON fetched in prod builds. */
43
+ let _storySourcesCache = null
44
+
45
+ /**
46
+ * Fetch story source code. In dev, uses Vite's ?raw dynamic import.
47
+ * In prod, fetches from the build-time _storyboard/stories/sources.json.
48
+ */
49
+ async function fetchStorySource(modulePath) {
50
+ // Dev: use Vite's ?raw import for live source
51
+ if (import.meta.env.DEV) {
52
+ const mod = await import(/* @vite-ignore */ `${resolveModulePath(modulePath)}?raw`)
53
+ return mod.default || ''
54
+ }
55
+
56
+ // Prod: load from static JSON endpoint (same pattern as inspector.json)
57
+ if (!_storySourcesCache) {
58
+ const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
59
+ const res = await fetch(`${base}/_storyboard/stories/sources.json`)
60
+ if (!res.ok) throw new Error(`Story sources not available (${res.status})`)
61
+ _storySourcesCache = await res.json()
62
+ }
63
+
64
+ // _storyModule is like "/src/canvas/stories/foo.story.jsx" — strip leading /
65
+ const key = modulePath.startsWith('/') ? modulePath.slice(1) : modulePath
66
+ const source = _storySourcesCache[key]
67
+ if (source == null) throw new Error(`Source not found for ${key}`)
68
+ return source
69
+ }
70
+
71
+ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate, resizable }, ref) {
72
+ const storyId = props?.storyId || ''
73
+ const exportName = props?.exportName || ''
74
+ const width = props?.width
75
+ const height = props?.height
76
+
77
+ // Snapshot props for lazy loading
78
+ const snapshotLight = props?.snapshotLight || null
79
+ const snapshotDark = props?.snapshotDark || null
80
+
81
+ const containerRef = useRef(null)
82
+ const iframeRef = useRef(null)
83
+ const [interactive, setInteractive] = useState(false)
84
+ const [showCode, setShowCode] = useState(!!props?.showCode)
85
+ const [sourceCode, setSourceCode] = useState(null)
86
+ const [highlightedHtml, setHighlightedHtml] = useState(null)
87
+ const [sourceLoading, setSourceLoading] = useState(false)
88
+
89
+ // Theme tracking for snapshot selection
90
+ const [canvasTheme, setCanvasTheme] = useState(() => {
91
+ if (typeof localStorage === 'undefined') return 'light'
92
+ const stored = localStorage.getItem('sb-color-scheme') || 'system'
93
+ if (stored !== 'system') return stored
94
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
95
+ })
96
+
97
+ useEffect(() => {
98
+ function onThemeChanged() {
99
+ const stored = localStorage.getItem('sb-color-scheme') || 'system'
100
+ if (stored !== 'system') { setCanvasTheme(stored); return }
101
+ setCanvasTheme(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
102
+ }
103
+ document.addEventListener('storyboard:theme:changed', onThemeChanged)
104
+ return () => document.removeEventListener('storyboard:theme:changed', onThemeChanged)
105
+ }, [])
106
+
107
+ // Lazy loading state — only use snapshots that match this widget's ID
108
+ const isDark = canvasTheme?.startsWith('dark')
109
+ const snapshotMatchesWidget = (url) => url && widgetId && url.includes(widgetId)
110
+ const validSnapshotLight = snapshotMatchesWidget(snapshotLight) ? snapshotLight : null
111
+ const validSnapshotDark = snapshotMatchesWidget(snapshotDark) ? snapshotDark : null
112
+ const currentSnapshot = isDark ? validSnapshotDark : validSnapshotLight
113
+ const hasSnapshot = !!currentSnapshot
114
+ const [preloadIframe, setPreloadIframe] = useState(!hasSnapshot)
115
+ const [iframeLoaded, setIframeLoaded] = useState(false)
116
+ const [showIframe, setShowIframe] = useState(!hasSnapshot)
117
+ const [showSpinner, setShowSpinner] = useState(false)
118
+ const capturingRef = useRef(false)
119
+
120
+ // Show spinner only after 500ms of loading
121
+ useEffect(() => {
122
+ if (showIframe && !iframeLoaded && hasSnapshot) {
123
+ const timer = setTimeout(() => setShowSpinner(true), 500)
124
+ return () => clearTimeout(timer)
125
+ }
126
+ setShowSpinner(false)
127
+ }, [showIframe, iframeLoaded, hasSnapshot])
128
+ const [storyIndexKey, setStoryIndexKey] = useState(0)
129
+
130
+ // Re-resolve story URL when the story index is live-patched (new story added)
131
+ useEffect(() => {
132
+ const handler = () => setStoryIndexKey((k) => k + 1)
133
+ document.addEventListener('storyboard:story-index-changed', handler)
134
+ return () => document.removeEventListener('storyboard:story-index-changed', handler)
135
+ }, [])
136
+
137
+ const toggleShowCode = useCallback(() => {
138
+ setShowCode((v) => {
139
+ const next = !v
140
+ // Persist to canvas JSONL in dev
141
+ if (onUpdate) {
142
+ onUpdate({ showCode: next })
143
+ }
144
+ return next
145
+ })
146
+ }, [onUpdate])
147
+
148
+ const enterInteractive = useCallback(() => setInteractive(true), [])
149
+
150
+ useEffect(() => {
151
+ if (!interactive) return
152
+ function handlePointerDown(e) {
153
+ if (containerRef.current && !containerRef.current.contains(e.target)) {
154
+ setInteractive(false)
155
+ }
156
+ }
157
+ document.addEventListener('pointerdown', handlePointerDown)
158
+ return () => document.removeEventListener('pointerdown', handlePointerDown)
159
+ }, [interactive])
160
+
161
+ // Listen for snapshot messages from the iframe
162
+ useEffect(() => {
163
+ function handleMessage(e) {
164
+ if (!iframeRef.current?.contentWindow) return
165
+ if (e.source !== iframeRef.current.contentWindow) return
166
+
167
+ if (e.data?.type === 'storyboard:embed:snapshot') {
168
+ if (e.data.error) {
169
+ console.warn('[canvas] Story snapshot capture failed:', e.data.error)
170
+ return
171
+ }
172
+ handleSnapshotResult(e.data.dataUrl)
173
+ return
174
+ }
175
+
176
+ // snapshot-ready means the iframe content has fully rendered
177
+ if (e.data?.type === 'storyboard:embed:snapshot-ready') {
178
+ setIframeLoaded(true)
179
+ if (onUpdate) requestSnapshotCapture()
180
+ }
181
+ }
182
+ window.addEventListener('message', handleMessage)
183
+ return () => window.removeEventListener('message', handleMessage)
184
+ }, [onUpdate, canvasTheme])
185
+
186
+ const requestSnapshotCapture = useCallback(() => {
187
+ if (!iframeRef.current?.contentWindow || capturingRef.current) return
188
+ capturingRef.current = true
189
+ iframeRef.current.contentWindow.postMessage({
190
+ type: 'storyboard:embed:capture',
191
+ requestId: `story-snap-${Date.now()}`,
192
+ }, '*')
193
+ }, [])
194
+
195
+ const handleSnapshotResult = useCallback(async (dataUrl) => {
196
+ if (!dataUrl || !onUpdate || !widgetId) return
197
+ capturingRef.current = false
198
+ try {
199
+ const result = await uploadImage(dataUrl, `snapshot-${widgetId}`)
200
+ if (!result?.success || !result?.filename) return
201
+ const imageUrl = `/_storyboard/canvas/images/${result.filename}`
202
+ const themeKey = isDark ? 'snapshotDark' : 'snapshotLight'
203
+ onUpdate?.({ [themeKey]: imageUrl })
204
+ } catch (err) {
205
+ console.warn('[canvas] Failed to upload story snapshot:', err)
206
+ }
207
+ }, [onUpdate, isDark, widgetId])
208
+
209
+ // Re-capture after resize
210
+ const resizeCaptureTimer = useRef(null)
211
+ const triggerResizeCapture = useCallback(() => {
212
+ if (!onUpdate) return
213
+ clearTimeout(resizeCaptureTimer.current)
214
+ resizeCaptureTimer.current = setTimeout(() => requestSnapshotCapture(), 2000)
215
+ }, [requestSnapshotCapture, onUpdate])
216
+
217
+ const handleResize = useCallback((w, h) => {
218
+ onUpdate?.({ width: w, height: h })
219
+ triggerResizeCapture()
220
+ }, [onUpdate, triggerResizeCapture])
221
+
222
+ // Re-capture for alternate theme variant when theme changes
223
+ const prevThemeRef = useRef(canvasTheme)
224
+ useEffect(() => {
225
+ if (canvasTheme !== prevThemeRef.current && onUpdate && showIframe) {
226
+ prevThemeRef.current = canvasTheme
227
+ const timer = setTimeout(() => requestSnapshotCapture(), 3000)
228
+ return () => clearTimeout(timer)
229
+ }
230
+ prevThemeRef.current = canvasTheme
231
+ }, [canvasTheme, onUpdate, showIframe, requestSnapshotCapture])
232
+
233
+ // Load source code when show-code is toggled on
234
+ useEffect(() => {
235
+ if (!showCode || sourceCode !== null) return
236
+ const story = getStoryData(storyId)
237
+ if (!story?._storyModule) {
238
+ setSourceCode('// Source not available')
239
+ return
240
+ }
241
+
242
+ let cancelled = false
243
+ setSourceLoading(true)
244
+
245
+ fetchStorySource(story._storyModule)
246
+ .then((code) => {
247
+ if (cancelled) return
248
+ setSourceCode(code || '// Empty file')
249
+ setSourceLoading(false)
250
+ })
251
+ .catch(() => {
252
+ if (cancelled) return
253
+ setSourceCode('// Failed to load source')
254
+ setSourceLoading(false)
255
+ })
256
+
257
+ return () => {
258
+ cancelled = true
259
+ setSourceLoading(false)
260
+ }
261
+ }, [showCode, sourceCode, storyId])
262
+
263
+ // Re-highlight when the code-box theme changes (storyboard:theme:changed event).
264
+ const [codeThemeKey, setCodeThemeKey] = useState(0)
265
+ useEffect(() => {
266
+ function onThemeChanged() {
267
+ setCodeThemeKey((k) => k + 1)
268
+ }
269
+ document.addEventListener('storyboard:theme:changed', onThemeChanged)
270
+ return () => document.removeEventListener('storyboard:theme:changed', onThemeChanged)
271
+ }, [])
272
+
273
+ // Syntax-highlight source code using the inspector highlighter.
274
+ // Uses the current code-box theme (data-sb-code-theme) set by the theme store.
275
+ useEffect(() => {
276
+ if (!sourceCode) return
277
+ let cancelled = false
278
+ createInspectorHighlighter().then((hl) => {
279
+ if (cancelled) return
280
+ const lang = storyId.endsWith('.tsx') ? 'tsx' : 'jsx'
281
+ const html = hl.codeToHtml(sourceCode, { lang })
282
+ setHighlightedHtml(html)
283
+ })
284
+ return () => { cancelled = true }
285
+ }, [sourceCode, storyId, codeThemeKey])
286
+
287
+ const copyCode = useCallback(async () => {
288
+ if (sourceCode) {
289
+ await navigator.clipboard?.writeText(sourceCode)
290
+ return
291
+ }
292
+ // Load source on demand if not already loaded
293
+ const story = getStoryData(storyId)
294
+ if (!story?._storyModule) return
295
+ try {
296
+ const code = await fetchStorySource(story._storyModule)
297
+ setSourceCode(code)
298
+ await navigator.clipboard?.writeText(code)
299
+ } catch { /* ignore */ }
300
+ }, [sourceCode, storyId])
301
+
302
+ useImperativeHandle(ref, () => ({
303
+ getState(key) {
304
+ if (key === 'showCode') return showCode
305
+ return undefined
306
+ },
307
+ handleAction(actionId) {
308
+ if (actionId === 'show-code') {
309
+ toggleShowCode()
310
+ } else if (actionId === 'copy-code') {
311
+ copyCode()
312
+ } else if (actionId === 'open-external') {
313
+ const story = getStoryData(storyId)
314
+ if (story?._route) {
315
+ const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
316
+ window.open(`${base}${story._route}`, '_blank', 'noopener')
317
+ }
318
+ }
319
+ },
320
+ }), [storyId, showCode, toggleShowCode, copyCode])
321
+
322
+ const iframeSrc = useMemo(
323
+ () => resolveStoryUrl(storyId, exportName),
324
+ [storyId, exportName, storyIndexKey],
325
+ )
326
+
327
+ const displayName = exportName ? `${storyId} / ${exportName}` : storyId
328
+
329
+ // Error state — missing story or no route
330
+ if (!storyId) {
331
+ return (
332
+ <WidgetWrapper>
333
+ <div className={styles.container} ref={containerRef}>
334
+ <div className={styles.error}>
335
+ <span className={styles.errorIcon}>📖</span>
336
+ <span className={styles.errorText}>Missing story ID</span>
337
+ </div>
338
+ </div>
339
+ </WidgetWrapper>
340
+ )
341
+ }
342
+
343
+ if (!iframeSrc) {
344
+ return (
345
+ <WidgetWrapper>
346
+ <div className={styles.container} ref={containerRef}>
347
+ <div className={styles.error}>
348
+ <span className={styles.errorIcon}>📖</span>
349
+ <span className={styles.errorText}>Story &ldquo;{storyId}&rdquo; not found or has no route</span>
350
+ </div>
351
+ </div>
352
+ </WidgetWrapper>
353
+ )
354
+ }
355
+
356
+ const sizeStyle = {}
357
+ if (typeof width === 'number') sizeStyle.width = `${width}px`
358
+ if (typeof height === 'number') sizeStyle.height = `${height}px`
359
+
360
+ return (
361
+ <WidgetWrapper>
362
+ <div ref={containerRef} className={styles.container} style={sizeStyle}>
363
+ <div className={styles.header}>
364
+ <span className={styles.headerIcon}>📖</span>
365
+ <span className={styles.headerTitle}>{displayName}</span>
366
+ </div>
367
+ {showCode ? (
368
+ <div
369
+ className={styles.codeView}
370
+ data-canvas-allow-text-selection
371
+ onPointerDown={(e) => e.stopPropagation()}
372
+ onMouseDown={(e) => e.stopPropagation()}
373
+ onClick={(e) => e.stopPropagation()}
374
+ >
375
+ <div className={styles.codeHeader}>
376
+ <span className={styles.codeLabel}>{storyId}.story.jsx</span>
377
+ <button
378
+ className={styles.codeCloseBtn}
379
+ onClick={() => setShowCode(false)}
380
+ aria-label="Close code view"
381
+ >×</button>
382
+ </div>
383
+ {sourceLoading ? (
384
+ <div className={styles.codeLoading}>Loading…</div>
385
+ ) : highlightedHtml ? (
386
+ <div
387
+ className={styles.codeBlock}
388
+ dangerouslySetInnerHTML={{ __html: highlightedHtml }}
389
+ />
390
+ ) : (
391
+ <pre className={styles.codeBlock}>
392
+ <code>{sourceCode || ''}</code>
393
+ </pre>
394
+ )}
395
+ </div>
396
+ ) : (
397
+ <>
398
+ {/* Snapshot image — shown until iframe is fully loaded */}
399
+ {hasSnapshot && !(showIframe && iframeLoaded) && (
400
+ <div className={styles.content}>
401
+ <img
402
+ src={(import.meta.env.BASE_URL || '/').replace(/\/$/, '') + currentSnapshot}
403
+ alt={displayName}
404
+ className={styles.snapshotImage}
405
+ draggable={false}
406
+ />
407
+ {showIframe && !iframeLoaded && showSpinner && (
408
+ <div className={styles.snapshotSpinner}>
409
+ <div className={styles.spinner} />
410
+ </div>
411
+ )}
412
+ </div>
413
+ )}
414
+
415
+ {/* Iframe — preloaded on hover, revealed after load */}
416
+ {(preloadIframe || showIframe) && (
417
+ <div
418
+ className={styles.content}
419
+ style={hasSnapshot && !(showIframe && iframeLoaded) ? { position: 'absolute', top: 31, left: 0, right: 0, bottom: 0, opacity: 0, pointerEvents: 'none' } : undefined}
420
+ >
421
+ <iframe
422
+ ref={iframeRef}
423
+ src={iframeSrc}
424
+ className={styles.iframe}
425
+ title={displayName}
426
+ />
427
+ </div>
428
+ )}
429
+
430
+ {!interactive && (
431
+ <div
432
+ className={overlayStyles.interactOverlay}
433
+ onPointerEnter={() => {
434
+ if (!preloadIframe) setPreloadIframe(true)
435
+ }}
436
+ onClick={(e) => {
437
+ if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
438
+ setShowIframe(true)
439
+ setPreloadIframe(true)
440
+ enterInteractive()
441
+ }}
442
+ role="button"
443
+ tabIndex={0}
444
+ onKeyDown={(e) => {
445
+ if (e.key === 'Enter' || e.key === ' ') {
446
+ e.preventDefault()
447
+ e.stopPropagation()
448
+ setShowIframe(true)
449
+ setPreloadIframe(true)
450
+ enterInteractive()
451
+ }
452
+ }}
453
+ aria-label="Click to interact with story component"
454
+ >
455
+ <span className={overlayStyles.interactHint}>Click to interact</span>
456
+ </div>
457
+ )}
458
+ </>
459
+ )}
460
+ {resizable && (
461
+ <ResizeHandle
462
+ targetRef={containerRef}
463
+ minWidth={100}
464
+ minHeight={60}
465
+ onResize={handleResize}
466
+ />
467
+ )}
468
+ </div>
469
+ </WidgetWrapper>
470
+ )
471
+ })
@@ -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
+ }