@dfosco/storyboard-react 4.0.0-beta.36 → 4.0.0-beta.38

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.
@@ -1663,6 +1663,20 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1663
1663
  return () => document.removeEventListener('wheel', handleWheel)
1664
1664
  }, [])
1665
1665
 
1666
+ // Receive cmd+wheel events forwarded from prototype/story iframes
1667
+ useEffect(() => {
1668
+ function handleMessage(e) {
1669
+ if (e.data?.type !== 'storyboard:embed:wheel') return
1670
+ zoomAccum.current += -e.data.deltaY
1671
+ const step = Math.trunc(zoomAccum.current)
1672
+ if (step === 0) return
1673
+ zoomAccum.current -= step
1674
+ applyZoom(zoomRef.current + step)
1675
+ }
1676
+ window.addEventListener('message', handleMessage)
1677
+ return () => window.removeEventListener('message', handleMessage)
1678
+ }, [])
1679
+
1666
1680
  // Touch pinch-to-zoom for mobile — two-finger pinch zooms the canvas
1667
1681
  const pinchState = useRef({ active: false, startDist: 0, startZoom: 0, centerX: 0, centerY: 0 })
1668
1682
  useEffect(() => {
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useMemo, useRef } from 'react'
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
2
  import { remark } from 'remark'
3
3
  import remarkGfm from 'remark-gfm'
4
4
  import remarkHtml from 'remark-html'
@@ -207,7 +207,7 @@ function GitHubIssueCard({ url, title, github, width, collapsed, onUpdate }) {
207
207
  )
208
208
  }
209
209
 
210
- export default function LinkPreview({ id, props, onUpdate, resizable }) {
210
+ export default function LinkPreview({ props, onUpdate, resizable }) {
211
211
  const url = readProp(props, 'url', linkPreviewSchema)
212
212
  const title = readProp(props, 'title', linkPreviewSchema)
213
213
  const github = props?.github && typeof props.github === 'object' ? props.github : null
@@ -215,6 +215,30 @@ export default function LinkPreview({ id, props, onUpdate, resizable }) {
215
215
  const width = typeof props?.width === 'number' ? props.width : null
216
216
  const height = typeof props?.height === 'number' ? props.height : null
217
217
 
218
+ // All hooks must be called before any early return
219
+ const ogImage = props?.ogImage || null
220
+ const description = props?.description || ''
221
+ const canEdit = typeof onUpdate === 'function'
222
+ const cardRef = useRef(null)
223
+ const inputRef = useRef(null)
224
+ const [editing, setEditing] = useState(false)
225
+
226
+ const startEditing = useCallback(() => {
227
+ if (!canEdit) return
228
+ setEditing(true)
229
+ }, [canEdit])
230
+
231
+ const handleTitleChange = useCallback((e) => {
232
+ onUpdate?.({ title: e.target.value })
233
+ }, [onUpdate])
234
+
235
+ useEffect(() => {
236
+ if (editing && inputRef.current) {
237
+ inputRef.current.focus()
238
+ inputRef.current.select()
239
+ }
240
+ }, [editing])
241
+
218
242
  if (github) {
219
243
  return (
220
244
  <GitHubIssueCard
@@ -238,26 +262,59 @@ export default function LinkPreview({ id, props, onUpdate, resizable }) {
238
262
  const handleResize = (w, h) => onUpdate?.({ width: w, height: h })
239
263
 
240
264
  return (
241
- <WidgetWrapper>
242
- <div className={styles.card} style={sizeStyle}>
243
- <div className={styles.header}>
244
- <span className={styles.icon}>🔗</span>
245
- <div className={styles.text}>
246
- {title && <p className={styles.title}>{title}</p>}
247
- </div>
265
+ <div className={styles.container}>
266
+ <div ref={cardRef} className={styles.card} style={sizeStyle}>
267
+ {ogImage && (
268
+ <img
269
+ className={styles.ogImage}
270
+ src={ogImage}
271
+ alt=""
272
+ loading="lazy"
273
+ onError={(e) => { e.target.style.display = 'none' }}
274
+ />
275
+ )}
276
+ <div className={styles.body}>
277
+ {editing ? (
278
+ <input
279
+ ref={inputRef}
280
+ className={styles.titleInput}
281
+ data-canvas-allow-text-selection
282
+ type="text"
283
+ value={title}
284
+ onChange={handleTitleChange}
285
+ onBlur={() => setEditing(false)}
286
+ onKeyDown={(e) => {
287
+ if (e.key === 'Enter' || e.key === 'Escape') setEditing(false)
288
+ }}
289
+ onMouseDown={(e) => e.stopPropagation()}
290
+ onPointerDown={(e) => e.stopPropagation()}
291
+ />
292
+ ) : (
293
+ <p
294
+ className={styles.title}
295
+ data-canvas-allow-text-selection={!canEdit ? '' : undefined}
296
+ onDoubleClick={startEditing}
297
+ role={canEdit ? 'button' : undefined}
298
+ tabIndex={canEdit ? 0 : undefined}
299
+ onKeyDown={canEdit ? (e) => { if (e.key === 'Enter') startEditing() } : undefined}
300
+ >
301
+ {title || hostname || url || 'Untitled'}
302
+ </p>
303
+ )}
304
+ {description && <p className={styles.description}>{description}</p>}
305
+ <a
306
+ href={url || '#'}
307
+ target="_blank"
308
+ rel="noopener noreferrer"
309
+ className={styles.url}
310
+ onMouseDown={(e) => e.stopPropagation()}
311
+ onPointerDown={(e) => e.stopPropagation()}
312
+ >
313
+ {hostname || url}
314
+ </a>
248
315
  </div>
249
- <a
250
- href={url || '#'}
251
- target="_blank"
252
- rel="noopener noreferrer"
253
- className={styles.url}
254
- onMouseDown={(e) => e.stopPropagation()}
255
- onPointerDown={(e) => e.stopPropagation()}
256
- >
257
- {hostname || url}
258
- </a>
259
316
  </div>
260
- {resizable && <ResizeHandle width={width} height={height} onResize={handleResize} />}
261
- </WidgetWrapper>
317
+ {resizable && <ResizeHandle targetRef={cardRef} width={width} height={height} onResize={handleResize} />}
318
+ </div>
262
319
  )
263
320
  }
@@ -1,42 +1,42 @@
1
1
  /* ── Plain link-preview card ──────────────────────────────────────── */
2
2
 
3
+ .container {
4
+ position: relative;
5
+ }
6
+
3
7
  .card {
4
8
  display: flex;
5
9
  flex-direction: column;
6
- gap: 10px;
7
- padding: 14px 16px;
8
- text-decoration: none;
9
- color: inherit;
10
10
  width: 320px;
11
- min-height: 120px;
12
- border-radius: 10px;
13
- border: 1px solid var(--borderColor-default, #d1d9e0);
11
+ border-radius: 6px;
12
+ border: 2px solid var(--borderColor-default, #d1d9e0);
14
13
  background: var(--bgColor-default, #ffffff);
15
- transition: background 150ms, border-color 150ms;
16
14
  box-sizing: border-box;
15
+ overflow: hidden;
16
+ font-family: var(--tc-font-stack, system-ui, -apple-system, sans-serif);
17
17
  }
18
18
 
19
- .card:hover {
20
- background: var(--bgColor-muted, #f6f8fa);
21
- border-color: var(--borderColor-muted, #afb8c1);
19
+ :global([data-sb-canvas-theme^='dark']) .card {
20
+ border-color: var(--borderColor-default, #3d444d);
22
21
  }
23
22
 
24
- .header {
25
- display: flex;
26
- align-items: flex-start;
27
- gap: 12px;
28
- width: 100%;
23
+ /* Hide own border when parent widget slot is selected (avoid double focus ring) */
24
+ :global([data-widget-selected]) .card {
25
+ border-color: transparent;
29
26
  }
30
27
 
31
- .icon {
32
- font-size: 20px;
33
- flex-shrink: 0;
28
+ .ogImage {
29
+ width: 100%;
30
+ max-height: 200px;
31
+ object-fit: cover;
32
+ display: block;
34
33
  }
35
34
 
36
- .text {
37
- overflow: hidden;
38
- min-width: 0;
39
- flex: 1;
35
+ .body {
36
+ display: flex;
37
+ flex-direction: column;
38
+ gap: 4px;
39
+ padding: 12px 14px;
40
40
  }
41
41
 
42
42
  .title {
@@ -45,6 +45,33 @@
45
45
  font-weight: 600;
46
46
  line-height: 1.35;
47
47
  color: var(--fgColor-default, #1f2328);
48
+ cursor: text;
49
+ word-break: break-word;
50
+ }
51
+
52
+ .titleInput {
53
+ margin: 0;
54
+ padding: 0;
55
+ border: none;
56
+ outline: none;
57
+ background: transparent;
58
+ font-family: inherit;
59
+ font-size: 14px;
60
+ font-weight: 600;
61
+ line-height: 1.35;
62
+ color: var(--fgColor-default, #1f2328);
63
+ width: 100%;
64
+ }
65
+
66
+ .description {
67
+ margin: 0;
68
+ font-size: 12px;
69
+ line-height: 1.4;
70
+ color: var(--fgColor-muted, #656d76);
71
+ display: -webkit-box;
72
+ -webkit-line-clamp: 2;
73
+ -webkit-box-orient: vertical;
74
+ overflow: hidden;
48
75
  }
49
76
 
50
77
  .url {
@@ -1,4 +1,4 @@
1
- import { render, screen } from '@testing-library/react'
1
+ import { render, screen, fireEvent } from '@testing-library/react'
2
2
  import { describe, expect, it, vi } from 'vitest'
3
3
  import LinkPreview from './LinkPreview.jsx'
4
4
 
@@ -68,4 +68,126 @@ describe('LinkPreview', () => {
68
68
  // No issue card rendered
69
69
  expect(container.querySelector('header')).toBeNull()
70
70
  })
71
+
72
+ it('renders OG image when present', () => {
73
+ const { container } = render(
74
+ <LinkPreview
75
+ id="link-4"
76
+ props={{
77
+ url: 'https://example.com',
78
+ title: 'With image',
79
+ ogImage: 'https://example.com/og.png',
80
+ }}
81
+ />,
82
+ )
83
+
84
+ const img = container.querySelector('img')
85
+ expect(img).toBeTruthy()
86
+ expect(img.src).toBe('https://example.com/og.png')
87
+ })
88
+
89
+ it('does not render image element when ogImage is absent', () => {
90
+ const { container } = render(
91
+ <LinkPreview
92
+ id="link-5"
93
+ props={{
94
+ url: 'https://example.com',
95
+ title: 'No image',
96
+ }}
97
+ />,
98
+ )
99
+
100
+ expect(container.querySelector('img')).toBeNull()
101
+ })
102
+
103
+ it('hides broken OG image on error', () => {
104
+ const { container } = render(
105
+ <LinkPreview
106
+ id="link-6"
107
+ props={{
108
+ url: 'https://example.com',
109
+ title: 'Broken image',
110
+ ogImage: 'https://example.com/broken.png',
111
+ }}
112
+ />,
113
+ )
114
+
115
+ const img = container.querySelector('img')
116
+ expect(img).toBeTruthy()
117
+ fireEvent.error(img)
118
+ expect(img.style.display).toBe('none')
119
+ })
120
+
121
+ it('renders description when present', () => {
122
+ render(
123
+ <LinkPreview
124
+ id="link-7"
125
+ props={{
126
+ url: 'https://example.com',
127
+ title: 'With desc',
128
+ description: 'A short description of the page',
129
+ }}
130
+ />,
131
+ )
132
+
133
+ expect(screen.getByText('A short description of the page')).toBeInTheDocument()
134
+ })
135
+
136
+ it('enters edit mode on double-click and saves on change', () => {
137
+ const onUpdate = vi.fn()
138
+ render(
139
+ <LinkPreview
140
+ id="link-8"
141
+ props={{
142
+ url: 'https://example.com',
143
+ title: 'Editable title',
144
+ }}
145
+ onUpdate={onUpdate}
146
+ />,
147
+ )
148
+
149
+ const titleEl = screen.getByText('Editable title')
150
+ fireEvent.doubleClick(titleEl)
151
+
152
+ const input = document.querySelector('input[type="text"]')
153
+ expect(input).toBeTruthy()
154
+ expect(input.value).toBe('Editable title')
155
+
156
+ fireEvent.change(input, { target: { value: 'New title' } })
157
+ expect(onUpdate).toHaveBeenCalledWith({ title: 'New title' })
158
+ })
159
+
160
+ it('does not enter edit mode when onUpdate is missing', () => {
161
+ render(
162
+ <LinkPreview
163
+ id="link-9"
164
+ props={{
165
+ url: 'https://example.com',
166
+ title: 'Read-only',
167
+ }}
168
+ />,
169
+ )
170
+
171
+ const titleEl = screen.getByText('Read-only')
172
+ fireEvent.doubleClick(titleEl)
173
+
174
+ // Should not show an input
175
+ expect(document.querySelector('input[type="text"]')).toBeNull()
176
+ })
177
+
178
+ it('shows fallback text when title is empty', () => {
179
+ render(
180
+ <LinkPreview
181
+ id="link-10"
182
+ props={{
183
+ url: 'https://example.com/page',
184
+ }}
185
+ onUpdate={() => {}}
186
+ />,
187
+ )
188
+
189
+ // Falls back to hostname — appears in both title and URL
190
+ const matches = screen.getAllByText('example.com')
191
+ expect(matches.length).toBeGreaterThanOrEqual(1)
192
+ })
71
193
  })
@@ -87,7 +87,7 @@
87
87
  border-radius: 4px;
88
88
  font-size: 12px;
89
89
  font-weight: 400;
90
- font-family: "Ioskeley Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
90
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
91
91
  }
92
92
 
93
93
  .preview ul {
@@ -169,7 +169,7 @@
169
169
  padding: 0;
170
170
  font-size: 12px;
171
171
  font-weight: 400;
172
- font-family: "Ioskeley Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
172
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
173
173
  white-space: pre;
174
174
  word-break: normal;
175
175
  overflow-wrap: normal;
@@ -403,7 +403,7 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
403
403
  </div>
404
404
  )}
405
405
  </div>
406
- {resizable && <ResizeHandle width={width} height={height} onResize={handleResize} />}
406
+ {resizable && <ResizeHandle targetRef={embedRef} width={width} height={height} onResize={handleResize} />}
407
407
  </WidgetWrapper>
408
408
  {createPortal(
409
409
  <div
@@ -270,7 +270,7 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
270
270
  </>
271
271
  )}
272
272
  </div>
273
- {resizable && <ResizeHandle width={width} height={height} onResize={handleResize} />}
273
+ {resizable && <ResizeHandle targetRef={containerRef} width={width} height={height} onResize={handleResize} />}
274
274
  </WidgetWrapper>
275
275
  )
276
276
  })
@@ -118,7 +118,7 @@
118
118
  }
119
119
 
120
120
  .codeLabel {
121
- font-family: "Ioskeley Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
121
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
122
122
  }
123
123
 
124
124
  .codeCloseBtn {
@@ -152,7 +152,7 @@
152
152
  .codeBlock pre {
153
153
  margin: 0;
154
154
  padding: var(--base-size-8, 8px) !important;
155
- font-family: "Ioskeley Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
155
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
156
156
  font-size: 12px;
157
157
  font-weight: 400;
158
158
  line-height: 1.6;
@@ -163,7 +163,7 @@
163
163
 
164
164
  /* Fallback when no highlighted HTML (plain pre/code) */
165
165
  .codeBlock > code {
166
- font-family: "Ioskeley Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
166
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
167
167
  font-size: 12px;
168
168
  font-weight: 400;
169
169
  line-height: 1.6;
package/src/index.js CHANGED
@@ -35,7 +35,7 @@ export { FormContext } from './context/FormContext.js'
35
35
  // ModeSwitch and ToolbarShell UI moved to @dfosco/storyboard-svelte-ui
36
36
 
37
37
  // Viewfinder dashboard
38
- export { default as Viewfinder } from './Viewfinder.jsx'
38
+ export { default as Viewfinder } from './ViewfinderNew.jsx'
39
39
 
40
40
  // Canvas
41
41
  export { default as CanvasPage } from './canvas/CanvasPage.jsx'
@@ -1128,6 +1128,30 @@ export default function storyboardDataPlugin() {
1128
1128
  return []
1129
1129
  },
1130
1130
 
1131
+ // Inject __SB_BRANCHES__ into HTML so the Viewfinder branch selector works.
1132
+ // Reads .worktrees/ports.json to enumerate active worktree dev servers.
1133
+ transformIndexHtml(html, ctx) {
1134
+ // Only inject in dev mode
1135
+ if (!ctx.server) return html
1136
+
1137
+ try {
1138
+ const portsJsonPath = path.resolve(root, '.worktrees', 'ports.json')
1139
+ if (!fs.existsSync(portsJsonPath)) return html
1140
+
1141
+ const ports = JSON.parse(fs.readFileSync(portsJsonPath, 'utf-8'))
1142
+ const branches = Object.entries(ports)
1143
+ .filter(([name]) => name !== 'main')
1144
+ .map(([name, port]) => ({ branch: name, folder: `branch--${name}`, port }))
1145
+
1146
+ if (branches.length === 0) return html
1147
+
1148
+ const script = `<script>window.__SB_BRANCHES__ = ${JSON.stringify(branches)};</script>`
1149
+ return html.replace('</head>', `${script}\n</head>`)
1150
+ } catch {
1151
+ return html
1152
+ }
1153
+ },
1154
+
1131
1155
  // Rebuild index on each build start
1132
1156
  buildStart() {
1133
1157
  buildResult = null
@@ -1,72 +0,0 @@
1
-
2
- import { useRef, useEffect, useMemo } from 'react'
3
-
4
- /**
5
- * Viewfinder — thin React wrapper around the Svelte Viewfinder component.
6
- *
7
- * Mounts the core Svelte Viewfinder into a container div and manages
8
- * its lifecycle via React's useEffect.
9
- *
10
- * @param {Object} props
11
- * @param {Record<string, unknown>} [props.scenes] - Scene/flow index (deprecated, ignored — data comes from core)
12
- * @param {Record<string, unknown>} [props.flows] - Flow index (deprecated, ignored — data comes from core)
13
- * @param {Record<string, unknown>} [props.pageModules] - import.meta.glob result for page files
14
- * @param {string} [props.basePath] - Base URL path
15
- * @param {string} [props.title] - Header title
16
- * @param {string} [props.subtitle] - Optional subtitle
17
- * @param {boolean} [props.showThumbnails] - Show thumbnail previews
18
- * @param {boolean} [props.hideDefaultFlow] - Hide the "default" flow from the "Other flows" section
19
- */
20
- export default function Viewfinder({ pageModules = {}, basePath, title = 'Storyboard', subtitle, showThumbnails = false, hideDefaultFlow, hideDefaultScene = false }) {
21
- const containerRef = useRef(null)
22
- const handleRef = useRef(null)
23
-
24
- const shouldHideDefault = hideDefaultFlow ?? hideDefaultScene
25
-
26
- const knownRoutes = useMemo(() => Object.keys(pageModules)
27
- .map(p => p.replace('/src/prototypes/', '').replace('.jsx', ''))
28
- .filter(n => !n.startsWith('_') && n !== 'index' && n !== 'viewfinder'),
29
- [pageModules])
30
-
31
- useEffect(() => {
32
- if (!containerRef.current) return
33
-
34
- let cancelled = false
35
-
36
- import('@dfosco/storyboard-core/ui-runtime').then(({ mountViewfinder, unmountViewfinder }) => {
37
- if (cancelled) return
38
- // Ensure clean state for re-mounts
39
- unmountViewfinder()
40
- handleRef.current = mountViewfinder(containerRef.current, {
41
- title,
42
- subtitle,
43
- basePath,
44
- knownRoutes,
45
- showThumbnails,
46
- hideDefaultFlow: shouldHideDefault,
47
- })
48
- // Wait for styles to be fully loaded before revealing
49
- handleRef.current.ready.then(() => {
50
- requestAnimationFrame(() => {
51
- if (containerRef.current) containerRef.current.style.opacity = '1'
52
- })
53
- })
54
- })
55
-
56
- return () => {
57
- cancelled = true
58
- if (handleRef.current) {
59
- handleRef.current.destroy()
60
- handleRef.current = null
61
- }
62
- }
63
- }, [title, subtitle, basePath, knownRoutes, showThumbnails, shouldHideDefault])
64
-
65
- return <div ref={containerRef} style={{
66
- minHeight: '100vh',
67
- background: 'var(--bgColor-default, #0d1117)',
68
- opacity: 0,
69
- transition: 'opacity 0.15s ease',
70
- }} />
71
- }
72
-