@dfosco/storyboard-react 4.0.0-beta.33 → 4.0.0-beta.35

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.
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-react",
3
- "version": "4.0.0-beta.33",
3
+ "version": "4.0.0-beta.35",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@dfosco/storyboard-core": "4.0.0-beta.33",
7
- "@dfosco/tiny-canvas": "4.0.0-beta.33",
6
+ "@dfosco/storyboard-core": "4.0.0-beta.35",
7
+ "@dfosco/tiny-canvas": "4.0.0-beta.35",
8
8
  "@neodrag/react": "^2.3.1",
9
9
  "glob": "^11.0.0",
10
10
  "jsonc-parser": "^3.3.1",
@@ -2,11 +2,10 @@ import { useState, useRef, useEffect, useCallback, useMemo, forwardRef, useImper
2
2
  import { createPortal } from 'react-dom'
3
3
  import { buildPrototypeIndex } from '@dfosco/storyboard-core'
4
4
  import WidgetWrapper from './WidgetWrapper.jsx'
5
+ import ResizeHandle from './ResizeHandle.jsx'
5
6
  import { readProp, prototypeEmbedSchema } from './widgetProps.js'
6
- import { getEmbedChromeVars, subscribeCanvasTheme } from './embedTheme.js'
7
+ import { getEmbedChromeVars } from './embedTheme.js'
7
8
  import { useIframeDevLogs } from './iframeDevLogs.js'
8
- import { useSnapshotCapture } from './useSnapshotCapture.js'
9
- import { enqueueRefresh, cancelRefresh, REVEAL_INTERVAL } from './refreshQueue.js'
10
9
  import styles from './PrototypeEmbed.module.css'
11
10
  import overlayStyles from './embedOverlay.module.css'
12
11
 
@@ -26,39 +25,20 @@ function formatName(name) {
26
25
  .replace(/\b\w/g, (c) => c.toUpperCase())
27
26
  }
28
27
 
29
- function listInternalPrototypes(index) {
30
- const allProtos = []
31
- const sortedFolders = index.sorted?.title?.folders
32
- const sortedPrototypes = index.sorted?.title?.prototypes
33
- const folderList = Array.isArray(sortedFolders) && sortedFolders.length > 0
34
- ? sortedFolders
35
- : (index.folders || [])
36
- const standaloneList = Array.isArray(sortedPrototypes) && sortedPrototypes.length > 0
37
- ? sortedPrototypes
38
- : (index.prototypes || [])
39
-
40
- for (const folder of folderList) {
41
- for (const proto of folder.prototypes || []) {
42
- if (!proto.isExternal) allProtos.push(proto)
43
- }
44
- }
45
- for (const proto of standaloneList) {
46
- if (!proto.isExternal) allProtos.push(proto)
47
- }
48
- return allProtos
49
- }
50
-
51
- function normalizeRoutePath(value, basePath = '') {
52
- if (!value || /^https?:\/\//.test(value)) return ''
53
- const noHash = value.split('#')[0]
54
- let route = noHash.split('?')[0]
55
- route = route.replace(/^\/branch--[^/]+/, '')
56
- if (basePath && route.startsWith(basePath)) {
57
- route = route.slice(basePath.length) || '/'
58
- }
59
- if (!route.startsWith('/')) route = `/${route}`
60
- route = route.replace(/\/+$/, '')
61
- return route || '/'
28
+ function resolveCanvasThemeFromStorage() {
29
+ if (typeof localStorage === 'undefined') return 'light'
30
+ let sync = { prototype: true, toolbar: false, codeBoxes: true, canvas: false }
31
+ try {
32
+ const rawSync = localStorage.getItem('sb-theme-sync')
33
+ if (rawSync) sync = { ...sync, ...JSON.parse(rawSync) }
34
+ } catch { /* */ }
35
+ if (!sync.canvas) return 'light'
36
+ const attrTheme = document.documentElement.getAttribute('data-sb-canvas-theme')
37
+ if (attrTheme) return attrTheme
38
+ const stored = localStorage.getItem('sb-color-scheme') || 'system'
39
+ if (stored !== 'system') return stored
40
+ return typeof window.matchMedia === 'function' &&
41
+ window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
62
42
  }
63
43
 
64
44
  export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdate, resizable }, ref) {
@@ -67,7 +47,6 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
67
47
  const height = readProp(props, 'height', prototypeEmbedSchema)
68
48
  const zoom = readProp(props, 'zoom', prototypeEmbedSchema)
69
49
  const label = readProp(props, 'label', prototypeEmbedSchema) || src
70
- const snapshot = props?.snapshot || props?.snapshotLight || props?.snapshotDark || ''
71
50
 
72
51
  const basePath = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
73
52
  const baseSegment = basePath.replace(/^\//, '')
@@ -81,119 +60,63 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
81
60
  }, [src, basePath, baseSegment])
82
61
 
83
62
  const scale = zoom / 100
63
+ const isExternal = /^https?:\/\//.test(src || '')
84
64
 
85
65
  const [editing, setEditing] = useState(false)
86
- const [interactive, _setInteractive] = useState(false)
87
- const [showIframe, _setShowIframe] = useState(false)
88
- const [iframeLoaded, _setIframeLoaded] = useState(false)
66
+ const [interactive, setInteractive] = useState(false)
89
67
  const [expanded, setExpanded] = useState(false)
90
68
  const [filter, setFilter] = useState('')
91
- const [canvasTheme, _setCanvasTheme] = useState('light')
92
- const [brokenSnaps, _setBrokenSnaps] = useState({})
93
-
94
- // ── Debug logging wrappers ──
95
- const setInteractive = (v) => { console.log(`[embed:${widgetId}] setInteractive →`, v); _setInteractive(v) }
96
- const setShowIframe = (v) => { if (v) console.trace(`[embed:${widgetId}] setShowIframe → TRUE`); else console.log(`[embed:${widgetId}] setShowIframe → false`); _setShowIframe(v) }
97
- const setIframeLoaded = (v) => { console.log(`[embed:${widgetId}] iframeLoaded →`, v); _setIframeLoaded(v) }
98
- const setCanvasTheme = (v) => { console.log(`[embed:${widgetId}] canvasTheme →`, v); _setCanvasTheme(v) }
99
- const setBrokenSnaps = (fn) => { console.log(`[embed:${widgetId}] setBrokenSnaps`); _setBrokenSnaps(fn) }
100
-
69
+ const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasThemeFromStorage())
101
70
  const inputRef = useRef(null)
102
71
  const filterRef = useRef(null)
103
72
  const embedRef = useRef(null)
104
73
  const iframeRef = useRef(null)
105
- const captureOnReadyRef = useRef(false)
106
- const exitSessionRef = useRef(0)
107
- const teardownTimerRef = useRef(null)
108
74
  const inlineContainerRef = useRef(null)
109
75
  const modalContainerRef = useRef(null)
110
- const resizeTimerRef = useRef(null)
111
- const prevInteractiveRef = useRef(false)
112
-
113
- // Snapshot capture hook — only active in dev mode (onUpdate present)
114
- const isExternal = /^https?:\/\//.test(src || '')
115
- const { iframeReady, requestCapture } = useSnapshotCapture({
116
- iframeRef,
117
- widgetId,
118
- onUpdate: isExternal ? null : onUpdate,
119
- showIframe,
120
- })
121
-
122
- // Single snapshot — backward compat reads snapshotLight/snapshotDark if snapshot is missing
123
- const hasSnap = !isExternal && !!(snapshot && snapshot.includes(widgetId) && !brokenSnaps[snapshot])
124
- console.log(`[embed:${widgetId}] render: hasSnap=${hasSnap}, showIframe=${showIframe}, interactive=${interactive}, canvasTheme=${canvasTheme}, snapshot=${snapshot ? snapshot.slice(0, 50) : 'null'}, broken=${!!brokenSnaps[snapshot]}`)
125
76
 
126
77
  const iframeSrc = useMemo(() => {
127
78
  if (!rawSrc) return ''
128
- // External URLs are embedded as-is — storyboard query params only apply to local prototypes
129
79
  if (/^https?:\/\//.test(rawSrc)) return rawSrc
130
80
  const hashIdx = rawSrc.indexOf('#')
131
81
  const base = hashIdx >= 0 ? rawSrc.slice(0, hashIdx) : rawSrc
132
82
  const hash = hashIdx >= 0 ? rawSrc.slice(hashIdx) : ''
133
83
  const sep = base.includes('?') ? '&' : '?'
134
- return `${base}${sep}_sb_embed&_sb_theme_target=prototype${hash}`
135
- }, [rawSrc])
84
+ return `${base}${sep}_sb_embed&_sb_theme_target=prototype&_sb_canvas_theme=${canvasTheme}${hash}`
85
+ }, [rawSrc, canvasTheme])
136
86
 
137
- useIframeDevLogs({
138
- widget: 'PrototypeEmbed',
139
- loaded: showIframe && Boolean(iframeSrc),
140
- src: iframeSrc,
141
- })
142
-
143
- // Build prototype index for the picker
144
87
  const prototypeIndex = useMemo(() => {
145
- try {
146
- return buildPrototypeIndex()
147
- } catch {
148
- return { folders: [], prototypes: [], globalFlows: [], sorted: { title: { prototypes: [], folders: [] } } }
149
- }
88
+ try { return buildPrototypeIndex() }
89
+ catch { return { folders: [], prototypes: [], globalFlows: [], sorted: { title: { prototypes: [], folders: [] } } } }
150
90
  }, [])
151
91
 
152
- // Build grouped picker entries from the prototype index
153
92
  const pickerGroups = useMemo(() => {
154
93
  const groups = []
155
94
  const idx = prototypeIndex
156
-
157
- const allProtos = listInternalPrototypes(idx)
158
-
95
+ const allProtos = []
96
+ for (const folder of (idx.sorted?.title?.folders || idx.folders || [])) {
97
+ for (const proto of folder.prototypes || []) {
98
+ if (!proto.isExternal) allProtos.push(proto)
99
+ }
100
+ }
101
+ for (const proto of (idx.sorted?.title?.prototypes || idx.prototypes || [])) {
102
+ if (!proto.isExternal) allProtos.push(proto)
103
+ }
159
104
  for (const proto of allProtos) {
160
105
  if (proto.hideFlows && proto.flows.length === 1) {
161
- groups.push({
162
- label: proto.name,
163
- items: [{ name: proto.name, route: proto.flows[0].route }],
164
- })
106
+ groups.push({ label: proto.name, items: [{ name: proto.name, route: proto.flows[0].route }] })
165
107
  } else if (proto.flows.length > 0) {
166
- groups.push({
167
- label: proto.name,
168
- items: proto.flows.map((f) => ({
169
- name: f.meta?.title || formatName(f.name),
170
- route: f.route,
171
- })),
172
- })
108
+ groups.push({ label: proto.name, items: proto.flows.map((f) => ({ name: f.meta?.title || formatName(f.name), route: f.route })) })
173
109
  } else {
174
- groups.push({
175
- label: proto.name,
176
- items: [{ name: proto.name, route: `/${proto.dirName}` }],
177
- })
110
+ groups.push({ label: proto.name, items: [{ name: proto.name, route: `/${proto.dirName}` }] })
178
111
  }
179
112
  }
180
-
181
- // Global flows
182
113
  const gf = idx.globalFlows || []
183
114
  if (gf.length > 0) {
184
- groups.push({
185
- label: 'Other flows',
186
- items: gf.map((f) => ({
187
- name: f.meta?.title || formatName(f.name),
188
- route: f.route,
189
- })),
190
- })
115
+ groups.push({ label: 'Other flows', items: gf.map((f) => ({ name: f.meta?.title || formatName(f.name), route: f.route })) })
191
116
  }
192
-
193
117
  return groups
194
118
  }, [prototypeIndex])
195
119
 
196
- // Filter groups by search text
197
120
  const filteredGroups = useMemo(() => {
198
121
  if (!filter) return pickerGroups
199
122
  const q = filter.toLowerCase()
@@ -211,36 +134,26 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
211
134
  }, [pickerGroups, filter])
212
135
 
213
136
  const prototypeName = useMemo(() => {
214
- const currentRoute = normalizeRoutePath(src, basePath) || normalizeRoutePath(rawSrc, basePath)
215
- if (!currentRoute) return ''
216
-
217
- let bestMatchName = ''
218
- let bestMatchLength = -1
219
-
220
- for (const proto of listInternalPrototypes(prototypeIndex)) {
221
- const candidateRoutes = [
222
- `/${proto.dirName}`,
223
- ...(proto.flows || []).map((flow) => flow.route),
224
- ]
225
- for (const candidate of candidateRoutes) {
226
- const candidateRoute = normalizeRoutePath(candidate, basePath)
227
- if (!candidateRoute || candidateRoute === '/') continue
228
- if (currentRoute === candidateRoute || currentRoute.startsWith(`${candidateRoute}/`)) {
229
- if (candidateRoute.length > bestMatchLength) {
230
- bestMatchLength = candidateRoute.length
231
- bestMatchName = proto.name || ''
232
- }
233
- }
137
+ if (!src) return ''
138
+ for (const group of pickerGroups) {
139
+ for (const item of group.items) {
140
+ const cleanRoute = item.route.replace(/^\/branch--[^/]+/, '')
141
+ const cleanSrc = src.replace(/^\/branch--[^/]+/, '')
142
+ if (cleanRoute === cleanSrc) return item.name
234
143
  }
235
144
  }
236
-
237
- return bestMatchName
238
- }, [prototypeIndex, src, rawSrc, basePath])
145
+ return ''
146
+ }, [src, pickerGroups])
239
147
 
240
148
  const prototypeTitle = prototypeName || label || 'Prototype'
241
-
242
149
  const hasPicker = pickerGroups.length > 0
243
150
 
151
+ useIframeDevLogs({
152
+ widget: 'PrototypeEmbed',
153
+ loaded: Boolean(iframeSrc && interactive),
154
+ src: iframeSrc,
155
+ })
156
+
244
157
  useEffect(() => {
245
158
  if (editing && hasPicker && filterRef.current) {
246
159
  filterRef.current.focus()
@@ -250,144 +163,27 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
250
163
  }
251
164
  }, [editing, hasPicker])
252
165
 
253
- useEffect(() => {
254
- console.log(`[embed:${widgetId}] effect:showIframe →`, showIframe)
255
- if (!showIframe) setIframeLoaded(false)
256
- }, [showIframe])
257
-
258
- // Exit interactive mode when clicking outside the embed.
259
- // Hides iframe immediately for a responsive feel, then captures
260
- // snapshots in the background with the iframe hidden but still mounted.
166
+ // Exit interactive mode when clicking outside the embed
261
167
  useEffect(() => {
262
168
  if (!interactive || expanded) return
263
- console.log(`[embed:${widgetId}] effect:exit-interactive listener attached`)
264
169
  function handlePointerDown(e) {
265
170
  if (embedRef.current && !embedRef.current.contains(e.target)) {
266
171
  const chromeEl = e.target.closest(`[data-widget-id="${widgetId}"]`)
267
172
  if (chromeEl) return
268
-
269
- console.log(`[embed:${widgetId}] exit-interactive: pointerdown outside, iframeLoaded=${iframeLoaded}, hasContentWindow=${!!iframeRef.current?.contentWindow}`)
270
173
  setInteractive(false)
271
- if (onUpdate && !isExternal && iframeLoaded && iframeRef.current?.contentWindow) {
272
- if (iframeRef.current) iframeRef.current.style.visibility = 'hidden'
273
- const session = ++exitSessionRef.current
274
- console.log(`[embed:${widgetId}] exit-interactive: starting capture, session=${session}`)
275
- setTimeout(() => {
276
- if (exitSessionRef.current !== session) { console.log(`[embed:${widgetId}] exit-interactive: stale session ${session}, current=${exitSessionRef.current}`); return }
277
- requestCapture({ force: true }).then((updates) => {
278
- if (exitSessionRef.current !== session) { console.log(`[embed:${widgetId}] exit-interactive: stale session after capture`); return }
279
- const snap = updates?.snapshot
280
- console.log(`[embed:${widgetId}] exit-interactive: capture done, snap=${snap ? 'yes(' + snap.length + ')' : 'null'}`)
281
- if (snap) {
282
- const img = new Image()
283
- const done = () => {
284
- if (exitSessionRef.current === session) setShowIframe(false)
285
- }
286
- img.onload = done
287
- img.onerror = done
288
- img.src = snap
289
- setTimeout(done, 2000)
290
- } else {
291
- setShowIframe(false)
292
- }
293
- })
294
- }, 0)
295
- } else if (isExternal && showIframe) {
296
- // External embeds (e.g. Figma) are slow to reload — keep the
297
- // iframe mounted for 2 min so re-entering is instant.
298
- const session = ++exitSessionRef.current
299
- clearTimeout(teardownTimerRef.current)
300
- teardownTimerRef.current = setTimeout(() => {
301
- if (exitSessionRef.current !== session) return
302
- setShowIframe(false)
303
- }, 2 * 60 * 1000)
304
- } else {
305
- setShowIframe(false)
306
- }
307
174
  }
308
175
  }
309
176
  document.addEventListener('pointerdown', handlePointerDown)
310
177
  return () => document.removeEventListener('pointerdown', handlePointerDown)
311
- }, [interactive, expanded, onUpdate, isExternal, iframeLoaded, requestCapture])
312
-
313
- useEffect(() => subscribeCanvasTheme({
314
- anchorRef: embedRef,
315
- onTheme: setCanvasTheme,
316
- }), [])
178
+ }, [interactive, expanded, widgetId])
317
179
 
318
- // On canvas theme change, enqueue a background snapshot refresh.
319
- // Skips the initial render (canvasThemeInitRef tracks first value).
320
- // Uses a ref to check hasSnap at callback time (not closure time).
321
- const canvasThemeInitRef = useRef(true)
322
- const refreshMetaRef = useRef(null)
323
- const hasSnapRef = useRef(hasSnap)
324
- hasSnapRef.current = hasSnap
325
180
  useEffect(() => {
326
- if (canvasThemeInitRef.current) { canvasThemeInitRef.current = false; console.log(`[embed:${widgetId}] theme-effect: skip init`); return }
327
- if (isExternal || !onUpdate || interactive || !hasSnap) { console.log(`[embed:${widgetId}] theme-effect: skip (ext=${isExternal}, noUpdate=${!onUpdate}, interactive=${interactive}, hasSnap=${hasSnap})`); return }
328
- console.log(`[embed:${widgetId}] theme-effect: enqueue refresh, hasSnap=${hasSnap}, hasSnapRef=${hasSnapRef.current}`)
329
- const rect = embedRef.current?.getBoundingClientRect()
330
- enqueueRefresh(widgetId, ({ revealOrder, batchStart }) => {
331
- console.log(`[embed:${widgetId}] refresh-callback: hasSnapRef=${hasSnapRef.current}`)
332
- if (!hasSnapRef.current) { console.log(`[embed:${widgetId}] refresh-callback: ABORT, no snap`); return Promise.resolve(false) }
333
- return new Promise((resolve) => {
334
- refreshMetaRef.current = { revealOrder, batchStart, resolve }
335
- captureOnReadyRef.current = true
336
- setShowIframe(true)
337
- // Safety timeout — report failure so retry pass picks it up
338
- setTimeout(() => { refreshMetaRef.current = null; resolve(false) }, 10000)
339
- })
340
- }, rect ? { x: rect.left, y: rect.top } : undefined)
341
- }, [canvasTheme]) // eslint-disable-line react-hooks/exhaustive-deps
342
-
343
- // Capture snapshot on first iframe ready (when no existing snapshot)
344
- useEffect(() => {
345
- console.log(`[embed:${widgetId}] effect:iframeReady → ${iframeReady}, onUpdate=${!!onUpdate}, isExternal=${isExternal}, hasSnap=${hasSnap}`)
346
- if (!iframeReady || !onUpdate || isExternal) return
347
- if (!hasSnap) {
348
- console.log(`[embed:${widgetId}] first-ready: requestCapture (no snap)`)
349
- requestCapture()
181
+ function readToolbarTheme() {
182
+ setCanvasTheme(resolveCanvasThemeFromStorage())
350
183
  }
351
- }, [iframeReady]) // eslint-disable-line react-hooks/exhaustive-deps
352
-
353
- // Capture when iframe becomes ready after refresh-thumbnail requested it
354
- useEffect(() => {
355
- if (iframeReady && captureOnReadyRef.current) {
356
- captureOnReadyRef.current = false
357
- console.log(`[embed:${widgetId}] captureOnReady: requestCapture`)
358
- requestCapture().then((updates) => {
359
- const meta = refreshMetaRef.current
360
- console.log(`[embed:${widgetId}] captureOnReady: done, snap=${updates?.snapshot ? 'yes' : 'null'}, hasMeta=${!!meta}`)
361
- if (meta) {
362
- refreshMetaRef.current = null
363
- const snap = updates?.snapshot
364
- const reveal = () => {
365
- if (snap) {
366
- const img = new Image()
367
- const done = () => setShowIframe(false)
368
- img.onload = done
369
- img.onerror = done
370
- img.src = snap
371
- setTimeout(done, 2000)
372
- } else {
373
- setShowIframe(false)
374
- }
375
- meta.resolve(!!snap)
376
- }
377
- // Wait for our reveal slot in the wave
378
- const elapsed = Date.now() - meta.batchStart
379
- const targetTime = meta.revealOrder * REVEAL_INTERVAL
380
- const wait = Math.max(0, targetTime - elapsed)
381
- setTimeout(reveal, wait)
382
- }
383
- })
384
- }
385
- }, [iframeReady, requestCapture])
386
-
387
- // Cleanup timers on unmount
388
- useEffect(() => () => {
389
- clearTimeout(resizeTimerRef.current)
390
- clearTimeout(teardownTimerRef.current)
184
+ readToolbarTheme()
185
+ document.addEventListener('storyboard:theme:changed', readToolbarTheme)
186
+ return () => document.removeEventListener('storyboard:theme:changed', readToolbarTheme)
391
187
  }, [])
392
188
 
393
189
  // Close expanded modal on Escape
@@ -403,25 +199,18 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
403
199
  return () => document.removeEventListener('keydown', handleKeyDown, true)
404
200
  }, [expanded])
405
201
 
406
- // Reparent iframe DOM node between inline container and modal.
407
- // Uses moveBefore() (Chrome 133+) which preserves the iframe's
408
- // browsing context — no reload. Falls back to appendChild which
409
- // will reload but still works functionally.
202
+ // Reparent iframe between inline and modal
410
203
  useEffect(() => {
411
204
  const iframe = iframeRef.current
412
205
  if (!iframe) return
413
-
414
206
  if (expanded && modalContainerRef.current) {
415
207
  iframe._savedClassName = iframe.className
416
208
  iframe._savedStyle = iframe.getAttribute('style') || ''
417
209
  iframe.className = styles.expandIframe
418
210
  iframe.removeAttribute('style')
419
211
  const target = modalContainerRef.current
420
- if (target.moveBefore) {
421
- target.moveBefore(iframe, target.firstChild)
422
- } else {
423
- target.prepend(iframe)
424
- }
212
+ if (target.moveBefore) target.moveBefore(iframe, target.firstChild)
213
+ else target.prepend(iframe)
425
214
  } else if (!expanded && inlineContainerRef.current) {
426
215
  if (iframe._savedClassName !== undefined) {
427
216
  iframe.className = iframe._savedClassName
@@ -430,28 +219,20 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
430
219
  delete iframe._savedStyle
431
220
  }
432
221
  const target = inlineContainerRef.current
433
- if (target.moveBefore) {
434
- target.moveBefore(iframe, null)
435
- } else {
436
- target.appendChild(iframe)
437
- }
222
+ if (target.moveBefore) target.moveBefore(iframe, null)
223
+ else target.appendChild(iframe)
438
224
  }
439
225
  }, [expanded])
440
226
 
441
- // Listen for messages from the embedded prototype iframe
227
+ // Listen for navigation events from the embedded prototype iframe
442
228
  useEffect(() => {
443
229
  function handleMessage(e) {
444
- if (!iframeRef.current?.contentWindow) return
445
- if (e.source !== iframeRef.current.contentWindow) return
446
-
447
- // Navigation events
448
- if (e.data?.type === 'storyboard:embed:navigate') {
449
- const newSrc = e.data.src
450
- if (newSrc && newSrc !== src) {
451
- const originalSrc = readProp(props, 'originalSrc', prototypeEmbedSchema)
452
- onUpdate?.({ src: newSrc, originalSrc: originalSrc || src })
453
- }
454
- return
230
+ if (e.source !== iframeRef.current?.contentWindow) return
231
+ if (e.data?.type !== 'storyboard:embed:navigate') return
232
+ const newSrc = e.data.src
233
+ if (newSrc && newSrc !== src) {
234
+ const originalSrc = readProp(props, 'originalSrc', prototypeEmbedSchema)
235
+ onUpdate?.({ src: newSrc, originalSrc: originalSrc || src })
455
236
  }
456
237
  }
457
238
  window.addEventListener('message', handleMessage)
@@ -460,35 +241,25 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
460
241
 
461
242
  const chromeVars = useMemo(() => getEmbedChromeVars(canvasTheme), [canvasTheme])
462
243
 
463
- const enterInteractive = useCallback(() => {
464
- console.log(`[embed:${widgetId}] enterInteractive`)
465
- exitSessionRef.current++
466
- clearTimeout(teardownTimerRef.current)
467
- cancelRefresh(widgetId)
468
- setShowIframe(true)
469
- setInteractive(true)
470
- }, [widgetId])
244
+ const enterInteractive = useCallback(() => setInteractive(true), [])
471
245
 
472
- // Expose imperative action handlers for WidgetChrome
473
246
  useImperativeHandle(ref, () => ({
474
247
  handleAction(actionId) {
475
248
  if (actionId === 'edit') {
476
249
  setEditing(true)
477
250
  } else if (actionId === 'expand') {
478
- setShowIframe(true)
479
251
  setExpanded(true)
480
252
  } else if (actionId === 'open-external') {
481
253
  if (rawSrc) window.open(rawSrc, '_blank', 'noopener')
482
- } else if (actionId === 'refresh-thumbnail') {
483
- if (iframeReady && iframeRef.current?.contentWindow) {
484
- requestCapture()
485
- } else {
486
- captureOnReadyRef.current = true
487
- setShowIframe(true)
488
- }
254
+ } else if (actionId === 'zoom-in') {
255
+ const step = zoom < 75 ? 5 : 25
256
+ onUpdate?.({ zoom: Math.min(200, zoom + step) })
257
+ } else if (actionId === 'zoom-out') {
258
+ const step = zoom <= 75 ? 5 : 25
259
+ onUpdate?.({ zoom: Math.max(25, zoom - step) })
489
260
  }
490
261
  },
491
- }), [rawSrc, iframeReady, requestCapture])
262
+ }), [rawSrc, zoom, onUpdate])
492
263
 
493
264
  function handlePickRoute(route) {
494
265
  onUpdate?.({ src: route })
@@ -509,6 +280,10 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
509
280
  setFilter('')
510
281
  }
511
282
 
283
+ const handleResize = useCallback((w, h) => {
284
+ onUpdate?.({ width: w, height: h })
285
+ }, [onUpdate])
286
+
512
287
  return (
513
288
  <>
514
289
  <WidgetWrapper>
@@ -531,12 +306,7 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
531
306
  <>
532
307
  <div className={styles.pickerHeader}>
533
308
  <span className={styles.urlLabel}>Pick a prototype</span>
534
- <button
535
- type="button"
536
- className={styles.urlCancel}
537
- onClick={handleCancelEdit}
538
- aria-label="Cancel"
539
- >✕</button>
309
+ <button type="button" className={styles.urlCancel} onClick={handleCancelEdit} aria-label="Cancel">✕</button>
540
310
  </div>
541
311
  <input
542
312
  ref={filterRef}
@@ -551,23 +321,14 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
551
321
  {filteredGroups.map((group) => (
552
322
  <div key={group.label} className={styles.pickerGroup}>
553
323
  {group.items.length === 1 && group.items[0].name === group.label ? (
554
- <button
555
- className={styles.pickerItem}
556
- role="option"
557
- onClick={() => handlePickRoute(group.items[0].route)}
558
- >
324
+ <button className={styles.pickerItem} role="option" onClick={() => handlePickRoute(group.items[0].route)}>
559
325
  {group.label}
560
326
  </button>
561
327
  ) : (
562
328
  <>
563
329
  <div className={styles.pickerGroupLabel}>{group.label}</div>
564
330
  {group.items.map((item) => (
565
- <button
566
- key={item.route}
567
- className={styles.pickerItem}
568
- role="option"
569
- onClick={() => handlePickRoute(item.route)}
570
- >
331
+ <button key={item.route} className={styles.pickerItem} role="option" onClick={() => handlePickRoute(item.route)}>
571
332
  {item.name}
572
333
  </button>
573
334
  ))}
@@ -575,30 +336,17 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
575
336
  )}
576
337
  </div>
577
338
  ))}
578
- {filteredGroups.length === 0 && (
579
- <div className={styles.pickerEmpty}>No matches</div>
580
- )}
339
+ {filteredGroups.length === 0 && <div className={styles.pickerEmpty}>No matches</div>}
581
340
  </div>
582
341
  <div className={styles.pickerDivider} />
583
342
  </>
584
343
  )}
585
344
  <form className={styles.customUrlSection} onSubmit={handleSubmit}>
586
- <label className={styles.urlLabel}>
587
- {hasPicker ? 'Or enter a custom URL' : 'Prototype URL path'}
588
- </label>
589
- <input
590
- ref={inputRef}
591
- className={styles.urlInput}
592
- type="text"
593
- defaultValue={src}
594
- placeholder="/MyPrototype/page"
595
- onKeyDown={(e) => { if (e.key === 'Escape') handleCancelEdit() }}
596
- />
345
+ <label className={styles.urlLabel}>{hasPicker ? 'Or enter a custom URL' : 'Prototype URL path'}</label>
346
+ <input ref={inputRef} className={styles.urlInput} type="text" defaultValue={src} placeholder="/MyPrototype/page" onKeyDown={(e) => { if (e.key === 'Escape') handleCancelEdit() }} />
597
347
  <div className={styles.urlActions}>
598
348
  <button type="submit" className={styles.urlSave}>Save</button>
599
- {!hasPicker && (
600
- <button type="button" className={styles.urlCancel} onClick={handleCancelEdit}>Cancel</button>
601
- )}
349
+ {!hasPicker && <button type="button" className={styles.urlCancel} onClick={handleCancelEdit}>Cancel</button>}
602
350
  </div>
603
351
  </form>
604
352
  </div>
@@ -609,46 +357,20 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
609
357
  className={styles.iframeContainer}
610
358
  style={expanded ? { visibility: 'hidden' } : undefined}
611
359
  >
612
- {/* Snapshot layer — single image */}
613
- {hasSnap && (
614
- <img
615
- src={snapshot}
616
- className={styles.snapshotImage}
617
- alt={`${prototypeTitle} snapshot`}
618
- draggable={false}
619
- onError={() => { console.log(`[embed:${widgetId}] snapshot img onError: ${snapshot?.slice(0, 60)}`); setBrokenSnaps(prev => ({ ...prev, [snapshot]: true })) }}
620
- />
621
- )}
622
-
623
- {/* Iframe layer — on top, transparent until loaded */}
624
- {showIframe && (
625
- <iframe
626
- ref={iframeRef}
627
- src={iframeSrc}
628
- className={styles.iframe}
629
- style={{
630
- width: width / scale,
631
- height: height / scale,
632
- transform: `scale(${scale})`,
633
- transformOrigin: '0 0',
634
- transition: 'opacity 150ms ease',
635
- ...(iframeLoaded ? {} : { opacity: 0 }),
636
- }}
637
- onLoad={() => { console.log(`[embed:${widgetId}] iframe onLoad`); setIframeLoaded(true) }}
638
- title={`${prototypeTitle} prototype`}
639
- sandbox="allow-same-origin allow-scripts allow-forms allow-popups"
640
- />
641
- )}
642
-
643
- {/* Placeholder — only when no snapshot and no iframe */}
644
- {!hasSnap && !showIframe && (
645
- <div className={styles.placeholder}>
646
- <CollageFrameIcon size={36} />
647
- <span className={styles.placeholderLabel}>{`${prototypeTitle} prototype`}</span>
648
- </div>
649
- )}
360
+ <iframe
361
+ ref={iframeRef}
362
+ src={iframeSrc}
363
+ className={styles.iframe}
364
+ style={{
365
+ width: width / scale,
366
+ height: height / scale,
367
+ transform: `scale(${scale})`,
368
+ transformOrigin: '0 0',
369
+ }}
370
+ title={`${prototypeTitle} prototype`}
371
+ sandbox="allow-same-origin allow-scripts allow-forms allow-popups"
372
+ />
650
373
  </div>
651
-
652
374
  {!interactive && !expanded && (
653
375
  <div
654
376
  className={overlayStyles.interactOverlay}
@@ -665,52 +387,20 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
665
387
  enterInteractive()
666
388
  }
667
389
  }}
668
- aria-label={hasSnap ? 'Click to interact with prototype' : 'Click to open prototype'}
390
+ aria-label="Click to interact with prototype"
669
391
  >
670
- <span className={overlayStyles.interactHint}>{hasSnap ? 'Click to interact' : 'Click to open'}</span>
392
+ <span className={overlayStyles.interactHint}>Click to interact</span>
671
393
  </div>
672
394
  )}
673
395
  </>
674
396
  ) : (
675
- <div
676
- className={styles.empty}
677
- onDoubleClick={() => setEditing(true)}
678
- role="button"
679
- tabIndex={0}
680
- onKeyDown={(e) => { if (e.key === 'Enter') setEditing(true) }}
681
- >
682
- <p>Double-click to set prototype URL</p>
397
+ <div className={styles.empty} onClick={() => onUpdate && setEditing(true)} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter') setEditing(true) }}>
398
+ <CollageFrameIcon size={36} />
399
+ <p>Click to set prototype URL</p>
683
400
  </div>
684
401
  )}
685
402
  </div>
686
- {resizable && (
687
- <div
688
- className={styles.resizeHandle}
689
- onMouseDown={(e) => {
690
- e.stopPropagation()
691
- e.preventDefault()
692
- const startX = e.clientX
693
- const startY = e.clientY
694
- const startW = width
695
- const startH = height
696
- function onMove(ev) {
697
- const newW = Math.max(200, startW + ev.clientX - startX)
698
- const newH = Math.max(150, startH + ev.clientY - startY)
699
- onUpdate?.({ width: newW, height: newH })
700
- }
701
- function onUp() {
702
- document.removeEventListener('mousemove', onMove)
703
- document.removeEventListener('mouseup', onUp)
704
- // Recapture snapshot after resize (debounced)
705
- clearTimeout(resizeTimerRef.current)
706
- resizeTimerRef.current = setTimeout(() => requestCapture(), 1500)
707
- }
708
- document.addEventListener('mousemove', onMove)
709
- document.addEventListener('mouseup', onUp)
710
- }}
711
- onPointerDown={(e) => e.stopPropagation()}
712
- />
713
- )}
403
+ {resizable && <ResizeHandle width={width} height={height} onResize={handleResize} />}
714
404
  </WidgetWrapper>
715
405
  {createPortal(
716
406
  <div
@@ -718,26 +408,11 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
718
408
  style={expanded ? undefined : { display: 'none' }}
719
409
  onClick={() => setExpanded(false)}
720
410
  onPointerDown={(e) => e.stopPropagation()}
721
- onKeyDown={(e) => {
722
- e.stopPropagation()
723
- if (e.key === 'Escape') setExpanded(false)
724
- }}
411
+ onKeyDown={(e) => e.stopPropagation()}
725
412
  onWheel={(e) => e.stopPropagation()}
726
- tabIndex={-1}
727
- ref={(el) => { if (el && expanded) el.focus() }}
728
413
  >
729
- <div
730
- ref={modalContainerRef}
731
- className={styles.expandContainer}
732
- onClick={(e) => e.stopPropagation()}
733
- >
734
- {/* iframe is reparented here via useEffect */}
735
- <button
736
- className={styles.expandClose}
737
- onClick={() => setExpanded(false)}
738
- aria-label="Close expanded view"
739
- autoFocus
740
- >✕</button>
414
+ <div ref={modalContainerRef} className={styles.expandContainer} onClick={(e) => e.stopPropagation()}>
415
+ <button className={styles.expandClose} onClick={() => setExpanded(false)} aria-label="Close expanded view" autoFocus>✕</button>
741
416
  </div>
742
417
  </div>,
743
418
  document.body