@dfosco/storyboard-react 4.2.0-beta.4 → 4.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/package.json +10 -11
  2. package/src/AuthModal/AuthModal.jsx +6 -8
  3. package/src/BranchBar/BranchBar.jsx +20 -6
  4. package/src/BranchBar/BranchBar.module.css +13 -4
  5. package/src/BranchBar/useBranches.js +20 -6
  6. package/src/BranchBar/useBranches.test.js +68 -0
  7. package/src/CommandPalette/CommandPalette.jsx +480 -187
  8. package/src/CommandPalette/command-palette.css +142 -78
  9. package/src/Icon.jsx +157 -58
  10. package/src/Viewfinder.jsx +562 -207
  11. package/src/Viewfinder.module.css +434 -93
  12. package/src/Workspace.jsx +7 -0
  13. package/src/canvas/CanvasPage.bridge.test.jsx +14 -6
  14. package/src/canvas/CanvasPage.dragdrop.test.jsx +11 -7
  15. package/src/canvas/CanvasPage.jsx +739 -219
  16. package/src/canvas/CanvasPage.module.css +13 -15
  17. package/src/canvas/CanvasPage.multiselect.test.jsx +17 -6
  18. package/src/canvas/ConnectorLayer.jsx +121 -165
  19. package/src/canvas/ConnectorLayer.module.css +69 -0
  20. package/src/canvas/PageSelector.test.jsx +15 -6
  21. package/src/canvas/canvasApi.js +68 -2
  22. package/src/canvas/canvasReloadGuard.test.js +1 -1
  23. package/src/canvas/connectorGeometry.js +132 -0
  24. package/src/canvas/hotPoolDevLogs.js +25 -0
  25. package/src/canvas/useCanvas.js +1 -1
  26. package/src/canvas/useMarqueeSelect.js +30 -4
  27. package/src/canvas/widgets/CodePenEmbed.jsx +1 -0
  28. package/src/canvas/widgets/ComponentSetWidget.jsx +199 -0
  29. package/src/canvas/widgets/ComponentSetWidget.module.css +89 -0
  30. package/src/canvas/widgets/ComponentWidget.jsx +1 -0
  31. package/src/canvas/widgets/CropOverlay.jsx +219 -0
  32. package/src/canvas/widgets/CropOverlay.module.css +118 -0
  33. package/src/canvas/widgets/ExpandedPane.jsx +474 -0
  34. package/src/canvas/widgets/ExpandedPane.module.css +179 -0
  35. package/src/canvas/widgets/ExpandedPane.test.jsx +240 -0
  36. package/src/canvas/widgets/ExpandedPaneTopBar.jsx +111 -0
  37. package/src/canvas/widgets/ExpandedPaneTopBar.module.css +59 -0
  38. package/src/canvas/widgets/ExpandedPaneTopBar.test.jsx +45 -0
  39. package/src/canvas/widgets/FigmaEmbed.jsx +62 -47
  40. package/src/canvas/widgets/FigmaEmbed.module.css +61 -0
  41. package/src/canvas/widgets/ImageWidget.jsx +130 -9
  42. package/src/canvas/widgets/ImageWidget.module.css +30 -0
  43. package/src/canvas/widgets/LinkPreview.jsx +113 -5
  44. package/src/canvas/widgets/LinkPreview.module.css +127 -0
  45. package/src/canvas/widgets/MarkdownBlock.jsx +167 -17
  46. package/src/canvas/widgets/MarkdownBlock.module.css +148 -0
  47. package/src/canvas/widgets/PromptWidget.jsx +414 -0
  48. package/src/canvas/widgets/PromptWidget.module.css +273 -0
  49. package/src/canvas/widgets/PrototypeEmbed.jsx +77 -39
  50. package/src/canvas/widgets/PrototypeEmbed.module.css +117 -0
  51. package/src/canvas/widgets/PrototypeEmbed.test.jsx +2 -2
  52. package/src/canvas/widgets/ResizeHandle.jsx +17 -6
  53. package/src/canvas/widgets/StoryWidget.jsx +73 -15
  54. package/src/canvas/widgets/TerminalReadWidget.jsx +146 -0
  55. package/src/canvas/widgets/TerminalReadWidget.module.css +94 -0
  56. package/src/canvas/widgets/TerminalWidget.jsx +445 -67
  57. package/src/canvas/widgets/TerminalWidget.module.css +271 -8
  58. package/src/canvas/widgets/TilesWidget.jsx +300 -0
  59. package/src/canvas/widgets/TilesWidget.module.css +133 -0
  60. package/src/canvas/widgets/WidgetChrome.jsx +74 -153
  61. package/src/canvas/widgets/WidgetChrome.module.css +30 -1
  62. package/src/canvas/widgets/embedInteraction.test.jsx +24 -26
  63. package/src/canvas/widgets/expandUtils.js +560 -0
  64. package/src/canvas/widgets/expandUtils.test.js +155 -0
  65. package/src/canvas/widgets/index.js +9 -0
  66. package/src/canvas/widgets/snapshotDisplay.test.jsx +23 -71
  67. package/src/canvas/widgets/tilePool.js +23 -0
  68. package/src/canvas/widgets/tiles/diagonal-bl.png +0 -0
  69. package/src/canvas/widgets/tiles/diagonal-br.png +0 -0
  70. package/src/canvas/widgets/tiles/diagonal-tl.png +0 -0
  71. package/src/canvas/widgets/tiles/leaf.png +0 -0
  72. package/src/canvas/widgets/tiles/quarter-tl.png +0 -0
  73. package/src/canvas/widgets/tiles/quarter-tr.png +0 -0
  74. package/src/canvas/widgets/tiles/solid-a.png +0 -0
  75. package/src/canvas/widgets/tiles/solid-b.png +0 -0
  76. package/src/canvas/widgets/widgetConfig.js +55 -4
  77. package/src/canvas/widgets/widgetIcons.jsx +190 -0
  78. package/src/canvas/widgets/widgetProps.js +1 -0
  79. package/src/context.jsx +48 -20
  80. package/src/hooks/useConfig.js +14 -0
  81. package/src/hooks/usePrototypeReloadGuard.js +64 -0
  82. package/src/hooks/useSceneData.js +1 -0
  83. package/src/hooks/useThemeState.test.js +1 -1
  84. package/src/index.js +8 -2
  85. package/src/story/ComponentSetPage.jsx +186 -0
  86. package/src/story/ComponentSetPage.module.css +121 -0
  87. package/src/story/StoryPage.jsx +32 -2
  88. package/src/vite/data-plugin.js +407 -67
  89. package/src/vite/data-plugin.test.js +1 -1
@@ -0,0 +1,132 @@
1
+ import { getConnectorDefaults } from './widgets/widgetConfig.js'
2
+
3
+ const connectorConfig = getConnectorDefaults()
4
+ const CONTROL_OFFSET = connectorConfig.controlOffset
5
+
6
+ /**
7
+ * Compute the anchor point on a widget's edge.
8
+ * Reads actual DOM dimensions for accuracy (widgets like markdown auto-size).
9
+ * Falls back to props/bounds/defaults if DOM element isn't found.
10
+ */
11
+ export function getAnchorPoint(widget, anchor) {
12
+ const x = widget.position?.x ?? 0
13
+ const y = widget.position?.y ?? 0
14
+
15
+ let w, h
16
+ const el = typeof document !== 'undefined' ? document.getElementById(widget.id) : null
17
+ if (el) {
18
+ const firstChild = el.querySelector('[data-widget-id]') || el.firstElementChild
19
+ if (firstChild) {
20
+ w = firstChild.offsetWidth
21
+ h = firstChild.offsetHeight
22
+ }
23
+ }
24
+ if (!w) w = widget.props?.width ?? widget.bounds?.width ?? 270
25
+ if (!h) h = widget.props?.height ?? widget.bounds?.height ?? 170
26
+
27
+ switch (anchor) {
28
+ case 'top': return { x: x + w / 2, y }
29
+ case 'bottom': return { x: x + w / 2, y: y + h }
30
+ case 'left': return { x, y: y + h / 2 }
31
+ case 'right': return { x: x + w, y: y + h / 2 }
32
+ default: return { x: x + w / 2, y: y + h / 2 }
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Compute the control point offset direction for an anchor.
38
+ */
39
+ export function getControlOffset(anchor) {
40
+ switch (anchor) {
41
+ case 'top': return { dx: 0, dy: -CONTROL_OFFSET }
42
+ case 'bottom': return { dx: 0, dy: CONTROL_OFFSET }
43
+ case 'left': return { dx: -CONTROL_OFFSET, dy: 0 }
44
+ case 'right': return { dx: CONTROL_OFFSET, dy: 0 }
45
+ default: return { dx: 0, dy: 0 }
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Build a cubic Bézier path string between two anchor points.
51
+ * When `freeEnd` is true, the end control point is computed from
52
+ * the direction vector (end→start) so the curve never bends in
53
+ * front of the cursor during drag.
54
+ */
55
+ export function buildPath(startPt, startAnchor, endPt, endAnchor, freeEnd = false) {
56
+ const c1 = getControlOffset(startAnchor)
57
+ let c2
58
+ if (freeEnd) {
59
+ const dx = startPt.x - endPt.x
60
+ const dy = startPt.y - endPt.y
61
+ const dist = Math.hypot(dx, dy) || 1
62
+ const scale = Math.min(CONTROL_OFFSET, dist * 0.4)
63
+ c2 = { dx: (dx / dist) * scale, dy: (dy / dist) * scale }
64
+ } else {
65
+ c2 = getControlOffset(endAnchor)
66
+ }
67
+ return `M ${startPt.x} ${startPt.y} C ${startPt.x + c1.dx} ${startPt.y + c1.dy}, ${endPt.x + c2.dx} ${endPt.y + c2.dy}, ${endPt.x} ${endPt.y}`
68
+ }
69
+
70
+ /**
71
+ * Evaluate a cubic Bézier curve at parameter t.
72
+ * B(t) = (1-t)³·P0 + 3(1-t)²t·P1 + 3(1-t)t²·P2 + t³·P3
73
+ */
74
+ function evalCubicBezier(p0, p1, p2, p3, t) {
75
+ const mt = 1 - t
76
+ const mt2 = mt * mt
77
+ const mt3 = mt2 * mt
78
+ const t2 = t * t
79
+ const t3 = t2 * t
80
+ return {
81
+ x: mt3 * p0.x + 3 * mt2 * t * p1.x + 3 * mt * t2 * p2.x + t3 * p3.x,
82
+ y: mt3 * p0.y + 3 * mt2 * t * p1.y + 3 * mt * t2 * p2.y + t3 * p3.y,
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Resolve a connector's Bézier control points from widget positions.
88
+ * Returns { p0, cp1, cp2, p3 } or null if either widget is missing.
89
+ */
90
+ export function getConnectorControlPoints(connector, widgetMap) {
91
+ const startWidget = widgetMap.get(connector.start?.widgetId)
92
+ const endWidget = widgetMap.get(connector.end?.widgetId)
93
+ if (!startWidget || !endWidget) return null
94
+
95
+ const p0 = getAnchorPoint(startWidget, connector.start.anchor)
96
+ const p3 = getAnchorPoint(endWidget, connector.end.anchor)
97
+ const c1 = getControlOffset(connector.start.anchor)
98
+ const c2 = getControlOffset(connector.end.anchor)
99
+
100
+ return {
101
+ p0,
102
+ cp1: { x: p0.x + c1.dx, y: p0.y + c1.dy },
103
+ cp2: { x: p3.x + c2.dx, y: p3.y + c2.dy },
104
+ p3,
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Test whether any point along a connector's Bézier path falls inside a rect.
110
+ * @param {Object} connector — { start: { widgetId, anchor }, end: { widgetId, anchor } }
111
+ * @param {Map} widgetMap — Map<widgetId, widget>
112
+ * @param {{ x, y, width, height }} rect — axis-aligned selection rectangle
113
+ * @param {number} numSamples — number of evenly-spaced points to test (default 24)
114
+ * @returns {boolean}
115
+ */
116
+ export function connectorIntersectsRect(connector, widgetMap, rect, numSamples = 24) {
117
+ const pts = getConnectorControlPoints(connector, widgetMap)
118
+ if (!pts) return false
119
+
120
+ const { p0, cp1, cp2, p3 } = pts
121
+ const rRight = rect.x + rect.width
122
+ const rBottom = rect.y + rect.height
123
+
124
+ for (let i = 0; i <= numSamples; i++) {
125
+ const t = i / numSamples
126
+ const pt = evalCubicBezier(p0, cp1, cp2, p3, t)
127
+ if (pt.x >= rect.x && pt.x <= rRight && pt.y >= rect.y && pt.y <= rBottom) {
128
+ return true
129
+ }
130
+ }
131
+ return false
132
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Hot Pool browser devlogs — listens for server HMR events and
3
+ * logs them to the browser console when DevTools "Dev logs" is on.
4
+ */
5
+
6
+ import { getFlag } from '@dfosco/storyboard-core'
7
+
8
+ let registered = false
9
+
10
+ function isDevLogsEnabled() {
11
+ return getFlag('dev-logs')
12
+ }
13
+
14
+ export function registerHotPoolDevLogs() {
15
+ if (registered) return
16
+ registered = true
17
+
18
+ if (!import.meta.hot) return
19
+
20
+ import.meta.hot.on('storyboard:hot-pool-log', (data) => {
21
+ if (!isDevLogsEnabled()) return
22
+ const poolTag = data.poolId ? `:${data.poolId}` : ''
23
+ console.log(`%c[hot-pool${poolTag}]%c ${data.message}`, 'color: #8b5cf6; font-weight: bold', 'color: inherit')
24
+ })
25
+ }
@@ -100,7 +100,7 @@ export function useCanvas(canvasId) {
100
100
  useEffect(() => {
101
101
  if (!import.meta.hot || !buildTimeCanvas) return
102
102
 
103
- const handleCanvasFileChanged = ({ data }) => {
103
+ const handleCanvasFileChanged = (data) => {
104
104
  const eventId = data?.canvasId || data?.name
105
105
  if (!data || eventId !== canvasId) return
106
106
  // Use metadata from the HMR event directly if available (faster)
@@ -1,4 +1,5 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'react'
2
+ import { connectorIntersectsRect } from './connectorGeometry.js'
2
3
 
3
4
  /**
4
5
  * Returns the bounding-box list for all widgets on the canvas.
@@ -45,6 +46,7 @@ function rectsIntersect(a, b) {
45
46
  * @param {React.RefObject} opts.zoomRef — ref holding current zoom (number 25-200)
46
47
  * @param {Function} opts.setSelectedWidgetIds — state setter for selected IDs (Set)
47
48
  * @param {Array} opts.widgets — current localWidgets array
49
+ * @param {Array} opts.connectors — current localConnectors array
48
50
  * @param {Array} opts.componentEntries — current componentEntries array
49
51
  * @param {Object} opts.fallbackSizes — WIDGET_FALLBACK_SIZES map
50
52
  * @param {boolean} opts.spaceHeld — whether the space key is pressed (panning)
@@ -57,6 +59,7 @@ export default function useMarqueeSelect({
57
59
  zoomRef,
58
60
  setSelectedWidgetIds,
59
61
  widgets,
62
+ connectors,
60
63
  componentEntries,
61
64
  fallbackSizes,
62
65
  spaceHeld,
@@ -93,6 +96,8 @@ export default function useMarqueeSelect({
93
96
  startCanvasY: canvasStart.y,
94
97
  startClientX: e.clientX,
95
98
  startClientY: e.clientY,
99
+ shiftKey: e.shiftKey,
100
+ altKey: e.altKey,
96
101
  }
97
102
 
98
103
  // Minimum drag distance before showing the marquee (avoids flicker on clicks)
@@ -141,11 +146,11 @@ export default function useMarqueeSelect({
141
146
 
142
147
  if (!ms) return
143
148
 
144
- // If the user barely moved, treat as a deselect click
149
+ // If the user barely moved, treat as a deselect click (unless shift held)
145
150
  const dx = ev.clientX - ms.startClientX
146
151
  const dy = ev.clientY - ms.startClientY
147
152
  if (Math.abs(dx) < MIN_DRAG && Math.abs(dy) < MIN_DRAG) {
148
- setSelectedWidgetIds(new Set())
153
+ if (!ms.shiftKey) setSelectedWidgetIds(new Set())
149
154
  return
150
155
  }
151
156
 
@@ -167,14 +172,35 @@ export default function useMarqueeSelect({
167
172
  }
168
173
  }
169
174
 
170
- setSelectedWidgetIds(selected)
175
+ // Option+marquee: also select widgets connected by intersected connectors
176
+ if (ms.altKey && connectors?.length) {
177
+ const widgetMap = new Map()
178
+ for (const w of (widgets ?? [])) widgetMap.set(w.id, w)
179
+ for (const conn of connectors) {
180
+ if (connectorIntersectsRect(conn, widgetMap, selRect)) {
181
+ if (conn.start?.widgetId) selected.add(conn.start.widgetId)
182
+ if (conn.end?.widgetId) selected.add(conn.end.widgetId)
183
+ }
184
+ }
185
+ }
186
+
187
+ // Shift+marquee merges with existing selection; plain marquee replaces it
188
+ if (ms.shiftKey) {
189
+ setSelectedWidgetIds((prev) => {
190
+ const merged = new Set(prev)
191
+ for (const id of selected) merged.add(id)
192
+ return merged
193
+ })
194
+ } else {
195
+ setSelectedWidgetIds(selected)
196
+ }
171
197
  }
172
198
 
173
199
  document.addEventListener('mousemove', handleMove)
174
200
  document.addEventListener('mouseup', handleUp)
175
201
  window.addEventListener('blur', handleCancel)
176
202
  cleanupRef.current = removeListeners
177
- }, [isLocalDev, spaceHeld, clientToCanvas, scrollRef, setSelectedWidgetIds, widgets, componentEntries, fallbackSizes])
203
+ }, [isLocalDev, spaceHeld, clientToCanvas, scrollRef, setSelectedWidgetIds, widgets, connectors, componentEntries, fallbackSizes])
178
204
 
179
205
  // Clean up listeners if component unmounts mid-drag
180
206
  useEffect(() => {
@@ -198,6 +198,7 @@ export default forwardRef(function CodePenEmbed({ props, onUpdate, resizable },
198
198
  title={`CodePen: ${headerTitle}`}
199
199
  allowFullScreen
200
200
  loading="lazy"
201
+ onLoad={(e) => e.target.blur()}
201
202
  />
202
203
  </div>
203
204
  ) : (
@@ -0,0 +1,199 @@
1
+ /**
2
+ * ComponentSetWidget — renders all exports from a story in a single iframe grid.
3
+ *
4
+ * Instead of N iframes (one per export), this widget loads one iframe pointing
5
+ * to the story's ComponentSetPage. Each export renders in a grid cell inside
6
+ * that single page. The user can select a cell (via label click) which updates
7
+ * `props.selected` — visible to connected agents.
8
+ *
9
+ * Props: { storyId, layout, selected, width, height }
10
+ */
11
+ import { forwardRef, useImperativeHandle, useRef, useCallback, useState, useEffect, useMemo } from 'react'
12
+ import { getStoryData } from '@dfosco/storyboard-core'
13
+ import Icon from '../../Icon.jsx'
14
+ import WidgetWrapper from './WidgetWrapper.jsx'
15
+ import ResizeHandle from './ResizeHandle.jsx'
16
+ import { useIframeDevLogs } from './iframeDevLogs.js'
17
+ import styles from './ComponentSetWidget.module.css'
18
+ import overlayStyles from './embedOverlay.module.css'
19
+
20
+ function GridIcon({ size = 16 }) {
21
+ return <Icon name="iconoir/view-grid" size={size} />
22
+ }
23
+
24
+ function resolveComponentSetUrl(storyId, layout, selected) {
25
+ const story = getStoryData(storyId)
26
+ if (!story?._route) return ''
27
+ const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
28
+ const params = new URLSearchParams()
29
+ params.set('_sb_embed', '')
30
+ params.set('_sb_hide_branch_bar', '')
31
+ params.set('_sb_component_set', '')
32
+ if (layout) params.set('layout', layout)
33
+ if (selected) params.set('selected', selected)
34
+ return `${base}${story._route}?${params}`
35
+ }
36
+
37
+ export default forwardRef(function ComponentSetWidget({ id: widgetId, props, onUpdate, resizable }, ref) {
38
+ const storyId = props?.storyId || ''
39
+ const layout = props?.layout || 'horizontal'
40
+ const selected = props?.selected || ''
41
+ const width = props?.width
42
+ const height = props?.height
43
+
44
+ const containerRef = useRef(null)
45
+ const iframeRef = useRef(null)
46
+ const [interactive, setInteractive] = useState(false)
47
+ const [storyIndexKey, setStoryIndexKey] = useState(0)
48
+
49
+ // Re-resolve when story index is live-patched
50
+ useEffect(() => {
51
+ const handler = () => setStoryIndexKey((k) => k + 1)
52
+ document.addEventListener('storyboard:story-index-changed', handler)
53
+ return () => document.removeEventListener('storyboard:story-index-changed', handler)
54
+ }, [])
55
+
56
+ const enterInteractive = useCallback(() => setInteractive(true), [])
57
+
58
+ // Exit interactive mode when clicking outside
59
+ useEffect(() => {
60
+ if (!interactive) return
61
+ function handlePointerDown(e) {
62
+ if (containerRef.current && !containerRef.current.contains(e.target)) {
63
+ const chromeEl = e.target.closest(`[data-widget-id="${widgetId}"]`)
64
+ if (chromeEl) return
65
+ setInteractive(false)
66
+ }
67
+ }
68
+ document.addEventListener('pointerdown', handlePointerDown)
69
+ return () => document.removeEventListener('pointerdown', handlePointerDown)
70
+ }, [interactive, widgetId])
71
+
72
+ // Listen for selection messages from the embedded ComponentSetPage
73
+ useEffect(() => {
74
+ function handleMessage(e) {
75
+ if (e.source !== iframeRef.current?.contentWindow) return
76
+ if (e.data?.type !== 'storyboard:component-set:select') return
77
+ const newSelected = e.data.exportName || ''
78
+ if (newSelected !== selected) {
79
+ onUpdate?.({ selected: newSelected })
80
+ }
81
+ }
82
+ window.addEventListener('message', handleMessage)
83
+ return () => window.removeEventListener('message', handleMessage)
84
+ }, [selected, onUpdate])
85
+
86
+ const handleResize = useCallback((w, h) => {
87
+ onUpdate?.({ width: w, height: h })
88
+ }, [onUpdate])
89
+
90
+ useImperativeHandle(ref, () => ({
91
+ handleAction(actionId) {
92
+ if (actionId === 'flip-layout') {
93
+ const next = layout === 'horizontal' ? 'vertical' : 'horizontal'
94
+ onUpdate?.({ layout: next })
95
+ return true
96
+ } else if (actionId === 'open-external') {
97
+ const story = getStoryData(storyId)
98
+ if (story?._route) {
99
+ const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
100
+ window.open(`${base}${story._route}`, '_blank', 'noopener')
101
+ }
102
+ return true
103
+ }
104
+ },
105
+ }), [storyId, layout, onUpdate])
106
+
107
+ const iframeSrc = useMemo(
108
+ () => resolveComponentSetUrl(storyId, layout, selected),
109
+ // storyIndexKey forces re-evaluation when HMR mutates the story index
110
+ // eslint-disable-next-line react-hooks/exhaustive-deps
111
+ [storyId, layout, selected, storyIndexKey],
112
+ )
113
+
114
+ useIframeDevLogs({
115
+ widget: 'ComponentSetWidget',
116
+ loaded: interactive && Boolean(iframeSrc),
117
+ src: iframeSrc,
118
+ })
119
+
120
+ const displayName = storyId || 'Component Set'
121
+
122
+ if (!storyId) {
123
+ return (
124
+ <WidgetWrapper>
125
+ <div className={styles.container} ref={containerRef}>
126
+ <div className={styles.error}>
127
+ <span className={styles.errorIcon}><GridIcon size={20} /></span>
128
+ <span className={styles.errorText}>Missing story ID</span>
129
+ </div>
130
+ </div>
131
+ </WidgetWrapper>
132
+ )
133
+ }
134
+
135
+ if (!iframeSrc) {
136
+ return (
137
+ <WidgetWrapper>
138
+ <div className={styles.container} ref={containerRef}>
139
+ <div className={styles.error}>
140
+ <span className={styles.errorIcon}><GridIcon size={20} /></span>
141
+ <span className={styles.errorText}>Story &ldquo;{storyId}&rdquo; not found or has no route</span>
142
+ </div>
143
+ </div>
144
+ </WidgetWrapper>
145
+ )
146
+ }
147
+
148
+ const sizeStyle = {}
149
+ if (typeof width === 'number') sizeStyle.width = `${width}px`
150
+ if (typeof height === 'number') sizeStyle.height = `${height}px`
151
+
152
+ return (
153
+ <WidgetWrapper>
154
+ <div ref={containerRef} className={styles.container} style={sizeStyle}>
155
+ <div className={styles.header}>
156
+ <span className={styles.headerIcon}><GridIcon size={16} /></span>
157
+ <span className={styles.headerTitle}>{displayName}</span>
158
+ {selected && (
159
+ <span className={styles.headerSelected}>· {selected}</span>
160
+ )}
161
+ <span className={styles.headerLayout} title={`Layout: ${layout}`}>
162
+ {layout === 'horizontal' ? '⇔' : '⇕'}
163
+ </span>
164
+ </div>
165
+ <div className={styles.content}>
166
+ <iframe
167
+ ref={iframeRef}
168
+ src={iframeSrc}
169
+ className={styles.iframe}
170
+ title={`${displayName} component set`}
171
+ onLoad={(e) => e.target.blur()}
172
+ />
173
+ </div>
174
+ {!interactive && (
175
+ <div
176
+ className={overlayStyles.interactOverlay}
177
+ onClick={(e) => {
178
+ if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
179
+ enterInteractive()
180
+ }}
181
+ role="button"
182
+ tabIndex={0}
183
+ onKeyDown={(e) => {
184
+ if (e.key === 'Enter' || e.key === ' ') {
185
+ e.preventDefault()
186
+ e.stopPropagation()
187
+ enterInteractive()
188
+ }
189
+ }}
190
+ aria-label="Click to interact"
191
+ >
192
+ <span className={overlayStyles.interactHint}>Click to interact</span>
193
+ </div>
194
+ )}
195
+ </div>
196
+ {resizable && <ResizeHandle targetRef={containerRef} width={width} height={height} onResize={handleResize} />}
197
+ </WidgetWrapper>
198
+ )
199
+ })
@@ -0,0 +1,89 @@
1
+ /* ComponentSetWidget — canvas widget chrome */
2
+
3
+ .container {
4
+ position: relative;
5
+ overflow: hidden;
6
+ min-width: 200px;
7
+ min-height: 120px;
8
+ background: var(--bgColor-default, #ffffff);
9
+ border: 3px solid var(--borderColor-default, #d0d7de);
10
+ border-radius: 12px;
11
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
12
+ width: 100%;
13
+ height: 100%;
14
+ }
15
+
16
+ .header {
17
+ display: flex;
18
+ align-items: center;
19
+ gap: 6px;
20
+ padding: 10px 10px;
21
+ font-size: 12px;
22
+ font-weight: 500;
23
+ color: var(--fgColor-muted, #656d76);
24
+ background: var(--bgColor-muted, #f6f8fa);
25
+ border-bottom: 1px solid var(--borderColor-muted, #d8dee4);
26
+ white-space: nowrap;
27
+ overflow: hidden;
28
+ text-overflow: ellipsis;
29
+ user-select: none;
30
+ }
31
+
32
+ .headerIcon {
33
+ display: inline-flex;
34
+ flex-shrink: 0;
35
+ }
36
+
37
+ .headerTitle {
38
+ overflow: hidden;
39
+ text-overflow: ellipsis;
40
+ }
41
+
42
+ .headerSelected {
43
+ color: var(--fgColor-accent, #0969da);
44
+ font-weight: 600;
45
+ flex-shrink: 0;
46
+ }
47
+
48
+ .headerLayout {
49
+ margin-left: auto;
50
+ font-size: 14px;
51
+ opacity: 0.5;
52
+ flex-shrink: 0;
53
+ }
54
+
55
+ .content {
56
+ position: relative;
57
+ width: 100%;
58
+ height: calc(100% - 37px);
59
+ }
60
+
61
+ .iframe {
62
+ position: absolute;
63
+ inset: 0;
64
+ display: block;
65
+ width: 100%;
66
+ height: 100%;
67
+ border: none;
68
+ z-index: 1;
69
+ }
70
+
71
+ .error {
72
+ display: flex;
73
+ align-items: center;
74
+ gap: 8px;
75
+ padding: 16px;
76
+ color: var(--fgColor-danger, #cf222e);
77
+ font-family: system-ui, -apple-system, sans-serif;
78
+ font-size: 13px;
79
+ line-height: 1.5;
80
+ }
81
+
82
+ .errorIcon {
83
+ font-size: 20px;
84
+ flex-shrink: 0;
85
+ }
86
+
87
+ .errorText {
88
+ word-break: break-word;
89
+ }
@@ -90,6 +90,7 @@ export default function ComponentWidget({
90
90
  className={styles.iframe}
91
91
  title={exportName || 'Component widget'}
92
92
  sandbox="allow-same-origin allow-scripts"
93
+ onLoad={(e) => e.target.blur()}
93
94
  />
94
95
  ) : (
95
96
  <div className={styles.placeholder} />