@dfosco/storyboard-react 4.0.0-beta.34 → 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.34",
3
+ "version": "4.0.0-beta.35",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@dfosco/storyboard-core": "4.0.0-beta.34",
7
- "@dfosco/tiny-canvas": "4.0.0-beta.34",
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 (temporary) ──
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,133 +163,27 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
250
163
  }
251
164
  }, [editing, hasPicker])
252
165
 
253
- useEffect(() => {
254
- if (!showIframe) setIframeLoaded(false)
255
- }, [showIframe])
256
-
257
- // Exit interactive mode when clicking outside the embed.
258
- // Captures a snapshot in the background, then unmounts the iframe.
166
+ // Exit interactive mode when clicking outside the embed
259
167
  useEffect(() => {
260
168
  if (!interactive || expanded) return
261
169
  function handlePointerDown(e) {
262
170
  if (embedRef.current && !embedRef.current.contains(e.target)) {
263
171
  const chromeEl = e.target.closest(`[data-widget-id="${widgetId}"]`)
264
172
  if (chromeEl) return
265
-
266
173
  setInteractive(false)
267
- if (onUpdate && !isExternal && iframeLoaded && iframeRef.current?.contentWindow) {
268
- if (iframeRef.current) iframeRef.current.style.visibility = 'hidden'
269
- const session = ++exitSessionRef.current
270
- setTimeout(() => {
271
- if (exitSessionRef.current !== session) return
272
- requestCapture({ force: true }).then((updates) => {
273
- if (exitSessionRef.current !== session) return
274
- const snap = updates?.snapshot
275
- if (snap) {
276
- const img = new Image()
277
- const done = () => {
278
- if (exitSessionRef.current === session) setShowIframe(false)
279
- }
280
- img.onload = done
281
- img.onerror = done
282
- img.src = snap
283
- setTimeout(done, 2000)
284
- } else {
285
- setShowIframe(false)
286
- }
287
- })
288
- }, 0)
289
- } else if (isExternal && showIframe) {
290
- const session = ++exitSessionRef.current
291
- clearTimeout(teardownTimerRef.current)
292
- teardownTimerRef.current = setTimeout(() => {
293
- if (exitSessionRef.current !== session) return
294
- setShowIframe(false)
295
- }, 2 * 60 * 1000)
296
- } else {
297
- setShowIframe(false)
298
- }
299
174
  }
300
175
  }
301
176
  document.addEventListener('pointerdown', handlePointerDown)
302
177
  return () => document.removeEventListener('pointerdown', handlePointerDown)
303
- }, [interactive, expanded, onUpdate, isExternal, iframeLoaded, requestCapture])
304
-
305
- useEffect(() => subscribeCanvasTheme({
306
- anchorRef: embedRef,
307
- onTheme: setCanvasTheme,
308
- }), [])
178
+ }, [interactive, expanded, widgetId])
309
179
 
310
- // On canvas theme change, enqueue a background snapshot refresh.
311
- // Only fires for true user-initiated theme changes (not page load).
312
- // Uses mountTime to ignore the initial theme resolution that happens
313
- // within the first 3 seconds of mount.
314
- const mountTimeRef = useRef(Date.now())
315
- const refreshMetaRef = useRef(null)
316
- const hasSnapRef = useRef(hasSnap)
317
- hasSnapRef.current = hasSnap
318
180
  useEffect(() => {
319
- // Ignore theme changes during initial page load (first 3 seconds)
320
- const elapsed = Date.now() - mountTimeRef.current
321
- if (elapsed < 3000) { console.log(`[embed:${widgetId}] theme-effect: skip page-load (${elapsed}ms)`); return }
322
- if (isExternal || !onUpdate || interactive) { console.log(`[embed:${widgetId}] theme-effect: skip (ext=${isExternal}, noUpdate=${!onUpdate}, interactive=${interactive})`); return }
323
- if (!hasSnapRef.current) { console.log(`[embed:${widgetId}] theme-effect: skip (no snap)`); return }
324
- console.log(`[embed:${widgetId}] theme-effect: enqueue refresh, hasSnapRef=${hasSnapRef.current}`)
325
- const rect = embedRef.current?.getBoundingClientRect()
326
- enqueueRefresh(widgetId, ({ revealOrder, batchStart }) => {
327
- if (!hasSnapRef.current) return Promise.resolve(false)
328
- return new Promise((resolve) => {
329
- refreshMetaRef.current = { revealOrder, batchStart, resolve }
330
- captureOnReadyRef.current = true
331
- setShowIframe(true)
332
- setTimeout(() => { refreshMetaRef.current = null; resolve(false) }, 10000)
333
- })
334
- }, rect ? { x: rect.left, y: rect.top } : undefined)
335
- }, [canvasTheme]) // eslint-disable-line react-hooks/exhaustive-deps
336
-
337
- // Capture snapshot on first iframe ready (when no existing snapshot)
338
- useEffect(() => {
339
- if (!iframeReady || !onUpdate || isExternal) return
340
- if (!hasSnap) {
341
- requestCapture()
181
+ function readToolbarTheme() {
182
+ setCanvasTheme(resolveCanvasThemeFromStorage())
342
183
  }
343
- }, [iframeReady]) // eslint-disable-line react-hooks/exhaustive-deps
344
-
345
- // Capture when iframe becomes ready after refresh-thumbnail requested it
346
- useEffect(() => {
347
- if (iframeReady && captureOnReadyRef.current) {
348
- captureOnReadyRef.current = false
349
- requestCapture().then((updates) => {
350
- const meta = refreshMetaRef.current
351
- if (meta) {
352
- refreshMetaRef.current = null
353
- const snap = updates?.snapshot
354
- const reveal = () => {
355
- if (snap) {
356
- const img = new Image()
357
- const done = () => setShowIframe(false)
358
- img.onload = done
359
- img.onerror = done
360
- img.src = snap
361
- setTimeout(done, 2000)
362
- } else {
363
- setShowIframe(false)
364
- }
365
- meta.resolve(!!snap)
366
- }
367
- const elapsed = Date.now() - meta.batchStart
368
- const targetTime = meta.revealOrder * REVEAL_INTERVAL
369
- const wait = Math.max(0, targetTime - elapsed)
370
- setTimeout(reveal, wait)
371
- }
372
- })
373
- }
374
- }, [iframeReady, requestCapture])
375
-
376
- // Cleanup timers on unmount
377
- useEffect(() => () => {
378
- clearTimeout(resizeTimerRef.current)
379
- clearTimeout(teardownTimerRef.current)
184
+ readToolbarTheme()
185
+ document.addEventListener('storyboard:theme:changed', readToolbarTheme)
186
+ return () => document.removeEventListener('storyboard:theme:changed', readToolbarTheme)
380
187
  }, [])
381
188
 
382
189
  // Close expanded modal on Escape
@@ -392,25 +199,18 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
392
199
  return () => document.removeEventListener('keydown', handleKeyDown, true)
393
200
  }, [expanded])
394
201
 
395
- // Reparent iframe DOM node between inline container and modal.
396
- // Uses moveBefore() (Chrome 133+) which preserves the iframe's
397
- // browsing context — no reload. Falls back to appendChild which
398
- // will reload but still works functionally.
202
+ // Reparent iframe between inline and modal
399
203
  useEffect(() => {
400
204
  const iframe = iframeRef.current
401
205
  if (!iframe) return
402
-
403
206
  if (expanded && modalContainerRef.current) {
404
207
  iframe._savedClassName = iframe.className
405
208
  iframe._savedStyle = iframe.getAttribute('style') || ''
406
209
  iframe.className = styles.expandIframe
407
210
  iframe.removeAttribute('style')
408
211
  const target = modalContainerRef.current
409
- if (target.moveBefore) {
410
- target.moveBefore(iframe, target.firstChild)
411
- } else {
412
- target.prepend(iframe)
413
- }
212
+ if (target.moveBefore) target.moveBefore(iframe, target.firstChild)
213
+ else target.prepend(iframe)
414
214
  } else if (!expanded && inlineContainerRef.current) {
415
215
  if (iframe._savedClassName !== undefined) {
416
216
  iframe.className = iframe._savedClassName
@@ -419,28 +219,20 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
419
219
  delete iframe._savedStyle
420
220
  }
421
221
  const target = inlineContainerRef.current
422
- if (target.moveBefore) {
423
- target.moveBefore(iframe, null)
424
- } else {
425
- target.appendChild(iframe)
426
- }
222
+ if (target.moveBefore) target.moveBefore(iframe, null)
223
+ else target.appendChild(iframe)
427
224
  }
428
225
  }, [expanded])
429
226
 
430
- // Listen for messages from the embedded prototype iframe
227
+ // Listen for navigation events from the embedded prototype iframe
431
228
  useEffect(() => {
432
229
  function handleMessage(e) {
433
- if (!iframeRef.current?.contentWindow) return
434
- if (e.source !== iframeRef.current.contentWindow) return
435
-
436
- // Navigation events
437
- if (e.data?.type === 'storyboard:embed:navigate') {
438
- const newSrc = e.data.src
439
- if (newSrc && newSrc !== src) {
440
- const originalSrc = readProp(props, 'originalSrc', prototypeEmbedSchema)
441
- onUpdate?.({ src: newSrc, originalSrc: originalSrc || src })
442
- }
443
- 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 })
444
236
  }
445
237
  }
446
238
  window.addEventListener('message', handleMessage)
@@ -449,35 +241,25 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
449
241
 
450
242
  const chromeVars = useMemo(() => getEmbedChromeVars(canvasTheme), [canvasTheme])
451
243
 
452
- const enterInteractive = useCallback(() => {
453
- console.log(`[embed:${widgetId}] enterInteractive`)
454
- exitSessionRef.current++
455
- clearTimeout(teardownTimerRef.current)
456
- cancelRefresh(widgetId)
457
- setShowIframe(true)
458
- setInteractive(true)
459
- }, [widgetId])
244
+ const enterInteractive = useCallback(() => setInteractive(true), [])
460
245
 
461
- // Expose imperative action handlers for WidgetChrome
462
246
  useImperativeHandle(ref, () => ({
463
247
  handleAction(actionId) {
464
248
  if (actionId === 'edit') {
465
249
  setEditing(true)
466
250
  } else if (actionId === 'expand') {
467
- setShowIframe(true)
468
251
  setExpanded(true)
469
252
  } else if (actionId === 'open-external') {
470
253
  if (rawSrc) window.open(rawSrc, '_blank', 'noopener')
471
- } else if (actionId === 'refresh-thumbnail') {
472
- if (iframeReady && iframeRef.current?.contentWindow) {
473
- requestCapture()
474
- } else {
475
- captureOnReadyRef.current = true
476
- setShowIframe(true)
477
- }
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) })
478
260
  }
479
261
  },
480
- }), [rawSrc, iframeReady, requestCapture])
262
+ }), [rawSrc, zoom, onUpdate])
481
263
 
482
264
  function handlePickRoute(route) {
483
265
  onUpdate?.({ src: route })
@@ -498,6 +280,10 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
498
280
  setFilter('')
499
281
  }
500
282
 
283
+ const handleResize = useCallback((w, h) => {
284
+ onUpdate?.({ width: w, height: h })
285
+ }, [onUpdate])
286
+
501
287
  return (
502
288
  <>
503
289
  <WidgetWrapper>
@@ -520,12 +306,7 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
520
306
  <>
521
307
  <div className={styles.pickerHeader}>
522
308
  <span className={styles.urlLabel}>Pick a prototype</span>
523
- <button
524
- type="button"
525
- className={styles.urlCancel}
526
- onClick={handleCancelEdit}
527
- aria-label="Cancel"
528
- >✕</button>
309
+ <button type="button" className={styles.urlCancel} onClick={handleCancelEdit} aria-label="Cancel">✕</button>
529
310
  </div>
530
311
  <input
531
312
  ref={filterRef}
@@ -540,23 +321,14 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
540
321
  {filteredGroups.map((group) => (
541
322
  <div key={group.label} className={styles.pickerGroup}>
542
323
  {group.items.length === 1 && group.items[0].name === group.label ? (
543
- <button
544
- className={styles.pickerItem}
545
- role="option"
546
- onClick={() => handlePickRoute(group.items[0].route)}
547
- >
324
+ <button className={styles.pickerItem} role="option" onClick={() => handlePickRoute(group.items[0].route)}>
548
325
  {group.label}
549
326
  </button>
550
327
  ) : (
551
328
  <>
552
329
  <div className={styles.pickerGroupLabel}>{group.label}</div>
553
330
  {group.items.map((item) => (
554
- <button
555
- key={item.route}
556
- className={styles.pickerItem}
557
- role="option"
558
- onClick={() => handlePickRoute(item.route)}
559
- >
331
+ <button key={item.route} className={styles.pickerItem} role="option" onClick={() => handlePickRoute(item.route)}>
560
332
  {item.name}
561
333
  </button>
562
334
  ))}
@@ -564,30 +336,17 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
564
336
  )}
565
337
  </div>
566
338
  ))}
567
- {filteredGroups.length === 0 && (
568
- <div className={styles.pickerEmpty}>No matches</div>
569
- )}
339
+ {filteredGroups.length === 0 && <div className={styles.pickerEmpty}>No matches</div>}
570
340
  </div>
571
341
  <div className={styles.pickerDivider} />
572
342
  </>
573
343
  )}
574
344
  <form className={styles.customUrlSection} onSubmit={handleSubmit}>
575
- <label className={styles.urlLabel}>
576
- {hasPicker ? 'Or enter a custom URL' : 'Prototype URL path'}
577
- </label>
578
- <input
579
- ref={inputRef}
580
- className={styles.urlInput}
581
- type="text"
582
- defaultValue={src}
583
- placeholder="/MyPrototype/page"
584
- onKeyDown={(e) => { if (e.key === 'Escape') handleCancelEdit() }}
585
- />
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() }} />
586
347
  <div className={styles.urlActions}>
587
348
  <button type="submit" className={styles.urlSave}>Save</button>
588
- {!hasPicker && (
589
- <button type="button" className={styles.urlCancel} onClick={handleCancelEdit}>Cancel</button>
590
- )}
349
+ {!hasPicker && <button type="button" className={styles.urlCancel} onClick={handleCancelEdit}>Cancel</button>}
591
350
  </div>
592
351
  </form>
593
352
  </div>
@@ -598,51 +357,20 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
598
357
  className={styles.iframeContainer}
599
358
  style={expanded ? { visibility: 'hidden' } : undefined}
600
359
  >
601
- {/* Snapshot layer — single image */}
602
- {hasSnap && (
603
- <img
604
- src={snapshot}
605
- className={styles.snapshotImage}
606
- alt={`${prototypeTitle} snapshot`}
607
- draggable={false}
608
- onError={() => {
609
- console.log(`[embed:${widgetId}] snapshot img onError: ${snapshot?.slice(0, 60)}`)
610
- setBrokenSnaps(prev => ({ ...prev, [snapshot]: true }))
611
- // Clear the broken snapshot from widget data so we stop trying
612
- onUpdate?.({ snapshot: '' })
613
- }}
614
- />
615
- )}
616
-
617
- {/* Iframe layer — on top, transparent until loaded */}
618
- {showIframe && (
619
- <iframe
620
- ref={iframeRef}
621
- src={iframeSrc}
622
- className={styles.iframe}
623
- style={{
624
- width: width / scale,
625
- height: height / scale,
626
- transform: `scale(${scale})`,
627
- transformOrigin: '0 0',
628
- transition: 'opacity 150ms ease',
629
- ...(iframeLoaded ? {} : { opacity: 0 }),
630
- }}
631
- onLoad={() => { console.log(`[embed:${widgetId}] iframe onLoad`); setIframeLoaded(true) }}
632
- title={`${prototypeTitle} prototype`}
633
- sandbox="allow-same-origin allow-scripts allow-forms allow-popups"
634
- />
635
- )}
636
-
637
- {/* Placeholder — only when no snapshot and no iframe */}
638
- {!hasSnap && !showIframe && (
639
- <div className={styles.placeholder}>
640
- <CollageFrameIcon size={36} />
641
- <span className={styles.placeholderLabel}>{`${prototypeTitle} prototype`}</span>
642
- </div>
643
- )}
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
+ />
644
373
  </div>
645
-
646
374
  {!interactive && !expanded && (
647
375
  <div
648
376
  className={overlayStyles.interactOverlay}
@@ -659,52 +387,20 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
659
387
  enterInteractive()
660
388
  }
661
389
  }}
662
- aria-label={hasSnap ? 'Click to interact with prototype' : 'Click to open prototype'}
390
+ aria-label="Click to interact with prototype"
663
391
  >
664
- <span className={overlayStyles.interactHint}>{hasSnap ? 'Click to interact' : 'Click to open'}</span>
392
+ <span className={overlayStyles.interactHint}>Click to interact</span>
665
393
  </div>
666
394
  )}
667
395
  </>
668
396
  ) : (
669
- <div
670
- className={styles.empty}
671
- onDoubleClick={() => setEditing(true)}
672
- role="button"
673
- tabIndex={0}
674
- onKeyDown={(e) => { if (e.key === 'Enter') setEditing(true) }}
675
- >
676
- <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>
677
400
  </div>
678
401
  )}
679
402
  </div>
680
- {resizable && (
681
- <div
682
- className={styles.resizeHandle}
683
- onMouseDown={(e) => {
684
- e.stopPropagation()
685
- e.preventDefault()
686
- const startX = e.clientX
687
- const startY = e.clientY
688
- const startW = width
689
- const startH = height
690
- function onMove(ev) {
691
- const newW = Math.max(200, startW + ev.clientX - startX)
692
- const newH = Math.max(150, startH + ev.clientY - startY)
693
- onUpdate?.({ width: newW, height: newH })
694
- }
695
- function onUp() {
696
- document.removeEventListener('mousemove', onMove)
697
- document.removeEventListener('mouseup', onUp)
698
- // Recapture snapshot after resize (debounced)
699
- clearTimeout(resizeTimerRef.current)
700
- resizeTimerRef.current = setTimeout(() => requestCapture(), 1500)
701
- }
702
- document.addEventListener('mousemove', onMove)
703
- document.addEventListener('mouseup', onUp)
704
- }}
705
- onPointerDown={(e) => e.stopPropagation()}
706
- />
707
- )}
403
+ {resizable && <ResizeHandle width={width} height={height} onResize={handleResize} />}
708
404
  </WidgetWrapper>
709
405
  {createPortal(
710
406
  <div
@@ -712,26 +408,11 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
712
408
  style={expanded ? undefined : { display: 'none' }}
713
409
  onClick={() => setExpanded(false)}
714
410
  onPointerDown={(e) => e.stopPropagation()}
715
- onKeyDown={(e) => {
716
- e.stopPropagation()
717
- if (e.key === 'Escape') setExpanded(false)
718
- }}
411
+ onKeyDown={(e) => e.stopPropagation()}
719
412
  onWheel={(e) => e.stopPropagation()}
720
- tabIndex={-1}
721
- ref={(el) => { if (el && expanded) el.focus() }}
722
413
  >
723
- <div
724
- ref={modalContainerRef}
725
- className={styles.expandContainer}
726
- onClick={(e) => e.stopPropagation()}
727
- >
728
- {/* iframe is reparented here via useEffect */}
729
- <button
730
- className={styles.expandClose}
731
- onClick={() => setExpanded(false)}
732
- aria-label="Close expanded view"
733
- autoFocus
734
- >✕</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>
735
416
  </div>
736
417
  </div>,
737
418
  document.body