@dfosco/storyboard-react 4.2.0-beta.2 → 4.2.0-beta.21

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 (85) hide show
  1. package/package.json +9 -4
  2. package/src/AuthModal/AuthModal.jsx +6 -2
  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 +478 -186
  8. package/src/CommandPalette/command-palette.css +142 -78
  9. package/src/Icon.jsx +157 -58
  10. package/src/Viewfinder.jsx +561 -191
  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 +10 -6
  15. package/src/canvas/CanvasPage.jsx +738 -216
  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 -153
  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/connectorGeometry.js +132 -0
  23. package/src/canvas/hotPoolDevLogs.js +25 -0
  24. package/src/canvas/useCanvas.js +1 -1
  25. package/src/canvas/useMarqueeSelect.js +30 -4
  26. package/src/canvas/widgets/CodePenEmbed.jsx +1 -0
  27. package/src/canvas/widgets/ComponentSetWidget.jsx +199 -0
  28. package/src/canvas/widgets/ComponentSetWidget.module.css +89 -0
  29. package/src/canvas/widgets/ComponentWidget.jsx +1 -0
  30. package/src/canvas/widgets/CropOverlay.jsx +219 -0
  31. package/src/canvas/widgets/CropOverlay.module.css +118 -0
  32. package/src/canvas/widgets/ExpandedPane.jsx +472 -0
  33. package/src/canvas/widgets/ExpandedPane.module.css +179 -0
  34. package/src/canvas/widgets/ExpandedPane.test.jsx +240 -0
  35. package/src/canvas/widgets/ExpandedPaneTopBar.jsx +111 -0
  36. package/src/canvas/widgets/ExpandedPaneTopBar.module.css +59 -0
  37. package/src/canvas/widgets/ExpandedPaneTopBar.test.jsx +45 -0
  38. package/src/canvas/widgets/FigmaEmbed.jsx +62 -47
  39. package/src/canvas/widgets/FigmaEmbed.module.css +61 -0
  40. package/src/canvas/widgets/ImageWidget.jsx +130 -9
  41. package/src/canvas/widgets/ImageWidget.module.css +30 -0
  42. package/src/canvas/widgets/LinkPreview.jsx +112 -4
  43. package/src/canvas/widgets/LinkPreview.module.css +127 -0
  44. package/src/canvas/widgets/MarkdownBlock.jsx +164 -17
  45. package/src/canvas/widgets/MarkdownBlock.module.css +148 -0
  46. package/src/canvas/widgets/PromptWidget.jsx +414 -0
  47. package/src/canvas/widgets/PromptWidget.module.css +273 -0
  48. package/src/canvas/widgets/PrototypeEmbed.jsx +77 -38
  49. package/src/canvas/widgets/PrototypeEmbed.module.css +117 -0
  50. package/src/canvas/widgets/PrototypeEmbed.test.jsx +2 -2
  51. package/src/canvas/widgets/ResizeHandle.jsx +17 -6
  52. package/src/canvas/widgets/StoryWidget.jsx +72 -15
  53. package/src/canvas/widgets/TerminalReadWidget.jsx +146 -0
  54. package/src/canvas/widgets/TerminalReadWidget.module.css +94 -0
  55. package/src/canvas/widgets/TerminalWidget.jsx +496 -69
  56. package/src/canvas/widgets/TerminalWidget.module.css +271 -8
  57. package/src/canvas/widgets/TilesWidget.jsx +302 -0
  58. package/src/canvas/widgets/TilesWidget.module.css +133 -0
  59. package/src/canvas/widgets/WidgetChrome.jsx +73 -153
  60. package/src/canvas/widgets/WidgetChrome.module.css +30 -1
  61. package/src/canvas/widgets/embedInteraction.test.jsx +24 -26
  62. package/src/canvas/widgets/expandUtils.js +557 -0
  63. package/src/canvas/widgets/expandUtils.test.js +155 -0
  64. package/src/canvas/widgets/index.js +9 -0
  65. package/src/canvas/widgets/snapshotDisplay.test.jsx +23 -71
  66. package/src/canvas/widgets/tilePool.js +23 -0
  67. package/src/canvas/widgets/tiles/diagonal-bl.png +0 -0
  68. package/src/canvas/widgets/tiles/diagonal-br.png +0 -0
  69. package/src/canvas/widgets/tiles/diagonal-tl.png +0 -0
  70. package/src/canvas/widgets/tiles/leaf.png +0 -0
  71. package/src/canvas/widgets/tiles/quarter-tl.png +0 -0
  72. package/src/canvas/widgets/tiles/quarter-tr.png +0 -0
  73. package/src/canvas/widgets/tiles/solid-a.png +0 -0
  74. package/src/canvas/widgets/tiles/solid-b.png +0 -0
  75. package/src/canvas/widgets/widgetConfig.js +55 -4
  76. package/src/canvas/widgets/widgetIcons.jsx +190 -0
  77. package/src/canvas/widgets/widgetProps.js +1 -0
  78. package/src/context.jsx +47 -19
  79. package/src/hooks/useConfig.js +14 -0
  80. package/src/hooks/usePrototypeReloadGuard.js +64 -0
  81. package/src/index.js +8 -2
  82. package/src/story/ComponentSetPage.jsx +186 -0
  83. package/src/story/ComponentSetPage.module.css +121 -0
  84. package/src/story/StoryPage.jsx +32 -2
  85. package/src/vite/data-plugin.js +324 -30
@@ -1,14 +1,16 @@
1
1
  /**
2
- * Viewfinder — SaaS-style homescreen for Storyboard.
2
+ * Workspace — SaaS-style homescreen for Storyboard.
3
3
  *
4
- * Replaces the old list-based Viewfinder with a sidebar + grid layout.
5
- * Wired to real data from buildPrototypeIndex and listStories.
4
+ * Sidebar + grid layout wired to real data from buildPrototypeIndex and listStories.
5
+ * Formerly known as Viewfinder renamed to match the /workspace route.
6
6
  */
7
- import { useState, useEffect, useMemo, useCallback, useSyncExternalStore } from 'react'
8
- import { buildPrototypeIndex, listStories, getStoryData, getLocal, setLocal } from '@dfosco/storyboard-core'
9
- import { MarkGithubIcon, GitBranchIcon, ChevronDownIcon, ChevronRightIcon, FileDirectoryFillIcon, PlusIcon, StarIcon, StarFillIcon, ThreeBarsIcon, XIcon, StackIcon } from '@primer/octicons-react'
7
+ import { useState, useEffect, useRef, useMemo, useCallback, useSyncExternalStore } from 'react'
8
+ import { buildPrototypeIndex, listStories, getStoryData, getLocal, setLocal, BranchSelect } from '@dfosco/storyboard-core'
9
+ import { MarkGithubIcon, GitBranchIcon, ChevronDownIcon, ChevronRightIcon, FileDirectoryFillIcon, PlusIcon, StarIcon, StarFillIcon, ThreeBarsIcon, XIcon, StackIcon, TrashIcon, ShieldLockIcon, KebabHorizontalIcon, PencilIcon } from '@primer/octicons-react'
10
10
  import { Menu } from '@base-ui/react/menu'
11
+ import { Dialog } from '@base-ui/react/dialog'
11
12
  import Icon from './Icon.jsx'
13
+ import { useBranches } from './BranchBar/useBranches.js'
12
14
  import css from './Viewfinder.module.css'
13
15
 
14
16
  /* ─── Theme sync: read toolbar theme from DOM and apply to Primer/BaseUI ─── */
@@ -45,12 +47,58 @@ function useToolbarTheme() {
45
47
  return attrs
46
48
  }
47
49
 
50
+ /* ─── GitHub user hook ─── */
51
+
52
+ const COMMENTS_USER_KEY = 'sb-comments-user'
53
+ const COMMENTS_TOKEN_KEY = 'sb-comments-token'
54
+
55
+ /**
56
+ * Resolve the current GitHub user for display in the sidebar.
57
+ * Priority: 1) PAT-cached user (from comments auth), 2) gh CLI login via git-user endpoint.
58
+ */
59
+ function useGitHubUser(basePath) {
60
+ const [user, setUser] = useState(() => {
61
+ try {
62
+ const raw = localStorage.getItem(COMMENTS_USER_KEY)
63
+ const token = localStorage.getItem(COMMENTS_TOKEN_KEY)
64
+ if (token && raw) {
65
+ const parsed = JSON.parse(raw)
66
+ if (parsed?.login) return parsed
67
+ }
68
+ } catch { /* ignore */ }
69
+ return null
70
+ })
71
+
72
+ // Listen for auth changes (when user signs in via AuthModal)
73
+ useEffect(() => {
74
+ const handler = () => {
75
+ try {
76
+ const raw = localStorage.getItem(COMMENTS_USER_KEY)
77
+ const token = localStorage.getItem(COMMENTS_TOKEN_KEY)
78
+ if (token && raw) {
79
+ const parsed = JSON.parse(raw)
80
+ if (parsed?.login) { setUser(parsed); return }
81
+ }
82
+ setUser(null)
83
+ } catch { setUser(null) }
84
+ }
85
+ window.addEventListener('storage', handler)
86
+ document.addEventListener('storyboard:auth-changed', handler)
87
+ return () => {
88
+ window.removeEventListener('storage', handler)
89
+ document.removeEventListener('storyboard:auth-changed', handler)
90
+ }
91
+ }, [])
92
+
93
+ return user
94
+ }
95
+
48
96
  /* ─── localStorage helpers ─── */
49
97
 
50
- const STARRED_KEY = 'sb-viewfinder-starred'
51
- const RECENT_KEY = 'sb-viewfinder-recent'
98
+ const STARRED_KEY = 'sb-workspace-starred'
99
+ const RECENT_KEY = 'sb-workspace-recent'
52
100
  const MAX_RECENT = 30
53
- const GROUP_BY_FOLDERS_KEY = 'sb-viewfinder-group-folders'
101
+ const GROUP_BY_FOLDERS_KEY = 'sb-workspace-group-folders'
54
102
 
55
103
  function readJSON(key, fallback) {
56
104
  try { return JSON.parse(localStorage.getItem(key)) || fallback }
@@ -128,7 +176,7 @@ function getTypeLabel(type) {
128
176
  function getTypeIcon(type, size = 14) {
129
177
  if (type === 'prototype') return <Icon name="prototype" size={size} />
130
178
  if (type === 'canvas') return <Icon name="canvas" size={size} />
131
- if (type === 'component') return <Icon name="component" size={size} />
179
+ if (type === 'component') return <Icon name="iconoir/keyframe" size={size} />
132
180
  return null
133
181
  }
134
182
 
@@ -157,10 +205,13 @@ function AvatarStack({ authors }) {
157
205
 
158
206
  /* ─── Star Button ─── */
159
207
 
160
- function StarBtn({ active, onClick }) {
208
+ function StarBtn({ active, onClick, inline }) {
209
+ const cls = inline
210
+ ? (active ? css.iconBtnInlineActive : css.iconBtnInline)
211
+ : (active ? css.iconBtnActive : css.iconBtn)
161
212
  return (
162
213
  <button
163
- className={active ? css.iconBtnActive : css.iconBtn}
214
+ className={cls}
164
215
  onClick={(e) => { e.preventDefault(); e.stopPropagation(); onClick() }}
165
216
  aria-label={active ? 'Remove favorite' : 'Favorite'}
166
217
  title={active ? 'Remove favorite' : 'Favorite'}
@@ -170,9 +221,251 @@ function StarBtn({ active, onClick }) {
170
221
  )
171
222
  }
172
223
 
224
+ /* ─── Card Actions Menu ─── */
225
+
226
+ function CardActionsMenu({ typeLabel, onEdit, onDelete }) {
227
+ return (
228
+ <Menu.Root>
229
+ <Menu.Trigger
230
+ className={css.iconBtn}
231
+ onClick={(e) => { e.preventDefault(); e.stopPropagation() }}
232
+ aria-label="Actions"
233
+ render={<button />}
234
+ >
235
+ <KebabHorizontalIcon size={16} />
236
+ </Menu.Trigger>
237
+ <Menu.Portal>
238
+ <Menu.Positioner className={css.actionsMenuPositioner} side="bottom" alignment="end">
239
+ <Menu.Popup className={css.actionsMenu} onClick={(e) => { e.preventDefault(); e.stopPropagation() }}>
240
+ <Menu.Item
241
+ className={css.actionsMenuItem}
242
+ onClick={(e) => { e.preventDefault(); e.stopPropagation(); onEdit() }}
243
+ render={<button />}
244
+ >
245
+ <PencilIcon size={16} />
246
+ Edit {typeLabel}
247
+ </Menu.Item>
248
+ <Menu.Item
249
+ className={css.actionsMenuItemDanger}
250
+ onClick={(e) => { e.preventDefault(); e.stopPropagation(); onDelete() }}
251
+ render={<button />}
252
+ >
253
+ <TrashIcon size={16} />
254
+ Delete {typeLabel}
255
+ </Menu.Item>
256
+ </Menu.Popup>
257
+ </Menu.Positioner>
258
+ </Menu.Portal>
259
+ </Menu.Root>
260
+ )
261
+ }
262
+
263
+ /* ─── Edit Artifact Modal ─── */
264
+
265
+ function EditArtifactModal({ item, dirName, basePath, onClose }) {
266
+ const [name, setName] = useState(item.name || '')
267
+ const [description, setDescription] = useState(item.description || '')
268
+ const [author, setAuthor] = useState(
269
+ item.author
270
+ ? (Array.isArray(item.author) ? item.author.join(', ') : item.author)
271
+ : ''
272
+ )
273
+ const [error, setError] = useState('')
274
+ const [submitting, setSubmitting] = useState(false)
275
+ const overlayRef = useRef(null)
276
+
277
+ const typeLabel = item.type === 'canvas' ? 'Canvas' : item.type === 'prototype' ? 'Prototype' : 'Component'
278
+
279
+ const handleSubmit = async (e) => {
280
+ e.preventDefault()
281
+ setError('')
282
+ setSubmitting(true)
283
+
284
+ const apiBase = (basePath || '/').replace(/\/+$/, '')
285
+ let endpoint
286
+
287
+ if (item.type === 'canvas') {
288
+ endpoint = `${apiBase}/_storyboard/canvas/update-meta`
289
+ } else if (item.type === 'prototype') {
290
+ endpoint = `${apiBase}/_storyboard/workshop/prototypes`
291
+ } else {
292
+ setError('Editing this type is not supported')
293
+ setSubmitting(false)
294
+ return
295
+ }
296
+
297
+ const body = {
298
+ name: dirName,
299
+ title: name.trim(),
300
+ description: description.trim(),
301
+ author: author.trim(),
302
+ }
303
+ if (item.folder) body.folder = item.folder
304
+
305
+ try {
306
+ const res = await fetch(endpoint, {
307
+ method: 'PUT',
308
+ headers: { 'Content-Type': 'application/json' },
309
+ body: JSON.stringify(body),
310
+ })
311
+ if (!res.ok) {
312
+ const text = await res.text()
313
+ throw new Error(text || `Request failed (${res.status})`)
314
+ }
315
+ window.location.reload()
316
+ } catch (err) {
317
+ setError(err.message)
318
+ setSubmitting(false)
319
+ }
320
+ }
321
+
322
+ useEffect(() => {
323
+ const handleKey = (e) => { if (e.key === 'Escape') onClose() }
324
+ document.addEventListener('keydown', handleKey)
325
+ return () => document.removeEventListener('keydown', handleKey)
326
+ }, [onClose])
327
+
328
+ return (
329
+ <div className={css.modalOverlay} onClick={(e) => { if (e.target === overlayRef.current) onClose() }} ref={overlayRef}>
330
+ <div className={css.modalContent} onClick={(e) => e.stopPropagation()}>
331
+ <form onSubmit={handleSubmit}>
332
+ <div className={css.createFormHeader}>
333
+ <div className={css.createMenuTitle}>Edit {typeLabel}</div>
334
+ <button type="button" className={css.createFormClose} onClick={onClose} aria-label="Close">
335
+ <XIcon size={16} />
336
+ </button>
337
+ </div>
338
+
339
+ <div className={css.createFormField}>
340
+ <label className={css.createFormLabel}>Name</label>
341
+ <input
342
+ className={css.createFormInput}
343
+ value={name}
344
+ onChange={e => setName(e.target.value)}
345
+ autoFocus
346
+ />
347
+ </div>
348
+
349
+ <div className={css.createFormField}>
350
+ <label className={css.createFormLabel}>Description</label>
351
+ <input
352
+ className={css.createFormInput}
353
+ value={description}
354
+ onChange={e => setDescription(e.target.value)}
355
+ placeholder="Optional description"
356
+ />
357
+ </div>
358
+
359
+ <div className={css.createFormField}>
360
+ <label className={css.createFormLabel}>Author</label>
361
+ <input
362
+ className={css.createFormInput}
363
+ value={author}
364
+ onChange={e => setAuthor(e.target.value)}
365
+ placeholder="GitHub username(s), comma-separated"
366
+ />
367
+ </div>
368
+
369
+ {error && <div className={css.createFormError}>{error}</div>}
370
+
371
+ <div className={css.modalActions}>
372
+ <button type="button" className={css.modalCancelBtn} onClick={onClose}>Cancel</button>
373
+ <button type="submit" className={css.modalSubmitBtn} disabled={submitting}>
374
+ {submitting ? 'Saving…' : 'Save Changes'}
375
+ </button>
376
+ </div>
377
+ </form>
378
+ </div>
379
+ </div>
380
+ )
381
+ }
382
+
383
+ /* ─── Delete Artifact Modal ─── */
384
+
385
+ function DeleteArtifactModal({ item, dirName, basePath, typeLabel, onClose, onDeleted }) {
386
+ const [error, setError] = useState('')
387
+ const [deleting, setDeleting] = useState(false)
388
+ const overlayRef = useRef(null)
389
+
390
+ const handleDelete = async () => {
391
+ setError('')
392
+ setDeleting(true)
393
+
394
+ const apiBase = (basePath || '/').replace(/\/+$/, '')
395
+ let endpoint
396
+
397
+ if (item.type === 'canvas') {
398
+ endpoint = `${apiBase}/_storyboard/canvas/delete-canvas`
399
+ } else if (item.type === 'prototype') {
400
+ endpoint = `${apiBase}/_storyboard/workshop/prototypes`
401
+ } else {
402
+ setError('Deleting this type is not supported')
403
+ setDeleting(false)
404
+ return
405
+ }
406
+
407
+ const body = { name: dirName }
408
+ if (item.folder) body.folder = item.folder
409
+
410
+ try {
411
+ const res = await fetch(endpoint, {
412
+ method: 'DELETE',
413
+ headers: { 'Content-Type': 'application/json' },
414
+ body: JSON.stringify(body),
415
+ })
416
+ if (!res.ok) {
417
+ const text = await res.text()
418
+ throw new Error(text || `Request failed (${res.status})`)
419
+ }
420
+ onDeleted?.()
421
+ onClose()
422
+ } catch (err) {
423
+ setError(err.message)
424
+ setDeleting(false)
425
+ }
426
+ }
427
+
428
+ useEffect(() => {
429
+ const handleKey = (e) => { if (e.key === 'Escape') onClose() }
430
+ document.addEventListener('keydown', handleKey)
431
+ return () => document.removeEventListener('keydown', handleKey)
432
+ }, [onClose])
433
+
434
+ return (
435
+ <div className={css.modalOverlay} onClick={(e) => { if (e.target === overlayRef.current) onClose() }} ref={overlayRef}>
436
+ <div className={css.modalContent} onClick={(e) => e.stopPropagation()}>
437
+ <div className={css.createFormHeader}>
438
+ <div className={css.createMenuTitle}>Delete {typeLabel}</div>
439
+ <button type="button" className={css.createFormClose} onClick={onClose} aria-label="Close">
440
+ <XIcon size={16} />
441
+ </button>
442
+ </div>
443
+
444
+ <p className={css.deleteMessage}>
445
+ Are you sure you want to delete <strong>{item.name}</strong>? This action cannot be undone.
446
+ </p>
447
+
448
+ {error && <div className={css.createFormError}>{error}</div>}
449
+
450
+ <div className={css.modalActions}>
451
+ <button type="button" className={css.modalCancelBtn} onClick={onClose}>Cancel</button>
452
+ <button
453
+ type="button"
454
+ className={css.deleteConfirmBtn}
455
+ onClick={handleDelete}
456
+ disabled={deleting}
457
+ >
458
+ {deleting ? 'Deleting…' : `Delete ${typeLabel}`}
459
+ </button>
460
+ </div>
461
+ </div>
462
+ </div>
463
+ )
464
+ }
465
+
173
466
  /* ─── Artifact Card ─── */
174
467
 
175
- function ArtifactCard({ item, basePath, starred, onToggleStar }) {
468
+ function ArtifactCard({ item, basePath, starred, onToggleStar, onItemDeleted }) {
176
469
  const href = item.route ? withBase(basePath, item.route) : '#'
177
470
  const isExternal = item.isExternal
178
471
 
@@ -189,36 +482,73 @@ function ArtifactCard({ item, basePath, starred, onToggleStar }) {
189
482
  ? (Array.isArray(item.author) ? item.author : [item.author])
190
483
  : item.gitAuthor ? [item.gitAuthor] : []
191
484
 
485
+ const [showEdit, setShowEdit] = useState(false)
486
+ const [showDelete, setShowDelete] = useState(false)
487
+
488
+ // Extract dirName from item.id (format: "type:dirName")
489
+ const dirName = item.id.split(':').slice(1).join(':')
490
+ const typeLabel = item.type === 'canvas' ? 'Canvas' : item.type === 'prototype' ? 'Prototype' : 'Component'
491
+ const canEditDelete = item.type === 'canvas' || item.type === 'prototype'
492
+
192
493
  return (
193
- <Tag className={css.card} {...linkProps} onClick={handleClick}>
194
- <div className={css.cardHeader}>
195
- <span className={css.cardBadge}>{getTypeLabel(item.type)}</span>
196
- <div className={css.cardActions}>
197
- {item.flows?.length > 0 && <FlowsDropdown flows={item.flows} basePath={basePath} />}
198
- {item.pages?.length > 1 && <PagesDropdown pages={item.pages} basePath={basePath} />}
199
- <StarBtn active={starred} onClick={() => onToggleStar(item.id)} />
200
- </div>
201
- </div>
202
- <div className={css.cardBody}>
203
- <div className={css.cardBodyContent}>
204
- <div className={css.cardTitle}>
205
- {item.name}
206
- {isExternal && <span className={css.externalBadge}>↗</span>}
494
+ <>
495
+ <Tag className={css.card} {...linkProps} onClick={handleClick}>
496
+ <div className={css.cardHeader}>
497
+ <span className={css.cardBadge}>{getTypeLabel(item.type)}</span>
498
+ <div className={css.cardActions}>
499
+ {item.flows?.length > 0 && <FlowsDropdown flows={item.flows} basePath={basePath} />}
500
+ {item.pages?.length > 1 && <PagesDropdown pages={item.pages} basePath={basePath} />}
501
+ {canEditDelete && (
502
+ <CardActionsMenu
503
+ typeLabel={typeLabel}
504
+ onEdit={() => setShowEdit(true)}
505
+ onDelete={() => setShowDelete(true)}
506
+ />
507
+ )}
207
508
  </div>
208
- {item.description && (
209
- <div className={css.cardDescription}>{item.description}</div>
210
- )}
211
- <div className={css.cardFooter}>
212
- <AvatarStack authors={authorList} />
213
- <div className={css.cardMeta}>
214
- {authorList.length > 0 && <span>{authorList.join(', ')}</span>}
215
- {authorList.length > 0 && formatRelativeTime(item.lastModified) && <span className={css.cardMetaDot} />}
216
- {formatRelativeTime(item.lastModified) && <span>{formatRelativeTime(item.lastModified)}</span>}
509
+ </div>
510
+ <div className={css.cardBody}>
511
+ <div className={css.cardBodyContent}>
512
+ <div className={css.cardTitleRow}>
513
+ <div className={css.cardTitle}>
514
+ {item.name}
515
+ {isExternal && <span className={css.externalBadge}>↗</span>}
516
+ </div>
517
+ <StarBtn active={starred} onClick={() => onToggleStar(item.id)} inline />
518
+ </div>
519
+ {item.description && (
520
+ <div className={css.cardDescription}>{item.description}</div>
521
+ )}
522
+ <div className={css.cardFooter}>
523
+ <AvatarStack authors={authorList} />
524
+ <div className={css.cardMeta}>
525
+ {authorList.length > 0 && <span>{authorList.join(', ')}</span>}
526
+ {authorList.length > 0 && formatRelativeTime(item.lastModified) && <span className={css.cardMetaDot} />}
527
+ {formatRelativeTime(item.lastModified) && <span>{formatRelativeTime(item.lastModified)}</span>}
528
+ </div>
217
529
  </div>
218
530
  </div>
219
531
  </div>
220
- </div>
221
- </Tag>
532
+ </Tag>
533
+ {showEdit && (
534
+ <EditArtifactModal
535
+ item={item}
536
+ dirName={dirName}
537
+ basePath={basePath}
538
+ onClose={() => setShowEdit(false)}
539
+ />
540
+ )}
541
+ {showDelete && (
542
+ <DeleteArtifactModal
543
+ item={item}
544
+ dirName={dirName}
545
+ basePath={basePath}
546
+ typeLabel={typeLabel}
547
+ onClose={() => setShowDelete(false)}
548
+ onDeleted={() => onItemDeleted?.(item.id)}
549
+ />
550
+ )}
551
+ </>
222
552
  )
223
553
  }
224
554
 
@@ -316,7 +646,7 @@ function PagesDropdown({ pages, basePath }) {
316
646
 
317
647
  /* ─── Folder Section ─── */
318
648
 
319
- function FolderSection({ folder, collapsed, onToggle, basePath, starred, onToggleStar }) {
649
+ function FolderSection({ folder, collapsed, onToggle, basePath, starred, onToggleStar, onItemDeleted }) {
320
650
  return (
321
651
  <section className={collapsed ? css.folderSectionCollapsed : css.folderSection}>
322
652
  <button className={css.folderHeader} onClick={onToggle}>
@@ -337,6 +667,7 @@ function FolderSection({ folder, collapsed, onToggle, basePath, starred, onToggl
337
667
  basePath={basePath}
338
668
  starred={starred.has(item.id)}
339
669
  onToggleStar={onToggleStar}
670
+ onItemDeleted={onItemDeleted}
340
671
  />
341
672
  ))}
342
673
  </div>
@@ -545,7 +876,7 @@ function CreateMenu({ onClose, basePath }) {
545
876
  const items = [
546
877
  { icon: <Icon name="canvas" size={18} />, title: 'Canvas', desc: 'Interactive board for prototypes, components, and documents' },
547
878
  { icon: <Icon name="prototype" size={18} />, title: 'Prototype', desc: 'Interactive page flow' },
548
- { icon: <Icon name="component" size={18} />, title: 'Component', desc: 'Reusable component' },
879
+ { icon: <Icon name="iconoir/-couple-solid" size={18} />, title: 'Component', desc: 'Reusable component' },
549
880
  ]
550
881
 
551
882
  const moreItems = [
@@ -615,147 +946,134 @@ const NAV_ITEMS = [
615
946
 
616
947
  const TAB_FILTERS = ['All', 'Recent', 'Starred']
617
948
 
618
- /* ─── Branch Dropdown ─── */
949
+ /* ─── Branch Navigation ─── */
619
950
 
620
- function useBranches(basePath) {
621
- const [branches, setBranches] = useState(() => {
622
- if (typeof window !== 'undefined' && Array.isArray(window.__SB_BRANCHES__)) {
623
- return window.__SB_BRANCHES__
624
- }
625
- return null
626
- })
627
-
628
- const [gitUser, setGitUser] = useState(null)
629
-
630
- useEffect(() => {
631
- const apiBase = (basePath || '/').replace(/\/$/, '')
632
-
633
- // Fetch git user info for "my branches" filtering
634
- fetch(`${apiBase}/_storyboard/git-user`).then(r => r.ok ? r.json() : null)
635
- .then(data => { if (data?.name) setGitUser(data.name) })
636
- .catch(() => {})
637
-
638
- // Always fetch live branch list from server API
639
- fetch(`${apiBase}/_storyboard/worktrees`).then(r => r.ok ? r.json() : null)
640
- .then(data => { if (Array.isArray(data) && data.length > 0) setBranches(data) })
641
- .catch(() => {})
642
- }, [])
643
-
644
- const currentBranch = useMemo(() => {
645
- const m = (basePath || '').match(/\/branch--([^/]+)\/?$/)
646
- return m ? m[1] : 'main'
647
- }, [basePath])
648
-
649
- const branchBasePath = (basePath || '/').replace(/\/branch--[^/]*\/$/, '/')
650
-
651
- return { branches, currentBranch, branchBasePath, gitUser }
652
- }
653
-
654
- function BranchDropdown({ basePath }) {
655
- // Dev: hide dropdown — use CLI to switch branches
951
+ function BranchNav({ basePath }) {
656
952
  const isLocalDev = typeof window !== 'undefined' && window.__SB_LOCAL_DEV__ === true
657
- if (isLocalDev) return null
658
-
659
- const { branches, currentBranch, branchBasePath, gitUser } = useBranches(basePath)
660
- const [showAll, setShowAll] = useState(false)
953
+ const { branches, currentBranch, branchBasePath } = useBranches(basePath)
661
954
  const [switching, setSwitching] = useState(null)
662
- const [switchError, setSwitchError] = useState(null)
663
955
 
664
956
  if (!branches || branches.length === 0) return null
665
957
 
666
- const twoWeeksAgo = Date.now() - 14 * 24 * 60 * 60 * 1000
958
+ const branchNames = branches.map(b => b.branch)
667
959
 
668
- // Split into "my branches" vs others
669
- const myBranches = gitUser
670
- ? branches.filter(b => b.author === gitUser || b.branch === currentBranch)
671
- : branches.filter(b => b.branch === currentBranch)
960
+ const navigate = async (branch) => {
961
+ if (switching) return
962
+ const target = branches.find(b => b.branch === branch)
963
+ const folder = target?.folder || (branch === 'main' ? '' : `branch--${branch}/`)
964
+ const directUrl = `${branchBasePath}${folder}`
672
965
 
673
- const otherBranches = branches.filter(b => !myBranches.some(m => m.branch === b.branch))
674
-
675
- // Recent = last 2 weeks (or all if showAll)
676
- const recentBranches = showAll
677
- ? [...otherBranches].sort((a, b) => (a.branch || '').localeCompare(b.branch || ''))
678
- : otherBranches
679
- .filter(b => !b.lastModified || new Date(b.lastModified).getTime() > twoWeeksAgo)
680
- .sort((a, b) => (a.branch || '').localeCompare(b.branch || ''))
966
+ if (!isLocalDev) {
967
+ window.location.href = directUrl
968
+ return
969
+ }
681
970
 
682
- const switchBranch = (branch) => {
971
+ // Local dev: ask server to spin up the branch, then navigate
683
972
  setSwitching(branch)
684
- const target = branches?.find(b => b.branch === branch)
685
- window.location.href = `${branchBasePath}${target?.folder || (branch === 'main' ? '' : `branch--${branch}/`)}`
973
+ const apiBase = (basePath || '/').replace(/\/$/, '')
974
+ try {
975
+ const res = await fetch(`${apiBase}/_storyboard/switch-branch`, {
976
+ method: 'POST',
977
+ headers: { 'Content-Type': 'application/json' },
978
+ body: JSON.stringify({ branch }),
979
+ })
980
+ const data = await res.json()
981
+ window.location.href = (res.ok && data.url) ? data.url : directUrl
982
+ } catch {
983
+ window.location.href = directUrl
984
+ }
686
985
  }
687
986
 
688
987
  return (
689
- <Menu.Root>
690
- <Menu.Trigger className={css.branchBtn} disabled={!!switching}>
988
+ <>
989
+ <div className={css.branchNav}>
691
990
  <GitBranchIcon size={14} />
692
- <span className={css.branchBtnText}>{switching ? `Switching to ${switching}…` : currentBranch}</span>
693
- {!switching && <ChevronDownIcon size={12} />}
694
- </Menu.Trigger>
695
- <Menu.Portal>
696
- <Menu.Positioner className={css.branchPositioner} side="bottom" align="end" sideOffset={4}>
697
- <Menu.Popup className={css.branchPopup}>
698
- {myBranches.length > 0 && (
699
- <>
700
- <div className={css.branchSectionLabel}>My branches</div>
701
- {myBranches.map(b => (
702
- <Menu.Item
703
- key={b.branch}
704
- className={`${css.branchItem}${b.branch === currentBranch ? ` ${css.branchItemActive}` : ''}`}
705
- onClick={() => switchBranch(b.branch)}
706
- >
707
- <GitBranchIcon size={12} />
708
- {b.branch}
709
- </Menu.Item>
710
- ))}
711
- </>
712
- )}
991
+ <BranchSelect
992
+ branches={branchNames}
993
+ value={currentBranch}
994
+ onChange={(e) => navigate(e.target.value)}
995
+ disabled={!!switching}
996
+ />
997
+ </div>
998
+ {switching && <div className={css.switchOverlay}>
999
+ <div className={css.switchSpinner} />
1000
+ <span>Starting {switching}…</span>
1001
+ </div>}
1002
+ </>
1003
+ )
1004
+ }
713
1005
 
714
- {myBranches.length > 0 && recentBranches.length > 0 && (
715
- <div className={css.branchSeparator} />
716
- )}
1006
+ /* ─── User Settings Dialog ─── */
717
1007
 
718
- {recentBranches.length > 0 && (
719
- <>
720
- <div className={css.branchSectionLabel}>
721
- {showAll ? 'All branches' : 'Recent branches'}
722
- </div>
723
- <Menu.Viewport className={css.branchViewport}>
724
- {recentBranches.map(b => (
725
- <Menu.Item
726
- key={b.branch}
727
- className={`${css.branchItem}${b.branch === currentBranch ? ` ${css.branchItemActive}` : ''}`}
728
- onClick={() => switchBranch(b.branch)}
729
- >
730
- <GitBranchIcon size={12} />
731
- {b.branch}
732
- </Menu.Item>
733
- ))}
734
- </Menu.Viewport>
735
- </>
736
- )}
1008
+ function UserSettingsDialog({ open, onOpenChange, user, onRemoveToken }) {
1009
+ const hasToken = (() => {
1010
+ try { return !!localStorage.getItem(COMMENTS_TOKEN_KEY) } catch { return false }
1011
+ })()
1012
+ const scopes = user?.scopes || []
1013
+ const isFineGrained = hasToken && scopes.length === 0
737
1014
 
738
- {!showAll && otherBranches.length > recentBranches.length && (
739
- <>
740
- <div className={css.branchSeparator} />
741
- <button
742
- className={css.branchShowAll}
743
- onClick={(e) => { e.stopPropagation(); setShowAll(true) }}
744
- >
745
- See all branches ({otherBranches.length})
746
- </button>
747
- </>
748
- )}
749
- </Menu.Popup>
750
- </Menu.Positioner>
751
- </Menu.Portal>
752
- </Menu.Root>
1015
+ return (
1016
+ <Dialog.Root open={open} onOpenChange={onOpenChange}>
1017
+ <Dialog.Portal>
1018
+ <Dialog.Backdrop className={css.settingsBackdrop} />
1019
+ <div className={css.settingsPopupWrap}>
1020
+ <Dialog.Popup className={css.settingsPopup}>
1021
+ <Dialog.Title className={css.settingsTitle}>Settings</Dialog.Title>
1022
+ <Dialog.Close className={css.settingsCloseBtn} aria-label="Close">×</Dialog.Close>
1023
+
1024
+ {/* GitHub connection section */}
1025
+ <div className={css.settingsSection}>
1026
+ <div className={css.settingsSectionHeader}>
1027
+ <ShieldLockIcon size={16} />
1028
+ <span>GitHub Connection</span>
1029
+ </div>
1030
+
1031
+ {hasToken ? (
1032
+ <div className={css.settingsTokenCard}>
1033
+ <div className={css.settingsTokenRow}>
1034
+ <span className={css.settingsTokenLabel}>Token</span>
1035
+ <code className={css.settingsTokenValue}>••••••••••••••••</code>
1036
+ </div>
1037
+ <div className={css.settingsTokenRow}>
1038
+ <span className={css.settingsTokenLabel}>Permissions</span>
1039
+ <span className={css.settingsTokenValue}>
1040
+ {isFineGrained
1041
+ ? 'Fine-grained token'
1042
+ : scopes.map(s => <code key={s} className={css.settingsScope}>{s}</code>)
1043
+ }
1044
+ </span>
1045
+ </div>
1046
+ <button className={css.settingsRemoveBtn} onClick={onRemoveToken}>
1047
+ <TrashIcon size={14} />
1048
+ Remove token
1049
+ </button>
1050
+ </div>
1051
+ ) : (
1052
+ <div className={css.settingsNoToken}>
1053
+ <p>No GitHub token configured.</p>
1054
+ <button
1055
+ className={css.settingsSignInBtn}
1056
+ onClick={() => {
1057
+ onOpenChange(false)
1058
+ document.dispatchEvent(new CustomEvent('storyboard:open-auth-modal'))
1059
+ }}
1060
+ >
1061
+ <MarkGithubIcon size={16} />
1062
+ Sign in with GitHub
1063
+ </button>
1064
+ </div>
1065
+ )}
1066
+ </div>
1067
+ </Dialog.Popup>
1068
+ </div>
1069
+ </Dialog.Portal>
1070
+ </Dialog.Root>
753
1071
  )
754
1072
  }
755
1073
 
756
1074
  /* ─── Main Component ─── */
757
1075
 
758
- export default function Viewfinder({
1076
+ export default function Workspace({
759
1077
  pageModules = {},
760
1078
  basePath,
761
1079
  title = 'Storyboard',
@@ -765,12 +1083,23 @@ export default function Viewfinder({
765
1083
  }) {
766
1084
  const shouldHideDefault = hideDefaultFlow ?? hideDefaultScene
767
1085
  const themeAttrs = useToolbarTheme()
1086
+ const ghUser = useGitHubUser(basePath)
1087
+ const [settingsOpen, setSettingsOpen] = useState(false)
1088
+
1089
+ const handleRemoveToken = useCallback(() => {
1090
+ try {
1091
+ localStorage.removeItem(COMMENTS_TOKEN_KEY)
1092
+ localStorage.removeItem(COMMENTS_USER_KEY)
1093
+ } catch { /* ignore */ }
1094
+ document.dispatchEvent(new CustomEvent('storyboard:auth-changed'))
1095
+ setSettingsOpen(false)
1096
+ }, [])
768
1097
 
769
1098
  // Build data index from real prototype/canvas/story data
770
1099
  const knownRoutes = useMemo(() =>
771
1100
  Object.keys(pageModules)
772
1101
  .map(p => p.replace('/src/prototypes/', '').replace('.jsx', ''))
773
- .filter(n => !n.startsWith('_') && n !== 'index' && n !== 'viewfinder'),
1102
+ .filter(n => !n.startsWith('_') && n !== 'index' && n !== 'workspace' && n !== 'viewfinder'),
774
1103
  [pageModules],
775
1104
  )
776
1105
 
@@ -860,20 +1189,23 @@ export default function Viewfinder({
860
1189
  const [activeNav, setActiveNav] = useState('all')
861
1190
  const [activeTab, setActiveTab] = useState('All')
862
1191
  const [showCreate, setShowCreate] = useState(false)
1192
+ const isLocalDev = typeof window !== 'undefined' && window.__SB_LOCAL_DEV__ === true
863
1193
  const [sidebarOpen, setSidebarOpen] = useState(false)
864
1194
  const [groupByFolders, setGroupByFolders] = useState(() => {
865
1195
  try { return localStorage.getItem(GROUP_BY_FOLDERS_KEY) !== 'false' } catch { return true }
866
1196
  })
867
1197
  const [collapsedFolders, setCollapsedFolders] = useState(new Set())
1198
+ const [hiddenItems, setHiddenItems] = useState(new Set())
868
1199
  const { starred, toggle: toggleStar } = useStarred()
869
1200
  const recentIds = useRecent()
870
1201
 
871
1202
  // Filter by nav category
1203
+ const typeMap = { prototypes: 'prototype', canvases: 'canvas', components: 'component' }
872
1204
  const navFiltered = useMemo(() => {
873
- if (activeNav === 'all') return allItems
874
- const typeMap = { prototypes: 'prototype', canvases: 'canvas', components: 'component' }
875
- return allItems.filter(i => i.type === typeMap[activeNav])
876
- }, [allItems, activeNav])
1205
+ let filtered = activeNav === 'all' ? allItems : allItems.filter(i => i.type === typeMap[activeNav])
1206
+ if (hiddenItems.size > 0) filtered = filtered.filter(i => !hiddenItems.has(i.id))
1207
+ return filtered
1208
+ }, [allItems, activeNav, hiddenItems])
877
1209
 
878
1210
  // Filter by tab
879
1211
  const items = useMemo(() => {
@@ -940,16 +1272,24 @@ export default function Viewfinder({
940
1272
  })
941
1273
  }, [])
942
1274
 
1275
+ const handleItemDeleted = useCallback((itemId) => {
1276
+ setHiddenItems(prev => new Set(prev).add(itemId))
1277
+ }, [])
1278
+
943
1279
  // Counts
1280
+ const visibleItems = useMemo(() =>
1281
+ hiddenItems.size > 0 ? allItems.filter(i => !hiddenItems.has(i.id)) : allItems
1282
+ , [allItems, hiddenItems])
1283
+
944
1284
  const counts = useMemo(() => ({
945
- all: allItems.length,
946
- prototypes: allItems.filter(i => i.type === 'prototype').length,
947
- canvases: allItems.filter(i => i.type === 'canvas').length,
948
- components: allItems.filter(i => i.type === 'component').length,
949
- }), [allItems])
1285
+ all: visibleItems.length,
1286
+ prototypes: visibleItems.filter(i => i.type === 'prototype').length,
1287
+ canvases: visibleItems.filter(i => i.type === 'canvas').length,
1288
+ components: visibleItems.filter(i => i.type === 'component').length,
1289
+ }), [visibleItems])
950
1290
 
951
1291
  // Starred items for sidebar
952
- const starredItems = useMemo(() => allItems.filter(i => starred.has(i.id)), [allItems, starred])
1292
+ const starredItems = useMemo(() => visibleItems.filter(i => starred.has(i.id)), [visibleItems, starred])
953
1293
 
954
1294
  const pageTitle = NAV_ITEMS.find(n => n.id === activeNav)?.label || 'All artifacts'
955
1295
 
@@ -972,19 +1312,21 @@ export default function Viewfinder({
972
1312
  </div>
973
1313
  </div>
974
1314
  <div className={css.topActions}>
975
- <BranchDropdown basePath={basePath} />
976
- <Menu.Root open={showCreate} onOpenChange={setShowCreate}>
977
- <Menu.Trigger className={css.createBtn}>
978
- <PlusIcon size={14} /> Create
979
- </Menu.Trigger>
980
- <Menu.Portal>
981
- <Menu.Positioner className={css.createDropdownPositioner} side="bottom" align="end" sideOffset={4}>
982
- <Menu.Popup className={css.createDropdown}>
983
- <CreateMenu onClose={() => setShowCreate(false)} basePath={basePath} />
984
- </Menu.Popup>
985
- </Menu.Positioner>
986
- </Menu.Portal>
987
- </Menu.Root>
1315
+ <BranchNav basePath={basePath} />
1316
+ {isLocalDev && (
1317
+ <Menu.Root open={showCreate} onOpenChange={setShowCreate}>
1318
+ <Menu.Trigger className={css.createBtn}>
1319
+ <PlusIcon size={14} /> Create
1320
+ </Menu.Trigger>
1321
+ <Menu.Portal>
1322
+ <Menu.Positioner className={css.createDropdownPositioner} side="bottom" align="end" sideOffset={4}>
1323
+ <Menu.Popup className={css.createDropdown}>
1324
+ <CreateMenu onClose={() => setShowCreate(false)} basePath={basePath} />
1325
+ </Menu.Popup>
1326
+ </Menu.Positioner>
1327
+ </Menu.Portal>
1328
+ </Menu.Root>
1329
+ )}
988
1330
  </div>
989
1331
  </header>
990
1332
 
@@ -1027,16 +1369,41 @@ export default function Viewfinder({
1027
1369
  ))}
1028
1370
  </div>
1029
1371
 
1030
- {/* User profile / login */}
1372
+ {/* User profile / settings */}
1031
1373
  <div className={css.sidebarFooter}>
1032
- <button className={css.loginBtn} onClick={() => document.dispatchEvent(new CustomEvent('storyboard:open-auth-modal'))}>
1033
- <span className={css.avatar}><MarkGithubIcon size={16} /></span>
1034
- <div>
1035
- <div className={css.userName}>Sign in</div>
1036
- <div className={css.userSub}>Connect with GitHub</div>
1374
+ {ghUser ? (
1375
+ <div className={css.footerRow}>
1376
+ <button className={css.userBtn} onClick={() => setSettingsOpen(true)}>
1377
+ <img
1378
+ className={css.userAvatar}
1379
+ src={ghUser.avatarUrl || `https://github.com/${ghUser.login}.png?size=64`}
1380
+ alt={ghUser.login}
1381
+ width={32}
1382
+ height={32}
1383
+ />
1384
+ <div className={css.userInfo}>
1385
+ <div className={css.userName}>{ghUser.login}</div>
1386
+ </div>
1387
+ </button>
1037
1388
  </div>
1038
- </button>
1389
+ ) : (
1390
+ <div className={css.footerRow}>
1391
+ <button className={css.loginBtn} onClick={() => document.dispatchEvent(new CustomEvent('storyboard:open-auth-modal'))}>
1392
+ <span className={css.avatar}><MarkGithubIcon size={16} /></span>
1393
+ <div>
1394
+ <div className={css.userName}>Sign in</div>
1395
+ <div className={css.userSub}>Connect with GitHub</div>
1396
+ </div>
1397
+ </button>
1398
+ </div>
1399
+ )}
1039
1400
  </div>
1401
+ <UserSettingsDialog
1402
+ open={settingsOpen}
1403
+ onOpenChange={setSettingsOpen}
1404
+ user={ghUser}
1405
+ onRemoveToken={handleRemoveToken}
1406
+ />
1040
1407
  </aside>
1041
1408
 
1042
1409
  {/* ─── Main ─── */}
@@ -1071,7 +1438,7 @@ export default function Viewfinder({
1071
1438
  {activeTab === 'Starred' && 'No starred items. Click ☆ on a card to star it.'}
1072
1439
  {activeTab === 'All' && 'No items found. Create a prototype, canvas, or component to get started.'}
1073
1440
  </div>
1074
- ) : groupByFolders && grouped && activeTab === 'All' && activeNav === 'all' ? (
1441
+ ) : groupByFolders && grouped && activeTab === 'All' ? (
1075
1442
  <>
1076
1443
  {grouped.folders.map(folder => (
1077
1444
  <FolderSection
@@ -1082,6 +1449,7 @@ export default function Viewfinder({
1082
1449
  basePath={basePath}
1083
1450
  starred={starred}
1084
1451
  onToggleStar={toggleStar}
1452
+ onItemDeleted={handleItemDeleted}
1085
1453
  />
1086
1454
  ))}
1087
1455
  {grouped.ungrouped.length > 0 && (
@@ -1093,6 +1461,7 @@ export default function Viewfinder({
1093
1461
  basePath={basePath}
1094
1462
  starred={starred.has(item.id)}
1095
1463
  onToggleStar={toggleStar}
1464
+ onItemDeleted={handleItemDeleted}
1096
1465
  />
1097
1466
  ))}
1098
1467
  </div>
@@ -1107,6 +1476,7 @@ export default function Viewfinder({
1107
1476
  basePath={basePath}
1108
1477
  starred={starred.has(item.id)}
1109
1478
  onToggleStar={toggleStar}
1479
+ onItemDeleted={handleItemDeleted}
1110
1480
  />
1111
1481
  ))}
1112
1482
  </div>