@dfosco/storyboard-react 4.0.0-beta.9 → 4.0.0

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 (75) hide show
  1. package/package.json +6 -3
  2. package/src/AuthModal/AuthModal.jsx +134 -0
  3. package/src/AuthModal/AuthModal.module.css +221 -0
  4. package/src/BranchBar/BranchBar.jsx +56 -0
  5. package/src/BranchBar/BranchBar.module.css +230 -0
  6. package/src/BranchBar/useBranches.js +79 -0
  7. package/src/CommandPalette/CommandPalette.jsx +936 -0
  8. package/src/CommandPalette/CreateDialog.jsx +219 -0
  9. package/src/CommandPalette/command-palette.css +111 -0
  10. package/src/Icon.jsx +180 -0
  11. package/src/Viewfinder.jsx +1104 -57
  12. package/src/Viewfinder.module.css +1107 -149
  13. package/src/canvas/CanvasControls.jsx +51 -2
  14. package/src/canvas/CanvasControls.module.css +31 -0
  15. package/src/canvas/CanvasPage.bridge.test.jsx +142 -19
  16. package/src/canvas/CanvasPage.dragdrop.test.jsx +346 -0
  17. package/src/canvas/CanvasPage.jsx +807 -251
  18. package/src/canvas/CanvasPage.module.css +98 -50
  19. package/src/canvas/CanvasPage.multiselect.test.jsx +13 -11
  20. package/src/canvas/CanvasToolbar.jsx +2 -2
  21. package/src/canvas/MarqueeOverlay.jsx +20 -0
  22. package/src/canvas/PageSelector.jsx +239 -0
  23. package/src/canvas/PageSelector.module.css +165 -0
  24. package/src/canvas/PageSelector.test.jsx +104 -0
  25. package/src/canvas/canvasApi.js +22 -8
  26. package/src/canvas/canvasTheme.js +96 -52
  27. package/src/canvas/componentIsolate.jsx +33 -7
  28. package/src/canvas/useCanvas.js +9 -8
  29. package/src/canvas/useCanvas.test.js +4 -4
  30. package/src/canvas/useMarqueeSelect.js +187 -0
  31. package/src/canvas/useMarqueeSelect.test.js +78 -0
  32. package/src/canvas/widgets/CodePenEmbed.jsx +292 -0
  33. package/src/canvas/widgets/CodePenEmbed.module.css +161 -0
  34. package/src/canvas/widgets/ComponentWidget.jsx +42 -10
  35. package/src/canvas/widgets/ComponentWidget.module.css +6 -5
  36. package/src/canvas/widgets/FigmaEmbed.jsx +110 -24
  37. package/src/canvas/widgets/FigmaEmbed.module.css +21 -7
  38. package/src/canvas/widgets/LinkPreview.jsx +297 -11
  39. package/src/canvas/widgets/LinkPreview.module.css +386 -18
  40. package/src/canvas/widgets/LinkPreview.test.jsx +193 -0
  41. package/src/canvas/widgets/MarkdownBlock.jsx +86 -5
  42. package/src/canvas/widgets/MarkdownBlock.module.css +64 -15
  43. package/src/canvas/widgets/PrototypeEmbed.jsx +96 -145
  44. package/src/canvas/widgets/PrototypeEmbed.module.css +74 -4
  45. package/src/canvas/widgets/StickyNote.module.css +5 -0
  46. package/src/canvas/widgets/StickyNote.test.jsx +9 -9
  47. package/src/canvas/widgets/StoryWidget.jsx +277 -0
  48. package/src/canvas/widgets/StoryWidget.module.css +211 -0
  49. package/src/canvas/widgets/WidgetChrome.jsx +76 -20
  50. package/src/canvas/widgets/WidgetChrome.module.css +2 -6
  51. package/src/canvas/widgets/WidgetWrapper.module.css +2 -0
  52. package/src/canvas/widgets/codepenUrl.js +75 -0
  53. package/src/canvas/widgets/codepenUrl.test.js +76 -0
  54. package/src/canvas/widgets/embedInteraction.test.jsx +235 -0
  55. package/src/canvas/widgets/embedOverlay.module.css +35 -0
  56. package/src/canvas/widgets/embedTheme.js +138 -39
  57. package/src/canvas/widgets/githubUrl.js +82 -0
  58. package/src/canvas/widgets/githubUrl.test.js +74 -0
  59. package/src/canvas/widgets/iframeDevLogs.js +49 -0
  60. package/src/canvas/widgets/iframeDevLogs.test.jsx +81 -0
  61. package/src/canvas/widgets/index.js +4 -0
  62. package/src/canvas/widgets/pasteRules.js +295 -0
  63. package/src/canvas/widgets/pasteRules.test.js +474 -0
  64. package/src/canvas/widgets/snapshotDisplay.test.jsx +259 -0
  65. package/src/canvas/widgets/widgetConfig.js +16 -5
  66. package/src/canvas/widgets/widgetConfig.test.js +34 -12
  67. package/src/context.jsx +145 -16
  68. package/src/hooks/useSceneData.js +4 -2
  69. package/src/hooks/useThemeState.js +61 -0
  70. package/src/hooks/useThemeState.test.js +66 -0
  71. package/src/index.js +10 -0
  72. package/src/story/StoryPage.jsx +117 -0
  73. package/src/story/StoryPage.module.css +18 -0
  74. package/src/vite/data-plugin.js +348 -66
  75. package/src/vite/data-plugin.test.js +405 -5
@@ -2,9 +2,22 @@ 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
7
  import { getEmbedChromeVars } from './embedTheme.js'
8
+ import { useIframeDevLogs } from './iframeDevLogs.js'
7
9
  import styles from './PrototypeEmbed.module.css'
10
+ import overlayStyles from './embedOverlay.module.css'
11
+
12
+ function CollageFrameIcon({ size = 36 }) {
13
+ return (
14
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
15
+ <path d="M19.4 20H4.6C4.26863 20 4 19.7314 4 19.4V4.6C4 4.26863 4.26863 4 4.6 4H19.4C19.7314 4 20 4.26863 20 4.6V19.4C20 19.7314 19.7314 20 19.4 20Z" />
16
+ <path d="M11 12V4" />
17
+ <path d="M4 12H20" />
18
+ </svg>
19
+ )
20
+ }
8
21
 
9
22
  function formatName(name) {
10
23
  return name
@@ -18,18 +31,17 @@ function resolveCanvasThemeFromStorage() {
18
31
  try {
19
32
  const rawSync = localStorage.getItem('sb-theme-sync')
20
33
  if (rawSync) sync = { ...sync, ...JSON.parse(rawSync) }
21
- } catch {
22
- // Ignore malformed sync settings
23
- }
34
+ } catch { /* */ }
24
35
  if (!sync.canvas) return 'light'
25
36
  const attrTheme = document.documentElement.getAttribute('data-sb-canvas-theme')
26
37
  if (attrTheme) return attrTheme
27
38
  const stored = localStorage.getItem('sb-color-scheme') || 'system'
28
39
  if (stored !== 'system') return stored
29
- return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
40
+ return typeof window.matchMedia === 'function' &&
41
+ window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
30
42
  }
31
43
 
32
- export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }, ref) {
44
+ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdate, resizable }, ref) {
33
45
  const src = readProp(props, 'src', prototypeEmbedSchema)
34
46
  const width = readProp(props, 'width', prototypeEmbedSchema)
35
47
  const height = readProp(props, 'height', prototypeEmbedSchema)
@@ -41,7 +53,6 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
41
53
  const rawSrc = useMemo(() => {
42
54
  if (!src) return ''
43
55
  if (/^https?:\/\//.test(src)) return src
44
- // Strip stale branch prefixes from stored src (e.g. /branch--old-feat/Page)
45
56
  const cleaned = src.replace(/^\/branch--[^/]+/, '')
46
57
  if (baseSegment && cleaned.startsWith(basePath)) return cleaned
47
58
  if (baseSegment && cleaned.startsWith(baseSegment)) return `/${cleaned}`
@@ -49,6 +60,7 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
49
60
  }, [src, basePath, baseSegment])
50
61
 
51
62
  const scale = zoom / 100
63
+ const isExternal = /^https?:\/\//.test(src || '')
52
64
 
53
65
  const [editing, setEditing] = useState(false)
54
66
  const [interactive, setInteractive] = useState(false)
@@ -64,30 +76,22 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
64
76
 
65
77
  const iframeSrc = useMemo(() => {
66
78
  if (!rawSrc) return ''
67
- // External URLs are embedded as-is — storyboard query params only apply to local prototypes
68
79
  if (/^https?:\/\//.test(rawSrc)) return rawSrc
69
80
  const hashIdx = rawSrc.indexOf('#')
70
81
  const base = hashIdx >= 0 ? rawSrc.slice(0, hashIdx) : rawSrc
71
82
  const hash = hashIdx >= 0 ? rawSrc.slice(hashIdx) : ''
72
83
  const sep = base.includes('?') ? '&' : '?'
73
- return `${base}${sep}_sb_embed&_sb_theme_target=prototype&_sb_canvas_theme=${canvasTheme}${hash}`
84
+ return `${base}${sep}_sb_embed&_sb_hide_branch_bar&_sb_theme_target=prototype&_sb_canvas_theme=${canvasTheme}${hash}`
74
85
  }, [rawSrc, canvasTheme])
75
86
 
76
- // Build prototype index for the picker
77
87
  const prototypeIndex = useMemo(() => {
78
- try {
79
- return buildPrototypeIndex()
80
- } catch {
81
- return { folders: [], prototypes: [], globalFlows: [], sorted: { title: { prototypes: [], folders: [] } } }
82
- }
88
+ try { return buildPrototypeIndex() }
89
+ catch { return { folders: [], prototypes: [], globalFlows: [], sorted: { title: { prototypes: [], folders: [] } } } }
83
90
  }, [])
84
91
 
85
- // Build grouped picker entries from the prototype index
86
92
  const pickerGroups = useMemo(() => {
87
93
  const groups = []
88
94
  const idx = prototypeIndex
89
-
90
- // Collect all prototypes (from folders first, then ungrouped)
91
95
  const allProtos = []
92
96
  for (const folder of (idx.sorted?.title?.folders || idx.folders || [])) {
93
97
  for (const proto of folder.prototypes || []) {
@@ -97,45 +101,22 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
97
101
  for (const proto of (idx.sorted?.title?.prototypes || idx.prototypes || [])) {
98
102
  if (!proto.isExternal) allProtos.push(proto)
99
103
  }
100
-
101
104
  for (const proto of allProtos) {
102
105
  if (proto.hideFlows && proto.flows.length === 1) {
103
- groups.push({
104
- label: proto.name,
105
- items: [{ name: proto.name, route: proto.flows[0].route }],
106
- })
106
+ groups.push({ label: proto.name, items: [{ name: proto.name, route: proto.flows[0].route }] })
107
107
  } else if (proto.flows.length > 0) {
108
- groups.push({
109
- label: proto.name,
110
- items: proto.flows.map((f) => ({
111
- name: f.meta?.title || formatName(f.name),
112
- route: f.route,
113
- })),
114
- })
108
+ groups.push({ label: proto.name, items: proto.flows.map((f) => ({ name: f.meta?.title || formatName(f.name), route: f.route })) })
115
109
  } else {
116
- groups.push({
117
- label: proto.name,
118
- items: [{ name: proto.name, route: `/${proto.dirName}` }],
119
- })
110
+ groups.push({ label: proto.name, items: [{ name: proto.name, route: `/${proto.dirName}` }] })
120
111
  }
121
112
  }
122
-
123
- // Global flows
124
113
  const gf = idx.globalFlows || []
125
114
  if (gf.length > 0) {
126
- groups.push({
127
- label: 'Other flows',
128
- items: gf.map((f) => ({
129
- name: f.meta?.title || formatName(f.name),
130
- route: f.route,
131
- })),
132
- })
115
+ groups.push({ label: 'Other flows', items: gf.map((f) => ({ name: f.meta?.title || formatName(f.name), route: f.route })) })
133
116
  }
134
-
135
117
  return groups
136
118
  }, [prototypeIndex])
137
119
 
138
- // Filter groups by search text
139
120
  const filteredGroups = useMemo(() => {
140
121
  if (!filter) return pickerGroups
141
122
  const q = filter.toLowerCase()
@@ -152,8 +133,30 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
152
133
  .filter(Boolean)
153
134
  }, [pickerGroups, filter])
154
135
 
136
+ const prototypeTitle = useMemo(() => {
137
+ if (!src) return label || 'Prototype'
138
+ const cleanSrc = src.replace(/^\/branch--[^/]+/, '')
139
+ for (const group of pickerGroups) {
140
+ for (const item of group.items) {
141
+ const cleanRoute = item.route.replace(/^\/branch--[^/]+/, '')
142
+ if (cleanRoute === cleanSrc) {
143
+ // If the flow name matches the group name, just show the name
144
+ if (item.name === group.label) return group.label
145
+ return `${group.label} · ${item.name}`
146
+ }
147
+ }
148
+ }
149
+ return label || 'Prototype'
150
+ }, [src, label, pickerGroups])
151
+
155
152
  const hasPicker = pickerGroups.length > 0
156
153
 
154
+ useIframeDevLogs({
155
+ widget: 'PrototypeEmbed',
156
+ loaded: Boolean(iframeSrc && interactive),
157
+ src: iframeSrc,
158
+ })
159
+
157
160
  useEffect(() => {
158
161
  if (editing && hasPicker && filterRef.current) {
159
162
  filterRef.current.focus()
@@ -165,15 +168,17 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
165
168
 
166
169
  // Exit interactive mode when clicking outside the embed
167
170
  useEffect(() => {
168
- if (!interactive) return
171
+ if (!interactive || expanded) return
169
172
  function handlePointerDown(e) {
170
173
  if (embedRef.current && !embedRef.current.contains(e.target)) {
174
+ const chromeEl = e.target.closest(`[data-widget-id="${widgetId}"]`)
175
+ if (chromeEl) return
171
176
  setInteractive(false)
172
177
  }
173
178
  }
174
179
  document.addEventListener('pointerdown', handlePointerDown)
175
180
  return () => document.removeEventListener('pointerdown', handlePointerDown)
176
- }, [interactive])
181
+ }, [interactive, expanded, widgetId])
177
182
 
178
183
  useEffect(() => {
179
184
  function readToolbarTheme() {
@@ -197,25 +202,18 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
197
202
  return () => document.removeEventListener('keydown', handleKeyDown, true)
198
203
  }, [expanded])
199
204
 
200
- // Reparent iframe DOM node between inline container and modal.
201
- // Uses moveBefore() (Chrome 133+) which preserves the iframe's
202
- // browsing context — no reload. Falls back to appendChild which
203
- // will reload but still works functionally.
205
+ // Reparent iframe between inline and modal
204
206
  useEffect(() => {
205
207
  const iframe = iframeRef.current
206
208
  if (!iframe) return
207
-
208
209
  if (expanded && modalContainerRef.current) {
209
210
  iframe._savedClassName = iframe.className
210
211
  iframe._savedStyle = iframe.getAttribute('style') || ''
211
212
  iframe.className = styles.expandIframe
212
213
  iframe.removeAttribute('style')
213
214
  const target = modalContainerRef.current
214
- if (target.moveBefore) {
215
- target.moveBefore(iframe, target.firstChild)
216
- } else {
217
- target.prepend(iframe)
218
- }
215
+ if (target.moveBefore) target.moveBefore(iframe, target.firstChild)
216
+ else target.prepend(iframe)
219
217
  } else if (!expanded && inlineContainerRef.current) {
220
218
  if (iframe._savedClassName !== undefined) {
221
219
  iframe.className = iframe._savedClassName
@@ -224,11 +222,8 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
224
222
  delete iframe._savedStyle
225
223
  }
226
224
  const target = inlineContainerRef.current
227
- if (target.moveBefore) {
228
- target.moveBefore(iframe, null)
229
- } else {
230
- target.appendChild(iframe)
231
- }
225
+ if (target.moveBefore) target.moveBefore(iframe, null)
226
+ else target.appendChild(iframe)
232
227
  }
233
228
  }, [expanded])
234
229
 
@@ -251,7 +246,6 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
251
246
 
252
247
  const enterInteractive = useCallback(() => setInteractive(true), [])
253
248
 
254
- // Expose imperative action handlers for WidgetChrome
255
249
  useImperativeHandle(ref, () => ({
256
250
  handleAction(actionId) {
257
251
  if (actionId === 'edit') {
@@ -289,6 +283,10 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
289
283
  setFilter('')
290
284
  }
291
285
 
286
+ const handleResize = useCallback((w, h) => {
287
+ onUpdate?.({ width: w, height: h })
288
+ }, [onUpdate])
289
+
292
290
  return (
293
291
  <>
294
292
  <WidgetWrapper>
@@ -297,6 +295,10 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
297
295
  className={styles.embed}
298
296
  style={{ width, height, ...chromeVars }}
299
297
  >
298
+ <div className={styles.header}>
299
+ <span className={styles.headerIcon}><CollageFrameIcon size={16} /></span>
300
+ <span className={styles.headerTitle}>{prototypeTitle}</span>
301
+ </div>
300
302
  {editing ? (
301
303
  <div
302
304
  className={styles.pickerPanel}
@@ -307,12 +309,7 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
307
309
  <>
308
310
  <div className={styles.pickerHeader}>
309
311
  <span className={styles.urlLabel}>Pick a prototype</span>
310
- <button
311
- type="button"
312
- className={styles.urlCancel}
313
- onClick={handleCancelEdit}
314
- aria-label="Cancel"
315
- >✕</button>
312
+ <button type="button" className={styles.urlCancel} onClick={handleCancelEdit} aria-label="Cancel">✕</button>
316
313
  </div>
317
314
  <input
318
315
  ref={filterRef}
@@ -327,23 +324,14 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
327
324
  {filteredGroups.map((group) => (
328
325
  <div key={group.label} className={styles.pickerGroup}>
329
326
  {group.items.length === 1 && group.items[0].name === group.label ? (
330
- <button
331
- className={styles.pickerItem}
332
- role="option"
333
- onClick={() => handlePickRoute(group.items[0].route)}
334
- >
327
+ <button className={styles.pickerItem} role="option" onClick={() => handlePickRoute(group.items[0].route)}>
335
328
  {group.label}
336
329
  </button>
337
330
  ) : (
338
331
  <>
339
332
  <div className={styles.pickerGroupLabel}>{group.label}</div>
340
333
  {group.items.map((item) => (
341
- <button
342
- key={item.route}
343
- className={styles.pickerItem}
344
- role="option"
345
- onClick={() => handlePickRoute(item.route)}
346
- >
334
+ <button key={item.route} className={styles.pickerItem} role="option" onClick={() => handlePickRoute(item.route)}>
347
335
  {item.name}
348
336
  </button>
349
337
  ))}
@@ -351,30 +339,17 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
351
339
  )}
352
340
  </div>
353
341
  ))}
354
- {filteredGroups.length === 0 && (
355
- <div className={styles.pickerEmpty}>No matches</div>
356
- )}
342
+ {filteredGroups.length === 0 && <div className={styles.pickerEmpty}>No matches</div>}
357
343
  </div>
358
344
  <div className={styles.pickerDivider} />
359
345
  </>
360
346
  )}
361
347
  <form className={styles.customUrlSection} onSubmit={handleSubmit}>
362
- <label className={styles.urlLabel}>
363
- {hasPicker ? 'Or enter a custom URL' : 'Prototype URL path'}
364
- </label>
365
- <input
366
- ref={inputRef}
367
- className={styles.urlInput}
368
- type="text"
369
- defaultValue={src}
370
- placeholder="/MyPrototype/page"
371
- onKeyDown={(e) => { if (e.key === 'Escape') handleCancelEdit() }}
372
- />
348
+ <label className={styles.urlLabel}>{hasPicker ? 'Or enter a custom URL' : 'Prototype URL path'}</label>
349
+ <input ref={inputRef} className={styles.urlInput} type="text" defaultValue={src} placeholder="/MyPrototype/page" onKeyDown={(e) => { if (e.key === 'Escape') handleCancelEdit() }} />
373
350
  <div className={styles.urlActions}>
374
351
  <button type="submit" className={styles.urlSave}>Save</button>
375
- {!hasPicker && (
376
- <button type="button" className={styles.urlCancel} onClick={handleCancelEdit}>Cancel</button>
377
- )}
352
+ {!hasPicker && <button type="button" className={styles.urlCancel} onClick={handleCancelEdit}>Cancel</button>}
378
353
  </div>
379
354
  </form>
380
355
  </div>
@@ -395,54 +370,40 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
395
370
  transform: `scale(${scale})`,
396
371
  transformOrigin: '0 0',
397
372
  }}
398
- title={label || 'Prototype embed'}
373
+ title={`${prototypeTitle} prototype`}
399
374
  sandbox="allow-same-origin allow-scripts allow-forms allow-popups"
400
375
  />
401
376
  </div>
402
377
  {!interactive && !expanded && (
403
378
  <div
404
- className={styles.dragOverlay}
405
- onDoubleClick={enterInteractive}
406
- />
379
+ className={overlayStyles.interactOverlay}
380
+ onClick={(e) => {
381
+ if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
382
+ enterInteractive()
383
+ }}
384
+ role="button"
385
+ tabIndex={0}
386
+ onKeyDown={(e) => {
387
+ if (e.key === 'Enter' || e.key === ' ') {
388
+ e.preventDefault()
389
+ e.stopPropagation()
390
+ enterInteractive()
391
+ }
392
+ }}
393
+ aria-label="Click to interact with prototype"
394
+ >
395
+ <span className={overlayStyles.interactHint}>Click to interact</span>
396
+ </div>
407
397
  )}
408
398
  </>
409
399
  ) : (
410
- <div
411
- className={styles.empty}
412
- onDoubleClick={() => setEditing(true)}
413
- role="button"
414
- tabIndex={0}
415
- onKeyDown={(e) => { if (e.key === 'Enter') setEditing(true) }}
416
- >
417
- <p>Double-click to set prototype URL</p>
400
+ <div className={styles.empty} onClick={() => onUpdate && setEditing(true)} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter') setEditing(true) }}>
401
+ <CollageFrameIcon size={36} />
402
+ <p>Click to set prototype URL</p>
418
403
  </div>
419
404
  )}
420
405
  </div>
421
- {resizable && (
422
- <div
423
- className={styles.resizeHandle}
424
- onMouseDown={(e) => {
425
- e.stopPropagation()
426
- e.preventDefault()
427
- const startX = e.clientX
428
- const startY = e.clientY
429
- const startW = width
430
- const startH = height
431
- function onMove(ev) {
432
- const newW = Math.max(200, startW + ev.clientX - startX)
433
- const newH = Math.max(150, startH + ev.clientY - startY)
434
- onUpdate?.({ width: newW, height: newH })
435
- }
436
- function onUp() {
437
- document.removeEventListener('mousemove', onMove)
438
- document.removeEventListener('mouseup', onUp)
439
- }
440
- document.addEventListener('mousemove', onMove)
441
- document.addEventListener('mouseup', onUp)
442
- }}
443
- onPointerDown={(e) => e.stopPropagation()}
444
- />
445
- )}
406
+ {resizable && <ResizeHandle targetRef={embedRef} width={width} height={height} onResize={handleResize} />}
446
407
  </WidgetWrapper>
447
408
  {createPortal(
448
409
  <div
@@ -453,18 +414,8 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
453
414
  onKeyDown={(e) => e.stopPropagation()}
454
415
  onWheel={(e) => e.stopPropagation()}
455
416
  >
456
- <div
457
- ref={modalContainerRef}
458
- className={styles.expandContainer}
459
- onClick={(e) => e.stopPropagation()}
460
- >
461
- {/* iframe is reparented here via useEffect */}
462
- <button
463
- className={styles.expandClose}
464
- onClick={() => setExpanded(false)}
465
- aria-label="Close expanded view"
466
- autoFocus
467
- >✕</button>
417
+ <div ref={modalContainerRef} className={styles.expandContainer} onClick={(e) => e.stopPropagation()}>
418
+ <button className={styles.expandClose} onClick={() => setExpanded(false)} aria-label="Close expanded view" autoFocus>✕</button>
468
419
  </div>
469
420
  </div>,
470
421
  document.body
@@ -7,22 +7,92 @@
7
7
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
8
8
  }
9
9
 
10
+ .header {
11
+ display: flex;
12
+ align-items: center;
13
+ gap: 6px;
14
+ padding: 10px 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
+ .headerIcon {
27
+ display: inline-flex;
28
+ flex-shrink: 0;
29
+ }
30
+
31
+ .headerTitle {
32
+ overflow: hidden;
33
+ text-overflow: ellipsis;
34
+ }
35
+
10
36
  .iframeContainer {
37
+ position: relative;
11
38
  width: 100%;
12
- height: 100%;
39
+ height: calc(100% - 37px);
13
40
  overflow: hidden;
14
41
  }
15
42
 
43
+ .placeholder {
44
+ position: absolute;
45
+ inset: 0;
46
+ display: flex;
47
+ flex-direction: column;
48
+ align-items: center;
49
+ justify-content: center;
50
+ gap: 8px;
51
+ color: var(--fgColor-muted, #656d76);
52
+ text-align: center;
53
+ }
54
+
55
+ .placeholderIcon {
56
+ width: 36px;
57
+ height: 36px;
58
+ }
59
+
60
+ .placeholderLabel {
61
+ font-size: 13px;
62
+ font-weight: 500;
63
+ }
64
+
65
+ .spinner {
66
+ width: 24px;
67
+ height: 24px;
68
+ border: 3px solid var(--borderColor-muted, #d0d7de);
69
+ border-top-color: var(--fgColor-accent, #2f81f7);
70
+ border-radius: 50%;
71
+ animation: spin 0.8s linear infinite;
72
+ }
73
+
74
+ @keyframes spin {
75
+ from { transform: rotate(0deg); }
76
+ to { transform: rotate(360deg); }
77
+ }
78
+
16
79
  .iframe {
17
80
  border: none;
18
81
  display: block;
82
+ position: relative;
83
+ z-index: 1;
19
84
  }
20
85
 
21
- .dragOverlay {
86
+ .snapshotImage {
22
87
  position: absolute;
23
88
  inset: 0;
24
- z-index: 1;
25
- cursor: grab;
89
+ width: 100%;
90
+ height: 100%;
91
+ object-fit: cover;
92
+ object-position: top left;
93
+ display: block;
94
+ pointer-events: none;
95
+ transition: opacity 150ms ease;
26
96
  }
27
97
 
28
98
  .empty {
@@ -20,6 +20,11 @@
20
20
  box-shadow: 2px 3px 10px rgba(0, 0, 0, 0.35);
21
21
  }
22
22
 
23
+ /* Hide own border when parent widget slot is selected (avoid double focus ring) */
24
+ :global([data-widget-selected]) .sticky {
25
+ border-color: transparent;
26
+ }
27
+
23
28
  .text {
24
29
  padding: 16px 20px;
25
30
  margin: 0;
@@ -13,16 +13,16 @@ describe('stickyNoteSchema', () => {
13
13
  )
14
14
  })
15
15
 
16
- it('does not include default values for width/height so new widgets size naturally', () => {
16
+ it('includes default values for width/height from config', () => {
17
17
  const defaults = getDefaults(stickyNoteSchema)
18
- expect(defaults).not.toHaveProperty('width')
19
- expect(defaults).not.toHaveProperty('height')
18
+ expect(defaults).toHaveProperty('width', 270)
19
+ expect(defaults).toHaveProperty('height', 170)
20
20
  })
21
21
 
22
- it('returns null when width/height are not saved in props', () => {
22
+ it('returns default value when width/height are not saved in props', () => {
23
23
  const props = { text: 'hello', color: 'yellow' }
24
- expect(readProp(props, 'width', stickyNoteSchema)).toBeNull()
25
- expect(readProp(props, 'height', stickyNoteSchema)).toBeNull()
24
+ expect(readProp(props, 'width', stickyNoteSchema)).toBe(270)
25
+ expect(readProp(props, 'height', stickyNoteSchema)).toBe(170)
26
26
  })
27
27
 
28
28
  it('returns saved width/height when present in props', () => {
@@ -33,11 +33,11 @@ describe('stickyNoteSchema', () => {
33
33
  })
34
34
 
35
35
  describe('StickyNote', () => {
36
- it('renders without explicit dimensions when width/height are not saved', () => {
36
+ it('applies default dimensions as inline styles when not saved in props', () => {
37
37
  const { container } = render(<StickyNote props={{ text: 'Hi' }} onUpdate={vi.fn()} />)
38
38
  const sticky = container.querySelector('article')
39
- expect(sticky.style.width).toBe('')
40
- expect(sticky.style.height).toBe('')
39
+ expect(sticky.style.width).toBe('270px')
40
+ expect(sticky.style.height).toBe('170px')
41
41
  })
42
42
 
43
43
  it('applies saved dimensions as inline styles', () => {