@dfosco/storyboard-react 3.10.0 → 3.11.0-beta.1

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,121 @@
1
+ import { describe, it, expect } from 'vitest'
2
+
3
+ // computeCanvasBounds is not exported, so we replicate it here for unit testing.
4
+ // This keeps the test decoupled from CanvasPage internals while validating the algorithm.
5
+
6
+ const WIDGET_FALLBACK_SIZES = {
7
+ 'sticky-note': { width: 180, height: 60 },
8
+ 'markdown': { width: 360, height: 200 },
9
+ 'prototype': { width: 800, height: 600 },
10
+ 'link-preview': { width: 320, height: 120 },
11
+ 'figma-embed': { width: 800, height: 450 },
12
+ 'component': { width: 200, height: 150 },
13
+ 'image': { width: 400, height: 300 },
14
+ }
15
+
16
+ function computeCanvasBounds(widgets, sources, jsxExports) {
17
+ let minX = Infinity
18
+ let minY = Infinity
19
+ let maxX = -Infinity
20
+ let maxY = -Infinity
21
+ let hasItems = false
22
+
23
+ for (const w of (widgets ?? [])) {
24
+ const x = w?.position?.x ?? 0
25
+ const y = w?.position?.y ?? 0
26
+ const fallback = WIDGET_FALLBACK_SIZES[w.type] || { width: 200, height: 150 }
27
+ const width = w.props?.width ?? fallback.width
28
+ const height = w.props?.height ?? fallback.height
29
+ minX = Math.min(minX, x)
30
+ minY = Math.min(minY, y)
31
+ maxX = Math.max(maxX, x + width)
32
+ maxY = Math.max(maxY, y + height)
33
+ hasItems = true
34
+ }
35
+
36
+ const sourceMap = Object.fromEntries(
37
+ (sources || []).filter((s) => s?.export).map((s) => [s.export, s])
38
+ )
39
+ if (jsxExports) {
40
+ for (const exportName of Object.keys(jsxExports)) {
41
+ const sourceData = sourceMap[exportName] || {}
42
+ const x = sourceData.position?.x ?? 0
43
+ const y = sourceData.position?.y ?? 0
44
+ const fallback = WIDGET_FALLBACK_SIZES['component']
45
+ const width = sourceData.width ?? fallback.width
46
+ const height = sourceData.height ?? fallback.height
47
+ minX = Math.min(minX, x)
48
+ minY = Math.min(minY, y)
49
+ maxX = Math.max(maxX, x + width)
50
+ maxY = Math.max(maxY, y + height)
51
+ hasItems = true
52
+ }
53
+ }
54
+
55
+ return hasItems ? { minX, minY, maxX, maxY } : null
56
+ }
57
+
58
+ describe('computeCanvasBounds', () => {
59
+ it('returns null for empty canvas', () => {
60
+ expect(computeCanvasBounds([], [], null)).toBeNull()
61
+ expect(computeCanvasBounds(null, null, null)).toBeNull()
62
+ })
63
+
64
+ it('computes bounds for a single widget using props dimensions', () => {
65
+ const widgets = [
66
+ { type: 'sticky-note', position: { x: 100, y: 200 }, props: { width: 300, height: 100 } },
67
+ ]
68
+ const bounds = computeCanvasBounds(widgets, [], null)
69
+ expect(bounds).toEqual({ minX: 100, minY: 200, maxX: 400, maxY: 300 })
70
+ })
71
+
72
+ it('uses fallback dimensions when props are missing', () => {
73
+ const widgets = [
74
+ { type: 'sticky-note', position: { x: 50, y: 50 }, props: {} },
75
+ ]
76
+ const bounds = computeCanvasBounds(widgets, [], null)
77
+ expect(bounds).toEqual({ minX: 50, minY: 50, maxX: 230, maxY: 110 }) // 50+180, 50+60
78
+ })
79
+
80
+ it('computes bounds spanning multiple widgets', () => {
81
+ const widgets = [
82
+ { type: 'sticky-note', position: { x: 0, y: 0 }, props: { width: 180, height: 60 } },
83
+ { type: 'markdown', position: { x: 500, y: 300 }, props: { width: 360, height: 200 } },
84
+ ]
85
+ const bounds = computeCanvasBounds(widgets, [], null)
86
+ expect(bounds).toEqual({ minX: 0, minY: 0, maxX: 860, maxY: 500 })
87
+ })
88
+
89
+ it('includes JSX sources in bounds', () => {
90
+ const sources = [{ export: 'Hero', position: { x: -100, y: -50 }, width: 400, height: 300 }]
91
+ const jsxExports = { Hero: () => null }
92
+ const bounds = computeCanvasBounds([], sources, jsxExports)
93
+ expect(bounds).toEqual({ minX: -100, minY: -50, maxX: 300, maxY: 250 })
94
+ })
95
+
96
+ it('combines widgets and JSX sources', () => {
97
+ const widgets = [
98
+ { type: 'sticky-note', position: { x: 200, y: 200 }, props: { width: 180, height: 60 } },
99
+ ]
100
+ const sources = [{ export: 'Nav', position: { x: 0, y: 0 }, width: 100, height: 100 }]
101
+ const jsxExports = { Nav: () => null }
102
+ const bounds = computeCanvasBounds(widgets, sources, jsxExports)
103
+ expect(bounds).toEqual({ minX: 0, minY: 0, maxX: 380, maxY: 260 })
104
+ })
105
+
106
+ it('handles widgets with missing position (defaults to 0,0)', () => {
107
+ const widgets = [
108
+ { type: 'sticky-note', props: { width: 180, height: 60 } },
109
+ ]
110
+ const bounds = computeCanvasBounds(widgets, [], null)
111
+ expect(bounds).toEqual({ minX: 0, minY: 0, maxX: 180, maxY: 60 })
112
+ })
113
+
114
+ it('uses component fallback for JSX sources without explicit size', () => {
115
+ const sources = [{ export: 'Card', position: { x: 10, y: 10 } }]
116
+ const jsxExports = { Card: () => null }
117
+ const bounds = computeCanvasBounds([], sources, jsxExports)
118
+ // component fallback: 200x150
119
+ expect(bounds).toEqual({ minX: 10, minY: 10, maxX: 210, maxY: 160 })
120
+ })
121
+ })
@@ -0,0 +1,86 @@
1
+ import { useRef, useState, useCallback } from 'react'
2
+
3
+ const MAX_HISTORY = 100
4
+ const COALESCE_MS = 2000
5
+
6
+ /**
7
+ * Snapshot-based undo/redo history for canvas widgets.
8
+ *
9
+ * Tracks past/future stacks of widget array clones. The present state
10
+ * is always the live `localWidgets` — this hook only manages history.
11
+ *
12
+ * Edit coalescing: continuous edits to the same widget within COALESCE_MS
13
+ * are merged into one undo step (like Figma).
14
+ */
15
+ export default function useUndoRedo() {
16
+ const pastRef = useRef([])
17
+ const futureRef = useRef([])
18
+ const lastActionRef = useRef({ type: null, widgetId: null, time: 0 })
19
+ // State counter drives canUndo/canRedo re-renders without cloning the stacks
20
+ const [counts, setCounts] = useState({ past: 0, future: 0 })
21
+
22
+ const snapshot = useCallback((currentWidgets, actionType, widgetId) => {
23
+ const widgets = currentWidgets ?? []
24
+
25
+ // Edit coalescing — skip snapshot if same edit target within timeout
26
+ if (actionType === 'edit' && widgetId) {
27
+ const last = lastActionRef.current
28
+ const now = Date.now()
29
+ if (
30
+ last.type === 'edit' &&
31
+ last.widgetId === widgetId &&
32
+ now - last.time < COALESCE_MS
33
+ ) {
34
+ lastActionRef.current = { type: 'edit', widgetId, time: now }
35
+ return
36
+ }
37
+ }
38
+
39
+ pastRef.current.push(structuredClone(widgets))
40
+ if (pastRef.current.length > MAX_HISTORY) pastRef.current.shift()
41
+ futureRef.current = []
42
+ lastActionRef.current = {
43
+ type: actionType,
44
+ widgetId: widgetId || null,
45
+ time: Date.now(),
46
+ }
47
+ setCounts({ past: pastRef.current.length, future: 0 })
48
+ }, [])
49
+
50
+ const undo = useCallback((currentWidgets) => {
51
+ if (pastRef.current.length === 0) return null
52
+ futureRef.current.push(structuredClone(currentWidgets))
53
+ const previous = pastRef.current.pop()
54
+ lastActionRef.current = { type: 'undo', widgetId: null, time: Date.now() }
55
+ setCounts({ past: pastRef.current.length, future: futureRef.current.length })
56
+ return previous
57
+ }, [])
58
+
59
+ const redo = useCallback((currentWidgets) => {
60
+ if (futureRef.current.length === 0) return null
61
+ pastRef.current.push(structuredClone(currentWidgets))
62
+ const next = futureRef.current.pop()
63
+ lastActionRef.current = { type: 'redo', widgetId: null, time: Date.now() }
64
+ setCounts({ past: pastRef.current.length, future: futureRef.current.length })
65
+ return next
66
+ }, [])
67
+
68
+ const reset = useCallback(() => {
69
+ pastRef.current = []
70
+ futureRef.current = []
71
+ lastActionRef.current = { type: null, widgetId: null, time: 0 }
72
+ setCounts((prev) => {
73
+ if (prev.past === 0 && prev.future === 0) return prev
74
+ return { past: 0, future: 0 }
75
+ })
76
+ }, [])
77
+
78
+ return {
79
+ snapshot,
80
+ undo,
81
+ redo,
82
+ reset,
83
+ canUndo: counts.past > 0,
84
+ canRedo: counts.future > 0,
85
+ }
86
+ }
@@ -0,0 +1,231 @@
1
+ import { renderHook, act } from '@testing-library/react'
2
+ import useUndoRedo from './useUndoRedo.js'
3
+
4
+ describe('useUndoRedo', () => {
5
+ it('starts with canUndo and canRedo as false', () => {
6
+ const { result } = renderHook(() => useUndoRedo())
7
+ expect(result.current.canUndo).toBe(false)
8
+ expect(result.current.canRedo).toBe(false)
9
+ })
10
+
11
+ it('can undo after a snapshot', () => {
12
+ const { result } = renderHook(() => useUndoRedo())
13
+ const widgets = [{ id: '1', type: 'sticky-note', props: { text: 'a' }, position: { x: 0, y: 0 } }]
14
+
15
+ act(() => result.current.snapshot(widgets, 'add'))
16
+
17
+ expect(result.current.canUndo).toBe(true)
18
+ expect(result.current.canRedo).toBe(false)
19
+ })
20
+
21
+ it('undo returns the previous state and enables redo', () => {
22
+ const { result } = renderHook(() => useUndoRedo())
23
+ const before = [{ id: '1', props: { text: 'a' } }]
24
+ const after = [{ id: '1', props: { text: 'b' } }]
25
+
26
+ act(() => result.current.snapshot(before, 'edit', '1'))
27
+
28
+ let restored
29
+ act(() => { restored = result.current.undo(after) })
30
+
31
+ expect(restored).toEqual(before)
32
+ expect(result.current.canUndo).toBe(false)
33
+ expect(result.current.canRedo).toBe(true)
34
+ })
35
+
36
+ it('redo returns the next state', () => {
37
+ const { result } = renderHook(() => useUndoRedo())
38
+ const before = [{ id: '1', props: { text: 'a' } }]
39
+ const after = [{ id: '1', props: { text: 'b' } }]
40
+
41
+ act(() => result.current.snapshot(before, 'edit', '1'))
42
+ act(() => { result.current.undo(after) })
43
+
44
+ let redone
45
+ act(() => { redone = result.current.redo(before) })
46
+
47
+ expect(redone).toEqual(after)
48
+ expect(result.current.canUndo).toBe(true)
49
+ expect(result.current.canRedo).toBe(false)
50
+ })
51
+
52
+ it('new mutation after undo clears the redo chain', () => {
53
+ const { result } = renderHook(() => useUndoRedo())
54
+ const s0 = [{ id: '1' }]
55
+ const s1 = [{ id: '1' }, { id: '2' }]
56
+ const s2 = [{ id: '1' }, { id: '3' }]
57
+
58
+ act(() => result.current.snapshot(s0, 'add'))
59
+ act(() => result.current.undo(s1))
60
+ expect(result.current.canRedo).toBe(true)
61
+
62
+ // New mutation breaks redo
63
+ act(() => result.current.snapshot(s0, 'add'))
64
+ expect(result.current.canRedo).toBe(false)
65
+ expect(result.current.canUndo).toBe(true)
66
+ })
67
+
68
+ it('supports multi-step undo-redo-undo chains', () => {
69
+ const { result } = renderHook(() => useUndoRedo())
70
+ const s0 = [{ id: '1' }]
71
+ const s1 = [{ id: '1' }, { id: '2' }]
72
+ const s2 = [{ id: '1' }, { id: '2' }, { id: '3' }]
73
+ const s3 = [{ id: '1' }, { id: '2' }, { id: '3' }, { id: '4' }]
74
+
75
+ // Build history: s0 → s1 → s2 → s3
76
+ act(() => result.current.snapshot(s0, 'add'))
77
+ act(() => result.current.snapshot(s1, 'add'))
78
+ act(() => result.current.snapshot(s2, 'add'))
79
+
80
+ // present = s3, past = [s0, s1, s2]
81
+ // Undo to s2
82
+ let r
83
+ act(() => { r = result.current.undo(s3) })
84
+ expect(r).toEqual(s2)
85
+
86
+ // Undo to s1
87
+ act(() => { r = result.current.undo(s2) })
88
+ expect(r).toEqual(s1)
89
+
90
+ // Redo to s2
91
+ act(() => { r = result.current.redo(s1) })
92
+ expect(r).toEqual(s2)
93
+
94
+ // Redo to s3
95
+ act(() => { r = result.current.redo(s2) })
96
+ expect(r).toEqual(s3)
97
+
98
+ // Undo to s2 again
99
+ act(() => { r = result.current.undo(s3) })
100
+ expect(r).toEqual(s2)
101
+
102
+ // Undo to s1
103
+ act(() => { r = result.current.undo(s2) })
104
+ expect(r).toEqual(s1)
105
+
106
+ // Undo to s0
107
+ act(() => { r = result.current.undo(s1) })
108
+ expect(r).toEqual(s0)
109
+
110
+ // Can't undo further
111
+ expect(result.current.canUndo).toBe(false)
112
+ act(() => { r = result.current.undo(s0) })
113
+ expect(r).toBeNull()
114
+ })
115
+
116
+ it('coalesces edits to the same widget within timeout', () => {
117
+ const { result } = renderHook(() => useUndoRedo())
118
+ const s0 = [{ id: '1', props: { text: '' } }]
119
+
120
+ // First edit — creates snapshot
121
+ act(() => result.current.snapshot(s0, 'edit', '1'))
122
+ expect(result.current.canUndo).toBe(true)
123
+
124
+ // Second edit to same widget within 2s — coalesced, no new snapshot
125
+ act(() => result.current.snapshot(s0, 'edit', '1'))
126
+ // Still only one entry in past
127
+ let r
128
+ act(() => { r = result.current.undo([{ id: '1', props: { text: 'abc' } }]) })
129
+ expect(r).toEqual(s0)
130
+ // No more undo after that
131
+ expect(result.current.canUndo).toBe(false)
132
+ })
133
+
134
+ it('does NOT coalesce edits to different widgets', () => {
135
+ const { result } = renderHook(() => useUndoRedo())
136
+ const s0 = [{ id: '1' }, { id: '2' }]
137
+ const s1 = [{ id: '1', props: { text: 'a' } }, { id: '2' }]
138
+
139
+ act(() => result.current.snapshot(s0, 'edit', '1'))
140
+ act(() => result.current.snapshot(s1, 'edit', '2'))
141
+
142
+ // Two entries in past
143
+ expect(result.current.canUndo).toBe(true)
144
+ act(() => { result.current.undo([]) })
145
+ expect(result.current.canUndo).toBe(true)
146
+ act(() => { result.current.undo([]) })
147
+ expect(result.current.canUndo).toBe(false)
148
+ })
149
+
150
+ it('does NOT coalesce edit after a different action type', () => {
151
+ const { result } = renderHook(() => useUndoRedo())
152
+ const s0 = [{ id: '1' }]
153
+ const s1 = [{ id: '1' }, { id: '2' }]
154
+
155
+ act(() => result.current.snapshot(s0, 'edit', '1'))
156
+ act(() => result.current.snapshot(s1, 'add'))
157
+ act(() => result.current.snapshot(s1, 'edit', '1'))
158
+
159
+ // Three entries in past (edit + add + edit — not coalesced because add broke the sequence)
160
+ act(() => { result.current.undo([]) })
161
+ act(() => { result.current.undo([]) })
162
+ act(() => { result.current.undo([]) })
163
+ expect(result.current.canUndo).toBe(false)
164
+ })
165
+
166
+ it('reset clears all history', () => {
167
+ const { result } = renderHook(() => useUndoRedo())
168
+ act(() => result.current.snapshot([{ id: '1' }], 'add'))
169
+ act(() => result.current.snapshot([{ id: '1' }, { id: '2' }], 'add'))
170
+ expect(result.current.canUndo).toBe(true)
171
+
172
+ act(() => result.current.reset())
173
+ expect(result.current.canUndo).toBe(false)
174
+ expect(result.current.canRedo).toBe(false)
175
+ })
176
+
177
+ it('undo returns null when history is empty', () => {
178
+ const { result } = renderHook(() => useUndoRedo())
179
+ let r
180
+ act(() => { r = result.current.undo([]) })
181
+ expect(r).toBeNull()
182
+ })
183
+
184
+ it('redo returns null when future is empty', () => {
185
+ const { result } = renderHook(() => useUndoRedo())
186
+ let r
187
+ act(() => { r = result.current.redo([]) })
188
+ expect(r).toBeNull()
189
+ })
190
+
191
+ it('snapshots are deep clones (mutations do not leak)', () => {
192
+ const { result } = renderHook(() => useUndoRedo())
193
+ const widgets = [{ id: '1', props: { text: 'original' } }]
194
+
195
+ act(() => result.current.snapshot(widgets, 'add'))
196
+
197
+ // Mutate the original array
198
+ widgets[0].props.text = 'mutated'
199
+
200
+ let restored
201
+ act(() => { restored = result.current.undo(widgets) })
202
+ expect(restored[0].props.text).toBe('original')
203
+ })
204
+
205
+ it('caps history at 100 entries', () => {
206
+ const { result } = renderHook(() => useUndoRedo())
207
+
208
+ for (let i = 0; i < 110; i++) {
209
+ act(() => result.current.snapshot([{ id: String(i) }], 'add'))
210
+ }
211
+
212
+ // Should be capped at 100 — undo 100 times, then can't undo further
213
+ let count = 0
214
+ let r = true
215
+ while (r !== null) {
216
+ act(() => { r = result.current.undo([]) })
217
+ if (r !== null) count++
218
+ }
219
+ expect(count).toBe(100)
220
+ })
221
+
222
+ it('snapshots null widgets as empty array (first widget on new canvas)', () => {
223
+ const { result } = renderHook(() => useUndoRedo())
224
+ act(() => result.current.snapshot(null, 'add'))
225
+ expect(result.current.canUndo).toBe(true)
226
+
227
+ let restored
228
+ act(() => { restored = result.current.undo([{ id: '1' }]) })
229
+ expect(restored).toEqual([])
230
+ })
231
+ })
@@ -0,0 +1,106 @@
1
+ import { forwardRef, useImperativeHandle, useMemo, useCallback, useState } from 'react'
2
+ import WidgetWrapper from './WidgetWrapper.jsx'
3
+ import { readProp } from './widgetProps.js'
4
+ import { schemas } from './widgetConfig.js'
5
+ import { toFigmaEmbedUrl, getFigmaTitle, getFigmaType, isFigmaUrl } from './figmaUrl.js'
6
+ import styles from './FigmaEmbed.module.css'
7
+
8
+ const figmaEmbedSchema = schemas['figma-embed']
9
+
10
+ /** Inline Figma logo SVG */
11
+ function FigmaLogo() {
12
+ return (
13
+ <svg className={styles.figmaLogo} viewBox="0 0 38 57" fill="none" aria-hidden="true">
14
+ <path d="M19 28.5a9.5 9.5 0 1 1 19 0 9.5 9.5 0 0 1-19 0z" fill="#1ABCFE" />
15
+ <path d="M0 47.5A9.5 9.5 0 0 1 9.5 38H19v9.5a9.5 9.5 0 1 1-19 0z" fill="#0ACF83" />
16
+ <path d="M19 0v19h9.5a9.5 9.5 0 1 0 0-19H19z" fill="#FF7262" />
17
+ <path d="M0 9.5A9.5 9.5 0 0 0 9.5 19H19V0H9.5A9.5 9.5 0 0 0 0 9.5z" fill="#F24E1E" />
18
+ <path d="M0 28.5A9.5 9.5 0 0 0 9.5 38H19V19H9.5A9.5 9.5 0 0 0 0 28.5z" fill="#A259FF" />
19
+ </svg>
20
+ )
21
+ }
22
+
23
+ const TYPE_LABELS = { board: 'Board', design: 'Design', proto: 'Prototype' }
24
+
25
+ export default forwardRef(function FigmaEmbed({ props, onUpdate }, ref) {
26
+ const url = readProp(props, 'url', figmaEmbedSchema)
27
+ const width = readProp(props, 'width', figmaEmbedSchema)
28
+ const height = readProp(props, 'height', figmaEmbedSchema)
29
+
30
+ const [interactive, setInteractive] = useState(false)
31
+
32
+ // Validate URL at render time — only embed known Figma URLs
33
+ const isValid = useMemo(() => isFigmaUrl(url), [url])
34
+ const embedUrl = useMemo(() => (isValid ? toFigmaEmbedUrl(url) : ''), [url, isValid])
35
+ const title = useMemo(() => (url ? getFigmaTitle(url) : 'Figma'), [url])
36
+ const figmaType = useMemo(() => getFigmaType(url), [url])
37
+ const typeLabel = figmaType ? TYPE_LABELS[figmaType] : 'Figma'
38
+
39
+ const enterInteractive = useCallback(() => setInteractive(true), [])
40
+
41
+ useImperativeHandle(ref, () => ({
42
+ handleAction(actionId) {
43
+ if (actionId === 'open-external') {
44
+ if (url) window.open(url, '_blank', 'noopener')
45
+ }
46
+ },
47
+ }), [url])
48
+
49
+ return (
50
+ <WidgetWrapper>
51
+ <div className={styles.embed} style={{ width, height }}>
52
+ <div className={styles.header}>
53
+ <FigmaLogo />
54
+ <span className={styles.headerTitle}>{title}</span>
55
+ </div>
56
+ {embedUrl ? (
57
+ <>
58
+ <div className={styles.iframeContainer}>
59
+ <iframe
60
+ src={embedUrl}
61
+ className={styles.iframe}
62
+ title={`Figma ${typeLabel}: ${title}`}
63
+ allowFullScreen
64
+ />
65
+ </div>
66
+ {!interactive && (
67
+ <div
68
+ className={styles.dragOverlay}
69
+ onDoubleClick={enterInteractive}
70
+ />
71
+ )}
72
+ </>
73
+ ) : (
74
+ <div className={styles.iframeContainer} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
75
+ <p style={{ color: 'var(--fgColor-muted, #656d76)', fontSize: 14, fontStyle: 'italic' }}>
76
+ No Figma URL
77
+ </p>
78
+ </div>
79
+ )}
80
+ </div>
81
+ <div
82
+ className={styles.resizeHandle}
83
+ onMouseDown={(e) => {
84
+ e.stopPropagation()
85
+ e.preventDefault()
86
+ const startX = e.clientX
87
+ const startY = e.clientY
88
+ const startW = width
89
+ const startH = height
90
+ function onMove(ev) {
91
+ const newW = Math.max(200, startW + ev.clientX - startX)
92
+ const newH = Math.max(150, startH + ev.clientY - startY)
93
+ onUpdate?.({ width: newW, height: newH })
94
+ }
95
+ function onUp() {
96
+ document.removeEventListener('mousemove', onMove)
97
+ document.removeEventListener('mouseup', onUp)
98
+ }
99
+ document.addEventListener('mousemove', onMove)
100
+ document.addEventListener('mouseup', onUp)
101
+ }}
102
+ onPointerDown={(e) => e.stopPropagation()}
103
+ />
104
+ </WidgetWrapper>
105
+ )
106
+ })
@@ -0,0 +1,83 @@
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: 8px;
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
+ .figmaLogo {
27
+ width: 14px;
28
+ height: 14px;
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% - 31px); /* subtract header height */
40
+ overflow: hidden;
41
+ }
42
+
43
+ .iframe {
44
+ width: 100%;
45
+ height: calc(100% + 24px); /* clip Figma's built-in bottom toolbar */
46
+ border: none;
47
+ display: block;
48
+ }
49
+
50
+ .dragOverlay {
51
+ position: absolute;
52
+ inset: 0;
53
+ z-index: 1;
54
+ cursor: grab;
55
+ }
56
+
57
+ .resizeHandle {
58
+ position: absolute;
59
+ bottom: 0;
60
+ right: 0;
61
+ width: 16px;
62
+ height: 16px;
63
+ cursor: nwse-resize;
64
+ background: linear-gradient(
65
+ 135deg,
66
+ transparent 40%,
67
+ var(--borderColor-muted, rgba(0, 0, 0, 0.15)) 40%,
68
+ var(--borderColor-muted, rgba(0, 0, 0, 0.15)) 50%,
69
+ transparent 50%,
70
+ transparent 65%,
71
+ var(--borderColor-muted, rgba(0, 0, 0, 0.15)) 65%,
72
+ var(--borderColor-muted, rgba(0, 0, 0, 0.15)) 75%,
73
+ transparent 75%
74
+ );
75
+ opacity: 0;
76
+ transition: opacity 150ms;
77
+ z-index: 2;
78
+ }
79
+
80
+ .embed:hover ~ .resizeHandle,
81
+ .resizeHandle:hover {
82
+ opacity: 1;
83
+ }