@dfosco/storyboard-react 4.0.0-beta.37 → 4.0.0-beta.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-react",
3
- "version": "4.0.0-beta.37",
3
+ "version": "4.0.0-beta.39",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@dfosco/storyboard-core": "4.0.0-beta.37",
7
- "@dfosco/tiny-canvas": "4.0.0-beta.37",
6
+ "@base-ui/react": "^1.4.0",
7
+ "@dfosco/storyboard-core": "4.0.0-beta.39",
8
+ "@dfosco/tiny-canvas": "4.0.0-beta.39",
8
9
  "@neodrag/react": "^2.3.1",
9
10
  "glob": "^11.0.0",
10
11
  "jsonc-parser": "^3.3.1",
@@ -673,12 +673,10 @@ function useBranches(basePath) {
673
673
  .then(data => { if (data?.name) setGitUser(data.name) })
674
674
  .catch(() => {})
675
675
 
676
- // If no branches from window global, fetch from server API
677
- if (!branches) {
678
- fetch(`${apiBase}/_storyboard/worktrees`).then(r => r.ok ? r.json() : null)
679
- .then(data => { if (Array.isArray(data) && data.length > 0) setBranches(data) })
680
- .catch(() => {})
681
- }
676
+ // Always fetch live branch list from server API
677
+ fetch(`${apiBase}/_storyboard/worktrees`).then(r => r.ok ? r.json() : null)
678
+ .then(data => { if (Array.isArray(data) && data.length > 0) setBranches(data) })
679
+ .catch(() => {})
682
680
  }, [])
683
681
 
684
682
  const currentBranch = useMemo(() => {
@@ -715,9 +713,20 @@ function BranchDropdown({ basePath }) {
715
713
  .filter(b => !b.lastModified || new Date(b.lastModified).getTime() > twoWeeksAgo)
716
714
  .sort((a, b) => (a.branch || '').localeCompare(b.branch || ''))
717
715
 
716
+ const isLocalDev = typeof window !== 'undefined' && window.__SB_LOCAL_DEV__ === true
717
+
718
718
  const switchBranch = async (branch) => {
719
719
  setSwitching(branch)
720
720
  setSwitchError(null)
721
+
722
+ if (!isLocalDev) {
723
+ // Prod: direct navigation
724
+ const target = branches?.find(b => b.branch === branch)
725
+ window.location.href = `${branchBasePath}${target?.folder || (branch === 'main' ? '' : `branch--${branch}/`)}`
726
+ return
727
+ }
728
+
729
+ // Dev: call switch-branch API to spin up server
721
730
  const apiBase = (basePath || '/').replace(/\/$/, '')
722
731
  try {
723
732
  const res = await fetch(`${apiBase}/_storyboard/switch-branch`, {
@@ -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,43 +215,22 @@ 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
- if (github) {
219
- return (
220
- <GitHubIssueCard
221
- url={url}
222
- title={title}
223
- github={github}
224
- width={width}
225
- collapsed={!!props?.collapsed}
226
- onUpdate={onUpdate}
227
- />
228
- )
229
- }
230
-
218
+ // All hooks must be called before any early return
231
219
  const ogImage = props?.ogImage || null
232
220
  const description = props?.description || ''
233
221
  const canEdit = typeof onUpdate === 'function'
234
222
  const cardRef = useRef(null)
235
223
  const inputRef = useRef(null)
236
224
  const [editing, setEditing] = useState(false)
237
- const [editValue, setEditValue] = useState(title)
238
-
239
- // Sync editValue when title prop changes externally
240
- useEffect(() => { setEditValue(title) }, [title])
241
225
 
242
226
  const startEditing = useCallback(() => {
243
227
  if (!canEdit) return
244
- setEditValue(title)
245
228
  setEditing(true)
246
- }, [canEdit, title])
229
+ }, [canEdit])
247
230
 
248
- const commitEdit = useCallback(() => {
249
- setEditing(false)
250
- const trimmed = editValue.trim()
251
- if (trimmed !== title) {
252
- onUpdate?.({ title: trimmed })
253
- }
254
- }, [editValue, title, onUpdate])
231
+ const handleTitleChange = useCallback((e) => {
232
+ onUpdate?.({ title: e.target.value })
233
+ }, [onUpdate])
255
234
 
256
235
  useEffect(() => {
257
236
  if (editing && inputRef.current) {
@@ -260,6 +239,19 @@ export default function LinkPreview({ id, props, onUpdate, resizable }) {
260
239
  }
261
240
  }, [editing])
262
241
 
242
+ if (github) {
243
+ return (
244
+ <GitHubIssueCard
245
+ url={url}
246
+ title={title}
247
+ github={github}
248
+ width={width}
249
+ collapsed={!!props?.collapsed}
250
+ onUpdate={onUpdate}
251
+ />
252
+ )
253
+ }
254
+
263
255
  const sizeStyle = (width || height)
264
256
  ? { ...(width ? { width: `${width}px` } : {}), ...(height ? { minHeight: `${height}px` } : {}) }
265
257
  : undefined
@@ -270,56 +262,57 @@ export default function LinkPreview({ id, props, onUpdate, resizable }) {
270
262
  const handleResize = (w, h) => onUpdate?.({ width: w, height: h })
271
263
 
272
264
  return (
273
- <div ref={cardRef} className={styles.card} style={sizeStyle}>
274
- {ogImage && (
275
- <img
276
- className={styles.ogImage}
277
- src={ogImage}
278
- alt=""
279
- loading="lazy"
280
- onError={(e) => { e.target.style.display = 'none' }}
281
- />
282
- )}
283
- <div className={styles.body}>
284
- {editing ? (
285
- <input
286
- ref={inputRef}
287
- className={styles.titleInput}
288
- data-canvas-allow-text-selection
289
- type="text"
290
- value={editValue}
291
- onChange={(e) => setEditValue(e.target.value)}
292
- onBlur={commitEdit}
293
- onKeyDown={(e) => {
294
- if (e.key === 'Enter') commitEdit()
295
- if (e.key === 'Escape') setEditing(false)
296
- }}
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}
297
310
  onMouseDown={(e) => e.stopPropagation()}
298
311
  onPointerDown={(e) => e.stopPropagation()}
299
- />
300
- ) : (
301
- <p
302
- className={styles.title}
303
- data-canvas-allow-text-selection={!canEdit ? '' : undefined}
304
- onDoubleClick={startEditing}
305
- role={canEdit ? 'button' : undefined}
306
- tabIndex={canEdit ? 0 : undefined}
307
- onKeyDown={canEdit ? (e) => { if (e.key === 'Enter') startEditing() } : undefined}
308
312
  >
309
- {title || hostname || url || 'Untitled'}
310
- </p>
311
- )}
312
- {description && <p className={styles.description}>{description}</p>}
313
- <a
314
- href={url || '#'}
315
- target="_blank"
316
- rel="noopener noreferrer"
317
- className={styles.url}
318
- onMouseDown={(e) => e.stopPropagation()}
319
- onPointerDown={(e) => e.stopPropagation()}
320
- >
321
- {hostname || url}
322
- </a>
313
+ {hostname || url}
314
+ </a>
315
+ </div>
323
316
  </div>
324
317
  {resizable && <ResizeHandle targetRef={cardRef} width={width} height={height} onResize={handleResize} />}
325
318
  </div>
@@ -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
  })
@@ -834,7 +834,7 @@ export default function storyboardDataPlugin() {
834
834
  // can't trace into its deps. Include the remark entry points so
835
835
  // Vite pre-bundles the full chain — covers all transitive CJS
836
836
  // packages (debug, extend, etc.) without whack-a-mole.
837
- include: ['remark', 'remark-gfm', 'remark-html'],
837
+ include: ['remark', 'remark-gfm', 'remark-html', 'use-sync-external-store/shim', 'use-sync-external-store/shim/with-selector'],
838
838
  exclude: ['@dfosco/storyboard-react'],
839
839
  },
840
840
  }