@dfosco/storyboard-react 4.0.0-beta.27 → 4.0.0-beta.29

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 (35) hide show
  1. package/package.json +3 -3
  2. package/src/canvas/CanvasPage.bridge.test.jsx +87 -2
  3. package/src/canvas/CanvasPage.jsx +152 -9
  4. package/src/canvas/CanvasPage.module.css +54 -0
  5. package/src/canvas/CanvasPage.multiselect.test.jsx +2 -0
  6. package/src/canvas/canvasApi.js +8 -0
  7. package/src/canvas/widgets/CodePenEmbed.jsx +291 -0
  8. package/src/canvas/widgets/CodePenEmbed.module.css +161 -0
  9. package/src/canvas/widgets/FigmaEmbed.jsx +42 -7
  10. package/src/canvas/widgets/FigmaEmbed.module.css +21 -0
  11. package/src/canvas/widgets/LinkPreview.jsx +247 -18
  12. package/src/canvas/widgets/LinkPreview.module.css +349 -8
  13. package/src/canvas/widgets/LinkPreview.test.jsx +71 -0
  14. package/src/canvas/widgets/MarkdownBlock.jsx +2 -1
  15. package/src/canvas/widgets/MarkdownBlock.module.css +34 -11
  16. package/src/canvas/widgets/PrototypeEmbed.jsx +101 -44
  17. package/src/canvas/widgets/PrototypeEmbed.module.css +1 -0
  18. package/src/canvas/widgets/StoryWidget.jsx +86 -42
  19. package/src/canvas/widgets/StoryWidget.module.css +1 -0
  20. package/src/canvas/widgets/WidgetChrome.jsx +20 -1
  21. package/src/canvas/widgets/codepenUrl.js +75 -0
  22. package/src/canvas/widgets/codepenUrl.test.js +76 -0
  23. package/src/canvas/widgets/embedInteraction.test.jsx +16 -18
  24. package/src/canvas/widgets/embedTheme.js +37 -1
  25. package/src/canvas/widgets/githubUrl.js +82 -0
  26. package/src/canvas/widgets/githubUrl.test.js +74 -0
  27. package/src/canvas/widgets/index.js +2 -0
  28. package/src/canvas/widgets/refreshQueue.js +108 -0
  29. package/src/canvas/widgets/snapshotDisplay.test.jsx +60 -90
  30. package/src/canvas/widgets/useSnapshotCapture.js +38 -139
  31. package/src/canvas/widgets/useSnapshotCapture.test.jsx +30 -91
  32. package/src/canvas/widgets/widgetConfig.js +1 -1
  33. package/src/canvas/widgets/widgetConfig.test.js +1 -1
  34. package/src/story/StoryPage.jsx +25 -60
  35. package/src/story/StoryPage.module.css +0 -55
@@ -0,0 +1,291 @@
1
+ /**
2
+ * CodePen embed widget for canvas.
3
+ *
4
+ * Behaves like FigmaEmbed: click-to-interact overlay, iframe kept alive
5
+ * after deselect, expand modal, open-external action. Created via paste
6
+ * when a CodePen URL is pasted onto the canvas.
7
+ */
8
+ import { forwardRef, useImperativeHandle, useMemo, useCallback, useState, useEffect, useRef } from 'react'
9
+ import { createPortal } from 'react-dom'
10
+ import WidgetWrapper from './WidgetWrapper.jsx'
11
+ import { readProp } from './widgetProps.js'
12
+ import { schemas } from './widgetConfig.js'
13
+ import { isCodePenUrl, toCodePenEmbedUrl, getCodePenTitle, fetchCodePenMeta } from './codepenUrl.js'
14
+ import { useIframeDevLogs } from './iframeDevLogs.js'
15
+ import styles from './CodePenEmbed.module.css'
16
+ import overlayStyles from './embedOverlay.module.css'
17
+
18
+ const codepenEmbedSchema = schemas['codepen-embed']
19
+
20
+ /** CodePen logo SVG */
21
+ function CodePenLogo({ className }) {
22
+ return (
23
+ <svg className={className} viewBox="0 0 100 100" fill="none" aria-hidden="true">
24
+ <path
25
+ d="M100 34.2c0-.4-.1-.8-.2-1.2 0-.1 0-.2-.1-.3 0-.2-.1-.3-.2-.5 0-.1-.1-.2-.1-.3-.1-.2-.1-.3-.2-.4-.1-.1-.1-.2-.2-.3-.1-.1-.1-.3-.2-.4l-.3-.3-.2-.3c-.1-.1-.2-.2-.3-.2l-.3-.3c-.1-.1-.2-.1-.3-.2l-.3-.3-.4-.2-.6-.3L52.1 2.5c-1.3-.8-2.9-.8-4.2 0L3.2 27.6l-.6.3c-.1.1-.2.1-.4.2l-.3.3c-.1.1-.2.1-.3.2l-.3.3-.3.3-.2.3c-.1.1-.2.3-.2.4-.1.1-.1.2-.2.3-.1.1-.1.3-.2.4 0 .1-.1.2-.1.3-.1.2-.1.3-.2.5 0 .1 0 .2-.1.3-.1.4-.1.8-.2 1.2v31.4c0 .4.1.8.2 1.2 0 .1 0 .2.1.3 0 .2.1.3.2.5 0 .1.1.2.1.3.1.2.1.3.2.4.1.1.1.2.2.3.1.1.1.3.2.4l.3.3.2.3c.1.1.2.2.3.2l.3.3c.1.1.2.1.3.2l.3.3.4.2.6.3 44.7 25.1c.6.4 1.4.5 2.1.5.7 0 1.4-.2 2.1-.5l44.7-25.1.6-.3c.1-.1.2-.1.4-.2l.3-.3c.1-.1.2-.1.3-.2l.3-.3.3-.3.2-.3c.1-.1.2-.3.2-.4.1-.1.1-.2.2-.3.1-.1.1-.3.2-.4 0-.1.1-.2.1-.3.1-.2.1-.3.2-.5 0-.1 0-.2.1-.3.1-.4.1-.8.2-1.2V34.2zm-50-24L88.5 33 73 43.4 54.8 32.2V10.2zM45.2 10.2v22l-18.2 11.2L11.5 33l38.5-22.8zm-40 28.4L18.4 49.8 5.2 60.9V38.6zm40 51.2L6.7 66.6 22.2 56.2l23 14.1v19.5zm4.8-24.3l-16-9.8 16-9.8 16 9.8-16 9.8zm4.8 24.3V70.3l23-14.1 15.5 10.4L54.8 89.8zm40-28.9L81.6 49.8 94.8 38.6v22.3z"
26
+ fill="currentColor"
27
+ />
28
+ </svg>
29
+ )
30
+ }
31
+
32
+ /** Stroke-based code icon for empty state */
33
+ function CodeIcon({ size = 32, className }) {
34
+ return (
35
+ <svg
36
+ className={className}
37
+ width={size}
38
+ height={size}
39
+ viewBox="0 0 24 24"
40
+ fill="none"
41
+ stroke="currentColor"
42
+ strokeWidth="2"
43
+ strokeLinecap="round"
44
+ strokeLinejoin="round"
45
+ aria-hidden="true"
46
+ >
47
+ <polyline points="16 18 22 12 16 6" />
48
+ <polyline points="8 6 2 12 8 18" />
49
+ </svg>
50
+ )
51
+ }
52
+
53
+ export default forwardRef(function CodePenEmbed({ props, onUpdate, resizable }, ref) {
54
+ const url = readProp(props, 'url', codepenEmbedSchema)
55
+ const width = readProp(props, 'width', codepenEmbedSchema)
56
+ const height = readProp(props, 'height', codepenEmbedSchema)
57
+
58
+ const [interactive, setInteractive] = useState(false)
59
+ const [showIframe, setShowIframe] = useState(true)
60
+ const [expanded, setExpanded] = useState(false)
61
+
62
+ const iframeRef = useRef(null)
63
+ const embedRef = useRef(null)
64
+ const inlineContainerRef = useRef(null)
65
+ const modalContainerRef = useRef(null)
66
+ const teardownTimerRef = useRef(null)
67
+ const exitSessionRef = useRef(0)
68
+
69
+ const isValid = useMemo(() => isCodePenUrl(url), [url])
70
+ const embedUrl = useMemo(() => (isValid ? toCodePenEmbedUrl(url) : ''), [url, isValid])
71
+ const fallbackTitle = useMemo(() => (url ? getCodePenTitle(url) : 'CodePen'), [url])
72
+
73
+ // Fetch pen metadata (title + author) from CodePen oEmbed API
74
+ const [penMeta, setPenMeta] = useState(null)
75
+ useEffect(() => {
76
+ if (!url || !isValid) return
77
+ let cancelled = false
78
+ fetchCodePenMeta(url).then((meta) => {
79
+ if (!cancelled && meta) setPenMeta(meta)
80
+ })
81
+ return () => { cancelled = true }
82
+ }, [url, isValid])
83
+
84
+ const headerTitle = penMeta?.title
85
+ ? `${penMeta.title} · ${penMeta.author || fallbackTitle}`
86
+ : fallbackTitle
87
+
88
+ useIframeDevLogs({
89
+ widget: 'CodePenEmbed',
90
+ loaded: showIframe && Boolean(embedUrl),
91
+ src: embedUrl,
92
+ })
93
+
94
+ const enterInteractive = useCallback(() => {
95
+ exitSessionRef.current++
96
+ clearTimeout(teardownTimerRef.current)
97
+ setShowIframe(true)
98
+ setInteractive(true)
99
+ }, [])
100
+
101
+ // Exit interactive mode on click outside — keep iframe alive for 2 min
102
+ useEffect(() => {
103
+ if (!interactive || expanded) return
104
+ function handlePointerDown(e) {
105
+ if (embedRef.current && !embedRef.current.contains(e.target)) {
106
+ setInteractive(false)
107
+ const session = ++exitSessionRef.current
108
+ clearTimeout(teardownTimerRef.current)
109
+ teardownTimerRef.current = setTimeout(() => {
110
+ if (exitSessionRef.current !== session) return
111
+ setShowIframe(false)
112
+ }, 2 * 60 * 1000)
113
+ }
114
+ }
115
+ document.addEventListener('pointerdown', handlePointerDown)
116
+ return () => document.removeEventListener('pointerdown', handlePointerDown)
117
+ }, [interactive, expanded])
118
+
119
+ useEffect(() => () => clearTimeout(teardownTimerRef.current), [])
120
+
121
+ // Close expanded modal on Escape
122
+ useEffect(() => {
123
+ if (!expanded) return
124
+ function handleKeyDown(e) {
125
+ if (e.key === 'Escape') {
126
+ e.stopPropagation()
127
+ setExpanded(false)
128
+ }
129
+ }
130
+ document.addEventListener('keydown', handleKeyDown, true)
131
+ return () => document.removeEventListener('keydown', handleKeyDown, true)
132
+ }, [expanded])
133
+
134
+ // Reparent iframe between inline and modal containers
135
+ useEffect(() => {
136
+ const iframe = iframeRef.current
137
+ if (!iframe) return
138
+
139
+ if (expanded && modalContainerRef.current) {
140
+ iframe._savedClassName = iframe.className
141
+ iframe._savedStyle = iframe.getAttribute('style') || ''
142
+ iframe.className = styles.expandIframe
143
+ iframe.removeAttribute('style')
144
+ const target = modalContainerRef.current
145
+ if (target.moveBefore) {
146
+ target.moveBefore(iframe, target.firstChild)
147
+ } else {
148
+ target.prepend(iframe)
149
+ }
150
+ } else if (!expanded && inlineContainerRef.current) {
151
+ if (iframe._savedClassName !== undefined) {
152
+ iframe.className = iframe._savedClassName
153
+ iframe.setAttribute('style', iframe._savedStyle)
154
+ delete iframe._savedClassName
155
+ delete iframe._savedStyle
156
+ }
157
+ const target = inlineContainerRef.current
158
+ if (target.moveBefore) {
159
+ target.moveBefore(iframe, null)
160
+ } else {
161
+ target.appendChild(iframe)
162
+ }
163
+ }
164
+ }, [expanded])
165
+
166
+ useImperativeHandle(ref, () => ({
167
+ handleAction(actionId) {
168
+ if (actionId === 'open-external') {
169
+ if (url) window.open(url, '_blank', 'noopener')
170
+ } else if (actionId === 'expand') {
171
+ setShowIframe(true)
172
+ setExpanded(true)
173
+ }
174
+ },
175
+ }), [url])
176
+
177
+ return (
178
+ <>
179
+ <WidgetWrapper>
180
+ <div ref={embedRef} className={styles.embed} style={{ width, height }}>
181
+ <div className={styles.header}>
182
+ <CodePenLogo className={styles.codepenLogo} />
183
+ <span className={styles.headerTitle}>{headerTitle}</span>
184
+ </div>
185
+ {embedUrl ? (
186
+ <>
187
+ {showIframe ? (
188
+ <div
189
+ ref={inlineContainerRef}
190
+ className={styles.iframeContainer}
191
+ style={expanded ? { visibility: 'hidden' } : undefined}
192
+ >
193
+ <iframe
194
+ ref={iframeRef}
195
+ src={embedUrl}
196
+ className={styles.iframe}
197
+ title={`CodePen: ${headerTitle}`}
198
+ allowFullScreen
199
+ loading="lazy"
200
+ />
201
+ </div>
202
+ ) : (
203
+ <div className={styles.iframeContainer} />
204
+ )}
205
+ {!interactive && !expanded && (
206
+ <div
207
+ className={overlayStyles.interactOverlay}
208
+ onClick={(e) => {
209
+ if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
210
+ enterInteractive()
211
+ }}
212
+ role="button"
213
+ tabIndex={0}
214
+ onKeyDown={(e) => {
215
+ if (e.key === 'Enter' || e.key === ' ') {
216
+ e.preventDefault()
217
+ e.stopPropagation()
218
+ enterInteractive()
219
+ }
220
+ }}
221
+ aria-label="Click to interact with CodePen embed"
222
+ >
223
+ <span className={overlayStyles.interactHint}>Click to interact</span>
224
+ </div>
225
+ )}
226
+ </>
227
+ ) : (
228
+ <div className={styles.emptyState}>
229
+ <CodeIcon size={32} className={styles.emptyIcon} />
230
+ <span className={styles.emptyLabel}>No CodePen URL</span>
231
+ </div>
232
+ )}
233
+ </div>
234
+ {resizable && (
235
+ <div
236
+ className={styles.resizeHandle}
237
+ onMouseDown={(e) => {
238
+ e.stopPropagation()
239
+ e.preventDefault()
240
+ const startX = e.clientX
241
+ const startY = e.clientY
242
+ const startW = width
243
+ const startH = height
244
+ function onMove(ev) {
245
+ const newW = Math.max(200, startW + ev.clientX - startX)
246
+ const newH = Math.max(150, startH + ev.clientY - startY)
247
+ onUpdate?.({ width: newW, height: newH })
248
+ }
249
+ function onUp() {
250
+ document.removeEventListener('mousemove', onMove)
251
+ document.removeEventListener('mouseup', onUp)
252
+ }
253
+ document.addEventListener('mousemove', onMove)
254
+ document.addEventListener('mouseup', onUp)
255
+ }}
256
+ onPointerDown={(e) => e.stopPropagation()}
257
+ />
258
+ )}
259
+ </WidgetWrapper>
260
+ {createPortal(
261
+ <div
262
+ className={styles.expandBackdrop}
263
+ style={expanded && embedUrl ? undefined : { display: 'none' }}
264
+ onClick={() => setExpanded(false)}
265
+ onPointerDown={(e) => e.stopPropagation()}
266
+ onKeyDown={(e) => {
267
+ e.stopPropagation()
268
+ if (e.key === 'Escape') setExpanded(false)
269
+ }}
270
+ onWheel={(e) => e.stopPropagation()}
271
+ tabIndex={-1}
272
+ ref={(el) => { if (el && expanded) el.focus() }}
273
+ >
274
+ <div
275
+ ref={modalContainerRef}
276
+ className={styles.expandContainer}
277
+ onClick={(e) => e.stopPropagation()}
278
+ >
279
+ <button
280
+ className={styles.expandClose}
281
+ onClick={() => setExpanded(false)}
282
+ aria-label="Close expanded view"
283
+ autoFocus
284
+ >✕</button>
285
+ </div>
286
+ </div>,
287
+ document.body
288
+ )}
289
+ </>
290
+ )
291
+ })
@@ -0,0 +1,161 @@
1
+ .embed {
2
+ position: relative;
3
+ overflow: hidden;
4
+ background: var(--bgColor-default, #ffffff);
5
+ border: 3px solid var(--borderColor-default, #d0d7de);
6
+ border-radius: 12px;
7
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
8
+ }
9
+
10
+ .header {
11
+ display: flex;
12
+ align-items: center;
13
+ gap: 6px;
14
+ padding: 6px 10px;
15
+ font-size: 12px;
16
+ font-weight: 500;
17
+ color: var(--fgColor-muted, #656d76);
18
+ background: var(--bgColor-muted, #f6f8fa);
19
+ border-bottom: 1px solid var(--borderColor-muted, #d8dee4);
20
+ white-space: nowrap;
21
+ overflow: hidden;
22
+ text-overflow: ellipsis;
23
+ user-select: none;
24
+ }
25
+
26
+ .codepenLogo {
27
+ width: 16px;
28
+ height: 16px;
29
+ flex-shrink: 0;
30
+ }
31
+
32
+ .headerTitle {
33
+ overflow: hidden;
34
+ text-overflow: ellipsis;
35
+ }
36
+
37
+ .iframeContainer {
38
+ width: 100%;
39
+ height: calc(100% - 10px);
40
+ overflow: hidden;
41
+ }
42
+
43
+ .iframe {
44
+ width: 100%;
45
+ height: 100%;
46
+ border: none;
47
+ display: block;
48
+ }
49
+
50
+ .resizeHandle {
51
+ position: absolute;
52
+ bottom: 0;
53
+ right: 0;
54
+ width: 16px;
55
+ height: 16px;
56
+ cursor: nwse-resize;
57
+ background: linear-gradient(
58
+ 135deg,
59
+ transparent 40%,
60
+ var(--borderColor-muted, rgba(0, 0, 0, 0.15)) 40%,
61
+ var(--borderColor-muted, rgba(0, 0, 0, 0.15)) 50%,
62
+ transparent 50%,
63
+ transparent 65%,
64
+ var(--borderColor-muted, rgba(0, 0, 0, 0.15)) 65%,
65
+ var(--borderColor-muted, rgba(0, 0, 0, 0.15)) 75%,
66
+ transparent 75%
67
+ );
68
+ opacity: 0;
69
+ transition: opacity 150ms;
70
+ z-index: 2;
71
+ }
72
+
73
+ .embed:hover ~ .resizeHandle,
74
+ .resizeHandle:hover {
75
+ opacity: 1;
76
+ }
77
+
78
+ /* Expand modal */
79
+ .expandBackdrop {
80
+ position: fixed;
81
+ inset: 0;
82
+ z-index: 100000;
83
+ background: rgba(0, 0, 0, 0.8);
84
+ display: flex;
85
+ align-items: center;
86
+ justify-content: center;
87
+ animation: expandFadeIn 0.15s ease;
88
+ }
89
+
90
+ @keyframes expandFadeIn {
91
+ from { opacity: 0; }
92
+ to { opacity: 1; }
93
+ }
94
+
95
+ .expandContainer {
96
+ width: 90vw;
97
+ height: 90vh;
98
+ position: relative;
99
+ border-radius: 12px;
100
+ overflow: hidden;
101
+ background: var(--bgColor-default, #ffffff);
102
+ box-shadow: 0 24px 64px rgba(0, 0, 0, 0.4);
103
+ animation: expandScaleIn 0.2s ease;
104
+ }
105
+
106
+ @keyframes expandScaleIn {
107
+ from { transform: scale(0.95); opacity: 0; }
108
+ to { transform: scale(1); opacity: 1; }
109
+ }
110
+
111
+ .expandIframe {
112
+ border: none;
113
+ display: block;
114
+ width: 100%;
115
+ height: 100%;
116
+ }
117
+
118
+ .expandClose {
119
+ all: unset;
120
+ cursor: pointer;
121
+ position: absolute;
122
+ top: 12px;
123
+ right: 12px;
124
+ width: 32px;
125
+ height: 32px;
126
+ display: flex;
127
+ align-items: center;
128
+ justify-content: center;
129
+ border-radius: 8px;
130
+ background: rgba(0, 0, 0, 0.5);
131
+ color: #ffffff;
132
+ font-size: 16px;
133
+ z-index: 1;
134
+ transition: background 100ms;
135
+ backdrop-filter: blur(4px);
136
+ }
137
+
138
+ .expandClose:hover {
139
+ background: rgba(0, 0, 0, 0.7);
140
+ }
141
+
142
+ .emptyState {
143
+ width: 100%;
144
+ height: calc(100% - 10px);
145
+ display: flex;
146
+ flex-direction: column;
147
+ align-items: center;
148
+ justify-content: center;
149
+ gap: 8px;
150
+ }
151
+
152
+ .emptyIcon {
153
+ color: var(--fgColor-muted, #656d76);
154
+ opacity: 0.5;
155
+ }
156
+
157
+ .emptyLabel {
158
+ color: var(--fgColor-muted, #656d76);
159
+ font-size: 13px;
160
+ font-style: italic;
161
+ }
@@ -10,6 +10,30 @@ import overlayStyles from './embedOverlay.module.css'
10
10
 
11
11
  const figmaEmbedSchema = schemas['figma-embed']
12
12
 
13
+ /** Feather-icons figma icon (monochrome, stroke-based) */
14
+ function FigmaIcon({ size = 32, className }) {
15
+ return (
16
+ <svg
17
+ className={className}
18
+ width={size}
19
+ height={size}
20
+ viewBox="0 0 24 24"
21
+ fill="none"
22
+ stroke="currentColor"
23
+ strokeWidth="2"
24
+ strokeLinecap="round"
25
+ strokeLinejoin="round"
26
+ aria-hidden="true"
27
+ >
28
+ <path d="M5 5.5A3.5 3.5 0 0 1 8.5 2H12v7H8.5A3.5 3.5 0 0 1 5 5.5z" />
29
+ <path d="M12 2h3.5a3.5 3.5 0 1 1 0 7H12V2z" />
30
+ <path d="M12 12.5a3.5 3.5 0 1 1 7 0 3.5 3.5 0 1 1-7 0z" />
31
+ <path d="M5 19.5A3.5 3.5 0 0 1 8.5 16H12v3.5a3.5 3.5 0 1 1-7 0z" />
32
+ <path d="M5 12.5A3.5 3.5 0 0 1 8.5 9H12v7H8.5A3.5 3.5 0 0 1 5 12.5z" />
33
+ </svg>
34
+ )
35
+ }
36
+
13
37
  /** Inline Figma logo SVG */
14
38
  function FigmaLogo() {
15
39
  return (
@@ -31,13 +55,15 @@ export default forwardRef(function FigmaEmbed({ props, onUpdate, resizable }, re
31
55
  const height = readProp(props, 'height', figmaEmbedSchema)
32
56
 
33
57
  const [interactive, setInteractive] = useState(false)
34
- const [showIframe, setShowIframe] = useState(false)
58
+ const [showIframe, setShowIframe] = useState(true)
35
59
  const [expanded, setExpanded] = useState(false)
36
60
 
37
61
  const iframeRef = useRef(null)
38
62
  const embedRef = useRef(null)
39
63
  const inlineContainerRef = useRef(null)
40
64
  const modalContainerRef = useRef(null)
65
+ const teardownTimerRef = useRef(null)
66
+ const exitSessionRef = useRef(0)
41
67
 
42
68
  // Validate URL at render time — only embed known Figma URLs
43
69
  const isValid = useMemo(() => isFigmaUrl(url), [url])
@@ -53,6 +79,8 @@ export default forwardRef(function FigmaEmbed({ props, onUpdate, resizable }, re
53
79
  })
54
80
 
55
81
  const enterInteractive = useCallback(() => {
82
+ exitSessionRef.current++
83
+ clearTimeout(teardownTimerRef.current)
56
84
  setShowIframe(true)
57
85
  setInteractive(true)
58
86
  }, [])
@@ -62,13 +90,21 @@ export default forwardRef(function FigmaEmbed({ props, onUpdate, resizable }, re
62
90
  function handlePointerDown(e) {
63
91
  if (embedRef.current && !embedRef.current.contains(e.target)) {
64
92
  setInteractive(false)
65
- setShowIframe(false)
93
+ // Keep iframe alive for 5 min — Figma is slow to reload
94
+ const session = ++exitSessionRef.current
95
+ clearTimeout(teardownTimerRef.current)
96
+ teardownTimerRef.current = setTimeout(() => {
97
+ if (exitSessionRef.current !== session) return
98
+ setShowIframe(false)
99
+ }, 5 * 60 * 1000)
66
100
  }
67
101
  }
68
102
  document.addEventListener('pointerdown', handlePointerDown)
69
103
  return () => document.removeEventListener('pointerdown', handlePointerDown)
70
104
  }, [interactive, expanded])
71
105
 
106
+ useEffect(() => () => clearTimeout(teardownTimerRef.current), [])
107
+
72
108
  // Close expanded modal on Escape
73
109
  useEffect(() => {
74
110
  if (!expanded) return
@@ -178,11 +214,10 @@ export default forwardRef(function FigmaEmbed({ props, onUpdate, resizable }, re
178
214
  )}
179
215
  </>
180
216
  ) : (
181
- <div className={styles.iframeContainer} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
182
- <p style={{ color: 'var(--fgColor-muted, #656d76)', fontSize: 14, fontStyle: 'italic' }}>
183
- No Figma URL
184
- </p>
185
- </div>
217
+ <div className={styles.emptyState}>
218
+ <FigmaIcon size={32} className={styles.emptyIcon} />
219
+ <span className={styles.emptyLabel}>No Figma URL</span>
220
+ </div>
186
221
  )}
187
222
  </div>
188
223
  {resizable && (
@@ -138,3 +138,24 @@
138
138
  .expandClose:hover {
139
139
  background: rgba(0, 0, 0, 0.7);
140
140
  }
141
+
142
+ .emptyState {
143
+ width: 100%;
144
+ height: calc(100% - 10px);
145
+ display: flex;
146
+ flex-direction: column;
147
+ align-items: center;
148
+ justify-content: center;
149
+ gap: 8px;
150
+ }
151
+
152
+ .emptyIcon {
153
+ color: var(--fgColor-muted, #656d76);
154
+ opacity: 0.5;
155
+ }
156
+
157
+ .emptyLabel {
158
+ color: var(--fgColor-muted, #656d76);
159
+ font-size: 13px;
160
+ font-style: italic;
161
+ }