@dfosco/storyboard-react 4.2.0-beta.4 → 4.2.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 (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 +363 -67
  89. package/src/vite/data-plugin.test.js +1 -1
@@ -1,14 +1,17 @@
1
- import { useRef, useCallback, useState, forwardRef, useImperativeHandle } from 'react'
1
+ import { useRef, useCallback, useState, useMemo, forwardRef, useImperativeHandle } from 'react'
2
2
  import WidgetWrapper from './WidgetWrapper.jsx'
3
3
  import ResizeHandle from './ResizeHandle.jsx'
4
+ import ExpandedPane from './ExpandedPane.jsx'
5
+ import CropOverlay from './CropOverlay.jsx'
4
6
  import { readProp } from './widgetProps.js'
5
7
  import { schemas } from './widgetConfig.js'
6
- import { toggleImagePrivacy } from '../canvasApi.js'
8
+ import { toggleImagePrivacy, cropAndUpload } from '../canvasApi.js'
9
+ import { findAllConnectedSplitTargets, getSplitPaneLabel, buildPaneForWidget, buildSplitLayout } from './expandUtils.js'
7
10
  import styles from './ImageWidget.module.css'
8
11
 
9
12
  const imageSchema = schemas['image']
10
13
 
11
- function getImageUrl(src) {
14
+ export function getImageUrl(src) {
12
15
  if (!src) return ''
13
16
  const base = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
14
17
  return `${base}/_storyboard/canvas/images/${src}`
@@ -16,11 +19,18 @@ function getImageUrl(src) {
16
19
 
17
20
  /**
18
21
  * Canvas widget that displays a pasted image.
19
- * Supports aspect-ratio locked resize and privacy toggle.
22
+ * Supports aspect-ratio locked resize, privacy toggle, and expand/split-screen.
20
23
  */
21
- const ImageWidget = forwardRef(function ImageWidget({ props, onUpdate, resizable }, ref) {
24
+ const ImageWidget = forwardRef(function ImageWidget({ id, props, onUpdate, resizable }, ref) {
22
25
  const containerRef = useRef(null)
26
+ const imgRef = useRef(null)
23
27
  const [naturalRatio, setNaturalRatio] = useState(null)
28
+ const [naturalSize, setNaturalSize] = useState(null)
29
+ const [expandMode, setExpandMode] = useState(null)
30
+ const expanded = expandMode !== null
31
+ const [cropping, setCropping] = useState(false)
32
+ const [previousSrc, setPreviousSrc] = useState(null)
33
+ const [containerSize, setContainerSize] = useState(null)
24
34
 
25
35
  const src = readProp(props, 'src', imageSchema)
26
36
  const isPrivate = readProp(props, 'private', imageSchema)
@@ -34,6 +44,7 @@ const ImageWidget = forwardRef(function ImageWidget({ props, onUpdate, resizable
34
44
  const img = e.target
35
45
  if (img.naturalWidth && img.naturalHeight) {
36
46
  setNaturalRatio(img.naturalWidth / img.naturalHeight)
47
+ setNaturalSize({ width: img.naturalWidth, height: img.naturalHeight })
37
48
  }
38
49
  }, [])
39
50
 
@@ -43,8 +54,44 @@ const ImageWidget = forwardRef(function ImageWidget({ props, onUpdate, resizable
43
54
  onUpdate?.({ width: newWidth, height: newHeight })
44
55
  }, [naturalRatio, width, height, onUpdate])
45
56
 
57
+ const handleCropSave = useCallback(async (cropRect) => {
58
+ if (!src) return
59
+ const canvasId = window.__storyboardCanvasBridgeState?.canvasId || ''
60
+ try {
61
+ const result = await cropAndUpload(src, cropRect, canvasId)
62
+ if (result.success) {
63
+ setPreviousSrc(src)
64
+ onUpdate?.({ src: result.filename })
65
+ }
66
+ } catch (err) {
67
+ console.error('[canvas] Failed to crop image:', err)
68
+ }
69
+ setCropping(false)
70
+ }, [src, onUpdate])
71
+
72
+ const handleCropCancel = useCallback(() => {
73
+ setCropping(false)
74
+ }, [])
75
+
76
+ const handleCropUndo = useCallback(() => {
77
+ if (previousSrc) {
78
+ onUpdate?.({ src: previousSrc })
79
+ setPreviousSrc(null)
80
+ }
81
+ setCropping(false)
82
+ }, [previousSrc, onUpdate])
83
+
46
84
  useImperativeHandle(ref, () => ({
47
85
  handleAction(actionId) {
86
+ if (actionId === 'expand' || actionId === 'expand-single') { setExpandMode('single'); return true }
87
+ if (actionId === 'split-screen') { setExpandMode('split'); return true }
88
+ if (actionId === 'crop-image') {
89
+ // Measure container at activation time (not during render)
90
+ const el = containerRef.current
91
+ if (el) setContainerSize({ width: el.offsetWidth, height: el.offsetHeight })
92
+ setCropping(true)
93
+ return true
94
+ }
48
95
  if (actionId === 'toggle-private') {
49
96
  if (!src) return
50
97
  toggleImagePrivacy(src).then((result) => {
@@ -59,7 +106,7 @@ const ImageWidget = forwardRef(function ImageWidget({ props, onUpdate, resizable
59
106
  const url = getImageUrl(src)
60
107
  const a = document.createElement('a')
61
108
  a.href = url
62
- a.download = src.replace(/^_/, '')
109
+ a.download = src.replace(/^~/, '')
63
110
  document.body.appendChild(a)
64
111
  a.click()
65
112
  document.body.removeChild(a)
@@ -86,23 +133,37 @@ const ImageWidget = forwardRef(function ImageWidget({ props, onUpdate, resizable
86
133
  if (typeof width === 'number') sizeStyle.width = `${width}px`
87
134
 
88
135
  return (
136
+ <>
89
137
  <WidgetWrapper className={styles.imageWrapper}>
90
- <div ref={containerRef} className={styles.container} style={sizeStyle}>
138
+ <div ref={containerRef} className={styles.container} style={sizeStyle} data-crop-active={cropping || undefined}>
91
139
  <div className={styles.frame}>
92
140
  <img
141
+ ref={imgRef}
93
142
  src={getImageUrl(src)}
94
143
  alt=""
95
144
  className={styles.image}
96
145
  onLoad={handleImageLoad}
97
146
  draggable={false}
98
147
  />
99
- {isPrivate && (
148
+ {isPrivate && !cropping && (
100
149
  <span className={styles.privateBadge} title="Private — not committed to git">
101
150
  Private
102
151
  </span>
103
152
  )}
153
+ {cropping && (
154
+ <CropOverlay
155
+ containerWidth={containerSize?.width || width || 400}
156
+ containerHeight={containerSize?.height || height || 300}
157
+ naturalWidth={naturalSize?.width}
158
+ naturalHeight={naturalSize?.height}
159
+ onSave={handleCropSave}
160
+ onCancel={handleCropCancel}
161
+ onUndo={handleCropUndo}
162
+ canUndo={!!previousSrc}
163
+ />
164
+ )}
104
165
  </div>
105
- {resizable && (
166
+ {resizable && !cropping && (
106
167
  <ResizeHandle
107
168
  targetRef={containerRef}
108
169
  minWidth={100}
@@ -112,7 +173,67 @@ const ImageWidget = forwardRef(function ImageWidget({ props, onUpdate, resizable
112
173
  )}
113
174
  </div>
114
175
  </WidgetWrapper>
176
+ {expanded && (
177
+ <ImageExpandPane
178
+ widgetId={id}
179
+ src={src}
180
+ splitMode={expandMode === 'split'}
181
+ onClose={() => setExpandMode(null)}
182
+ />
183
+ )}
184
+ </>
115
185
  )
116
186
  })
117
187
 
188
+ /**
189
+ * Builds pane configs and renders ExpandedPane for an expanded image widget.
190
+ */
191
+ function ImageExpandPane({ widgetId, src, splitMode, onClose }) {
192
+ const connectedWidgets = useMemo(
193
+ () => splitMode ? findAllConnectedSplitTargets(widgetId) : [],
194
+ [widgetId, splitMode],
195
+ )
196
+ const primaryWidget = useMemo(() => {
197
+ const bridge = window.__storyboardCanvasBridgeState
198
+ return bridge?.widgets?.find((w) => w.id === widgetId) || { id: widgetId, type: 'image', position: { x: 0, y: 0 }, props: {} }
199
+ }, [widgetId])
200
+
201
+ const surface = splitMode ? 'splitbar' : 'fullbar'
202
+
203
+ const buildPaneFn = useCallback((widget) => {
204
+ if (widget.id === widgetId) {
205
+ return {
206
+ id: widgetId,
207
+ label: getSplitPaneLabel(primaryWidget) || 'Image',
208
+ widgetType: 'image',
209
+ kind: 'react',
210
+ render: () => (
211
+ <div className={styles.expandedImageContainer}>
212
+ <img
213
+ src={getImageUrl(src)}
214
+ alt=""
215
+ className={styles.expandedImage}
216
+ draggable={false}
217
+ />
218
+ </div>
219
+ ),
220
+ }
221
+ }
222
+ return buildPaneForWidget(widget, surface)
223
+ }, [widgetId, primaryWidget, src, surface])
224
+
225
+ const layout = useMemo(
226
+ () => buildSplitLayout(primaryWidget, connectedWidgets, buildPaneFn),
227
+ [primaryWidget, connectedWidgets, buildPaneFn],
228
+ )
229
+
230
+ return (
231
+ <ExpandedPane
232
+ initialLayout={layout}
233
+ variant={layout.flat().length <= 1 ? 'modal' : 'full'}
234
+ onClose={onClose}
235
+ />
236
+ )
237
+ }
238
+
118
239
  export default ImageWidget
@@ -23,6 +23,16 @@
23
23
  pointer-events: none;
24
24
  }
25
25
 
26
+ /* When cropping, the image needs pointer events for the overlay */
27
+ .container[data-crop-active] .image {
28
+ pointer-events: none;
29
+ }
30
+
31
+ /* Hide the widget toolbar when crop is active (WidgetChrome reads this) */
32
+ .container[data-crop-active] {
33
+ overflow: visible;
34
+ }
35
+
26
36
  .privateBadge {
27
37
  position: absolute;
28
38
  top: 20px;
@@ -37,3 +47,23 @@
37
47
  background: var(--bgColor-neutral-emphasis, rgba(0, 0, 0, 0.55));
38
48
  pointer-events: none;
39
49
  }
50
+
51
+ /* ── Expanded / split-screen image ───────────────────────────────── */
52
+
53
+ .expandedImageContainer {
54
+ width: 100%;
55
+ height: 100%;
56
+ display: flex;
57
+ align-items: center;
58
+ justify-content: center;
59
+ overflow: auto;
60
+ background: var(--bgColor-inset, #f6f8fa);
61
+ }
62
+
63
+ .expandedImage {
64
+ max-width: 100%;
65
+ max-height: 100%;
66
+ object-fit: contain;
67
+ border-radius: 0;
68
+ user-select: none;
69
+ }
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
1
+ import { useCallback, useEffect, useMemo, useRef, useState, forwardRef, useImperativeHandle } from 'react'
2
2
  import { remark } from 'remark'
3
3
  import remarkGfm from 'remark-gfm'
4
4
  import remarkHtml from 'remark-html'
@@ -6,9 +6,10 @@ import { MarkGithubIcon } from '@primer/octicons-react'
6
6
  import WidgetWrapper from './WidgetWrapper.jsx'
7
7
  import ResizeHandle from './ResizeHandle.jsx'
8
8
  import { readProp, linkPreviewSchema } from './widgetProps.js'
9
+ import ExpandedPane from './ExpandedPane.jsx'
10
+ import { findAllConnectedSplitTargets, buildPaneForWidget, buildSplitLayout } from './expandUtils.js'
9
11
  import styles from './LinkPreview.module.css'
10
12
 
11
- const VIDEO_EXT_RE = /\.(mp4|mov|webm|ogg)(\?[^)]*)?$/i
12
13
  const VIDEO_URL_LINE_RE = /^<p>\s*(https?:\/\/[^\s<]+\.(mp4|mov|webm|ogg)(?:\?[^\s<]*)?)\s*<\/p>$/gim
13
14
 
14
15
  /**
@@ -106,7 +107,7 @@ function getCommentKindLabel(github) {
106
107
  return 'Comment'
107
108
  }
108
109
 
109
- function GitHubIssueCard({ url, title, github, width, collapsed, onUpdate }) {
110
+ function GitHubIssueCard({ id, url, title, github, width, collapsed, expanded, expandMode, onCloseExpand }) {
110
111
  const authors = Array.isArray(github?.authors)
111
112
  ? github.authors.filter((a) => typeof a === 'string' && a.trim())
112
113
  : []
@@ -117,6 +118,7 @@ function GitHubIssueCard({ url, title, github, width, collapsed, onUpdate }) {
117
118
  const kindLabel = getCommentKindLabel(github)
118
119
 
119
120
  // Prefer pre-rendered bodyHtml (has signed image URLs), fall back to remark for discussions
121
+ // eslint-disable-next-line react-hooks/preserve-manual-memoization
120
122
  const bodyHtml = useMemo(() => {
121
123
  if (github?.bodyHtml) return postProcessHtml(github.bodyHtml)
122
124
  return renderMarkdown(github?.body || '')
@@ -146,6 +148,7 @@ function GitHubIssueCard({ url, title, github, width, collapsed, onUpdate }) {
146
148
  }
147
149
 
148
150
  return (
151
+ <>
149
152
  <WidgetWrapper>
150
153
  <div className={`${styles.issueCard} ${collapsed ? styles.issueCardCollapsed : ''}`} style={sizeStyle}>
151
154
  <div className={styles.typeBar}>
@@ -204,10 +207,40 @@ function GitHubIssueCard({ url, title, github, width, collapsed, onUpdate }) {
204
207
  )}
205
208
  </div>
206
209
  </WidgetWrapper>
210
+ {expanded && (
211
+ <LinkPreviewExpandPane
212
+ widgetId={id}
213
+ label={`${kindLabel}: ${titleText || url || 'GitHub'}`}
214
+ splitMode={expandMode === 'split'}
215
+ onClose={onCloseExpand}
216
+ >
217
+ <div className={styles.expandedIssue}>
218
+ <header className={styles.expandedIssueHeader}>
219
+ <h2 className={styles.expandedIssueTitle}>
220
+ <a href={url || '#'} target="_blank" rel="noopener noreferrer">
221
+ {titleText || url}
222
+ {issueNumber && <span className={styles.expandedIssueNumber}> {issueNumber}</span>}
223
+ </a>
224
+ </h2>
225
+ <div className={styles.expandedByline}>
226
+ {primaryAuthor && (
227
+ <a href={`https://github.com/${primaryAuthor}`} target="_blank" rel="noopener noreferrer" className={styles.expandedAuthor}>
228
+ <img src={`https://github.com/${primaryAuthor}.png?size=40`} alt="" width="20" height="20" className={styles.avatar} loading="lazy" />
229
+ {primaryAuthor}
230
+ </a>
231
+ )}
232
+ {createdAgo && <span className={styles.expandedBylineText}>{primaryAuthor ? ` opened ${createdAgo}` : `Opened ${createdAgo}`}</span>}
233
+ </div>
234
+ </header>
235
+ {bodyHtml && <div className={styles.expandedIssueBody} dangerouslySetInnerHTML={{ __html: bodyHtml }} />}
236
+ </div>
237
+ </LinkPreviewExpandPane>
238
+ )}
239
+ </>
207
240
  )
208
241
  }
209
242
 
210
- export default function LinkPreview({ props, onUpdate, resizable }) {
243
+ export default forwardRef(function LinkPreview({ id, props, onUpdate, resizable }, ref) {
211
244
  const url = readProp(props, 'url', linkPreviewSchema)
212
245
  const title = readProp(props, 'title', linkPreviewSchema)
213
246
  const github = props?.github && typeof props.github === 'object' ? props.github : null
@@ -222,6 +255,20 @@ export default function LinkPreview({ props, onUpdate, resizable }) {
222
255
  const cardRef = useRef(null)
223
256
  const inputRef = useRef(null)
224
257
  const [editing, setEditing] = useState(false)
258
+ const [expandMode, setExpandMode] = useState(null)
259
+ const expanded = expandMode !== null
260
+
261
+ useImperativeHandle(ref, () => ({
262
+ handleAction(actionId) {
263
+ if (actionId === 'expand' || actionId === 'expand-single') { setExpandMode('single'); return true }
264
+ if (actionId === 'split-screen') { setExpandMode('split'); return true }
265
+ if (actionId === 'open-external') {
266
+ if (url) window.open(url, '_blank', 'noopener')
267
+ return true
268
+ }
269
+ return false
270
+ },
271
+ }), [url])
225
272
 
226
273
  const startEditing = useCallback(() => {
227
274
  if (!canEdit) return
@@ -242,12 +289,15 @@ export default function LinkPreview({ props, onUpdate, resizable }) {
242
289
  if (github) {
243
290
  return (
244
291
  <GitHubIssueCard
292
+ id={id}
245
293
  url={url}
246
294
  title={title}
247
295
  github={github}
248
296
  width={width}
249
297
  collapsed={!!props?.collapsed}
250
- onUpdate={onUpdate}
298
+ expanded={expanded}
299
+ expandMode={expandMode}
300
+ onCloseExpand={() => setExpandMode(null)}
251
301
  />
252
302
  )
253
303
  }
@@ -262,6 +312,7 @@ export default function LinkPreview({ props, onUpdate, resizable }) {
262
312
  const handleResize = (w, h) => onUpdate?.({ width: w, height: h })
263
313
 
264
314
  return (
315
+ <>
265
316
  <div className={styles.container}>
266
317
  <div ref={cardRef} className={styles.card} style={sizeStyle}>
267
318
  {ogImage && (
@@ -316,5 +367,62 @@ export default function LinkPreview({ props, onUpdate, resizable }) {
316
367
  </div>
317
368
  {resizable && <ResizeHandle targetRef={cardRef} width={width} height={height} onResize={handleResize} />}
318
369
  </div>
370
+ {expanded && (
371
+ <LinkPreviewExpandPane
372
+ widgetId={id}
373
+ label={title || hostname || 'Link Preview'}
374
+ splitMode={expandMode === 'split'}
375
+ onClose={() => setExpandMode(null)}
376
+ >
377
+ <div className={styles.expandedLink}>
378
+ {ogImage && <img className={styles.expandedOgImage} src={ogImage} alt="" loading="lazy" />}
379
+ <h2 className={styles.expandedTitle}>{title || hostname || url || 'Untitled'}</h2>
380
+ {description && <p className={styles.expandedDescription}>{description}</p>}
381
+ {url && <a href={url} target="_blank" rel="noopener noreferrer" className={styles.expandedUrl}>{url}</a>}
382
+ </div>
383
+ </LinkPreviewExpandPane>
384
+ )}
385
+ </>
386
+ )
387
+ })
388
+
389
+ /**
390
+ * Builds pane configs and renders ExpandedPane for an expanded link-preview widget.
391
+ */
392
+ function LinkPreviewExpandPane({ widgetId, label, splitMode, onClose, children }) {
393
+ const connectedWidgets = useMemo(
394
+ () => splitMode ? findAllConnectedSplitTargets(widgetId) : [],
395
+ [widgetId, splitMode],
396
+ )
397
+
398
+ const primaryWidget = useMemo(() => {
399
+ const bridge = window.__storyboardCanvasBridgeState
400
+ return bridge?.widgets?.find((w) => w.id === widgetId) || { id: widgetId, type: 'link-preview', position: { x: 0, y: 0 }, props: {} }
401
+ }, [widgetId])
402
+
403
+ const buildPaneFn = useCallback((widget) => {
404
+ if (widget.id === widgetId) {
405
+ return {
406
+ id: widgetId,
407
+ label,
408
+ widgetType: 'link-preview',
409
+ kind: 'react',
410
+ render: () => children,
411
+ }
412
+ }
413
+ return buildPaneForWidget(widget)
414
+ }, [widgetId, label, children])
415
+
416
+ const layout = useMemo(
417
+ () => buildSplitLayout(primaryWidget, connectedWidgets, buildPaneFn),
418
+ [primaryWidget, connectedWidgets, buildPaneFn],
419
+ )
420
+
421
+ return (
422
+ <ExpandedPane
423
+ initialLayout={layout}
424
+ variant={layout.flat().length <= 1 ? 'modal' : 'full'}
425
+ onClose={onClose}
426
+ />
319
427
  )
320
428
  }
@@ -417,3 +417,130 @@
417
417
  font-size: 12px;
418
418
  color: var(--fgColor-danger, #cf222e);
419
419
  }
420
+
421
+ /* ── Expanded issue view in modal ─────────────────────────────────── */
422
+
423
+ .expandedIssue {
424
+ height: 100%;
425
+ display: flex;
426
+ flex-direction: column;
427
+ background: var(--bgColor-default, #ffffff);
428
+ font-family: var(--tc-font-stack, system-ui, -apple-system, sans-serif);
429
+ }
430
+
431
+ .expandedIssueHeader {
432
+ padding: 24px 40px 16px;
433
+ border-bottom: 1px solid var(--borderColor-muted, #d8dee4);
434
+ }
435
+
436
+ .expandedIssueTitle {
437
+ margin: 0 0 8px;
438
+ font-size: 28px;
439
+ font-weight: 400;
440
+ line-height: 1.25;
441
+ color: var(--fgColor-default, #1f2328);
442
+ }
443
+
444
+ .expandedIssueTitle a {
445
+ color: inherit;
446
+ text-decoration: none;
447
+ }
448
+
449
+ .expandedIssueTitle a:hover {
450
+ text-decoration: underline;
451
+ }
452
+
453
+ .expandedIssueNumber {
454
+ color: var(--fgColor-muted, #656d76);
455
+ }
456
+
457
+ .expandedByline {
458
+ display: flex;
459
+ align-items: center;
460
+ gap: 8px;
461
+ font-size: 13px;
462
+ }
463
+
464
+ .expandedAuthor {
465
+ display: flex;
466
+ align-items: center;
467
+ gap: 6px;
468
+ text-decoration: none;
469
+ color: var(--fgColor-default, #1f2328);
470
+ font-weight: 600;
471
+ }
472
+
473
+ .expandedAuthor:hover {
474
+ text-decoration: underline;
475
+ }
476
+
477
+ .expandedBylineText {
478
+ color: var(--fgColor-muted, #656d76);
479
+ }
480
+
481
+ .expandedIssueBody {
482
+ flex: 1;
483
+ overflow: auto;
484
+ padding: 24px 40px;
485
+ font-size: 15px;
486
+ line-height: 1.7;
487
+ color: var(--fgColor-default, #1f2328);
488
+ max-width: 800px;
489
+ }
490
+
491
+ .expandedIssueBody * { pointer-events: auto; }
492
+ .expandedIssueBody a { color: var(--fgColor-accent, #0969da); text-decoration: none; }
493
+ .expandedIssueBody a:hover { text-decoration: underline; }
494
+ .expandedIssueBody img { max-width: 100%; height: auto; border-radius: 6px; margin: 8px 0; display: block; }
495
+ .expandedIssueBody video { max-width: 100%; height: auto; border-radius: 6px; margin: 8px 0; display: block; }
496
+ .expandedIssueBody h1 { font-size: 20px; font-weight: 700; margin: 16px 0 8px; border-bottom: 1px solid var(--borderColor-muted, #d8dee4); padding-bottom: 4px; }
497
+ .expandedIssueBody h2 { font-size: 17px; font-weight: 600; margin: 14px 0 6px; }
498
+ .expandedIssueBody h3 { font-size: 15px; font-weight: 600; margin: 12px 0 4px; }
499
+ .expandedIssueBody p { margin: 0 0 12px; }
500
+ .expandedIssueBody code { background: var(--bgColor-neutral-muted, #afb8c133); padding: 2px 5px; border-radius: 4px; font-size: 13px; font-family: ui-monospace, SFMono-Regular, monospace; }
501
+ .expandedIssueBody pre { padding: 12px 16px; border-radius: 6px; border: 1px solid var(--borderColor-muted, #d8dee4); overflow-x: auto; margin: 12px 0; background: var(--bgColor-neutral-muted, #afb8c133); }
502
+ .expandedIssueBody pre code { background: none; padding: 0; }
503
+ .expandedIssueBody ul { margin: 0 0 12px; padding-left: 24px; list-style: disc; }
504
+ .expandedIssueBody ol { margin: 0 0 12px; padding-left: 24px; list-style: decimal; }
505
+ .expandedIssueBody li { margin: 0 0 4px; display: list-item; }
506
+ .expandedIssueBody blockquote { border-left: 4px solid var(--borderColor-default, #d0d7de); margin: 12px 0; padding: 4px 16px; color: var(--fgColor-muted, #656d76); }
507
+
508
+ /* ── Expanded plain link view ─────────────────────────────────────── */
509
+
510
+ .expandedLink {
511
+ padding: 32px 40px;
512
+ font-family: var(--tc-font-stack, system-ui, -apple-system, sans-serif);
513
+ }
514
+
515
+ .expandedOgImage {
516
+ max-width: 100%;
517
+ max-height: 400px;
518
+ object-fit: cover;
519
+ border-radius: 8px;
520
+ margin-bottom: 16px;
521
+ display: block;
522
+ }
523
+
524
+ .expandedTitle {
525
+ margin: 0 0 8px;
526
+ font-size: 24px;
527
+ font-weight: 600;
528
+ color: var(--fgColor-default, #1f2328);
529
+ }
530
+
531
+ .expandedDescription {
532
+ margin: 0 0 12px;
533
+ font-size: 15px;
534
+ line-height: 1.5;
535
+ color: var(--fgColor-muted, #656d76);
536
+ }
537
+
538
+ .expandedUrl {
539
+ font-size: 14px;
540
+ color: var(--fgColor-accent, #0969da);
541
+ text-decoration: none;
542
+ }
543
+
544
+ .expandedUrl:hover {
545
+ text-decoration: underline;
546
+ }