@dfosco/storyboard-react 4.0.0-beta.8 → 4.0.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 (75) hide show
  1. package/package.json +6 -3
  2. package/src/AuthModal/AuthModal.jsx +134 -0
  3. package/src/AuthModal/AuthModal.module.css +221 -0
  4. package/src/BranchBar/BranchBar.jsx +56 -0
  5. package/src/BranchBar/BranchBar.module.css +230 -0
  6. package/src/BranchBar/useBranches.js +79 -0
  7. package/src/CommandPalette/CommandPalette.jsx +936 -0
  8. package/src/CommandPalette/CreateDialog.jsx +219 -0
  9. package/src/CommandPalette/command-palette.css +111 -0
  10. package/src/Icon.jsx +180 -0
  11. package/src/Viewfinder.jsx +1104 -57
  12. package/src/Viewfinder.module.css +1107 -149
  13. package/src/canvas/CanvasControls.jsx +51 -2
  14. package/src/canvas/CanvasControls.module.css +31 -0
  15. package/src/canvas/CanvasPage.bridge.test.jsx +142 -19
  16. package/src/canvas/CanvasPage.dragdrop.test.jsx +346 -0
  17. package/src/canvas/CanvasPage.jsx +807 -251
  18. package/src/canvas/CanvasPage.module.css +98 -50
  19. package/src/canvas/CanvasPage.multiselect.test.jsx +13 -11
  20. package/src/canvas/CanvasToolbar.jsx +2 -2
  21. package/src/canvas/MarqueeOverlay.jsx +20 -0
  22. package/src/canvas/PageSelector.jsx +239 -0
  23. package/src/canvas/PageSelector.module.css +165 -0
  24. package/src/canvas/PageSelector.test.jsx +104 -0
  25. package/src/canvas/canvasApi.js +22 -8
  26. package/src/canvas/canvasTheme.js +96 -52
  27. package/src/canvas/componentIsolate.jsx +33 -7
  28. package/src/canvas/useCanvas.js +9 -8
  29. package/src/canvas/useCanvas.test.js +4 -4
  30. package/src/canvas/useMarqueeSelect.js +187 -0
  31. package/src/canvas/useMarqueeSelect.test.js +78 -0
  32. package/src/canvas/widgets/CodePenEmbed.jsx +292 -0
  33. package/src/canvas/widgets/CodePenEmbed.module.css +161 -0
  34. package/src/canvas/widgets/ComponentWidget.jsx +42 -10
  35. package/src/canvas/widgets/ComponentWidget.module.css +6 -5
  36. package/src/canvas/widgets/FigmaEmbed.jsx +110 -24
  37. package/src/canvas/widgets/FigmaEmbed.module.css +21 -7
  38. package/src/canvas/widgets/LinkPreview.jsx +297 -11
  39. package/src/canvas/widgets/LinkPreview.module.css +386 -18
  40. package/src/canvas/widgets/LinkPreview.test.jsx +193 -0
  41. package/src/canvas/widgets/MarkdownBlock.jsx +86 -5
  42. package/src/canvas/widgets/MarkdownBlock.module.css +64 -15
  43. package/src/canvas/widgets/PrototypeEmbed.jsx +96 -145
  44. package/src/canvas/widgets/PrototypeEmbed.module.css +74 -4
  45. package/src/canvas/widgets/StickyNote.module.css +5 -0
  46. package/src/canvas/widgets/StickyNote.test.jsx +9 -9
  47. package/src/canvas/widgets/StoryWidget.jsx +277 -0
  48. package/src/canvas/widgets/StoryWidget.module.css +211 -0
  49. package/src/canvas/widgets/WidgetChrome.jsx +76 -20
  50. package/src/canvas/widgets/WidgetChrome.module.css +2 -6
  51. package/src/canvas/widgets/WidgetWrapper.module.css +2 -0
  52. package/src/canvas/widgets/codepenUrl.js +75 -0
  53. package/src/canvas/widgets/codepenUrl.test.js +76 -0
  54. package/src/canvas/widgets/embedInteraction.test.jsx +235 -0
  55. package/src/canvas/widgets/embedOverlay.module.css +35 -0
  56. package/src/canvas/widgets/embedTheme.js +138 -39
  57. package/src/canvas/widgets/githubUrl.js +82 -0
  58. package/src/canvas/widgets/githubUrl.test.js +74 -0
  59. package/src/canvas/widgets/iframeDevLogs.js +49 -0
  60. package/src/canvas/widgets/iframeDevLogs.test.jsx +81 -0
  61. package/src/canvas/widgets/index.js +4 -0
  62. package/src/canvas/widgets/pasteRules.js +295 -0
  63. package/src/canvas/widgets/pasteRules.test.js +474 -0
  64. package/src/canvas/widgets/snapshotDisplay.test.jsx +259 -0
  65. package/src/canvas/widgets/widgetConfig.js +16 -5
  66. package/src/canvas/widgets/widgetConfig.test.js +34 -12
  67. package/src/context.jsx +145 -16
  68. package/src/hooks/useSceneData.js +4 -2
  69. package/src/hooks/useThemeState.js +61 -0
  70. package/src/hooks/useThemeState.test.js +66 -0
  71. package/src/index.js +10 -0
  72. package/src/story/StoryPage.jsx +117 -0
  73. package/src/story/StoryPage.module.css +18 -0
  74. package/src/vite/data-plugin.js +348 -66
  75. package/src/vite/data-plugin.test.js +405 -5
@@ -1,72 +1,1119 @@
1
-
2
- import { useRef, useEffect, useMemo } from 'react'
3
-
4
1
  /**
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.
2
+ * Viewfinder — SaaS-style homescreen for Storyboard.
9
3
  *
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
4
+ * Replaces the old list-based Viewfinder with a sidebar + grid layout.
5
+ * Wired to real data from buildPrototypeIndex and listStories.
19
6
  */
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)
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'
10
+ import { Menu } from '@base-ui/react/menu'
11
+ import Icon from './Icon.jsx'
12
+ import css from './Viewfinder.module.css'
23
13
 
24
- const shouldHideDefault = hideDefaultFlow ?? hideDefaultScene
14
+ /* ─── Theme sync: read toolbar theme from DOM and apply to Primer/BaseUI ─── */
15
+
16
+ function getToolbarThemeAttrs() {
17
+ const theme = document.documentElement.getAttribute('data-sb-toolbar-theme') || 'light'
18
+ if (theme === 'dark_dimmed') {
19
+ return { 'data-color-mode': 'dark', 'data-dark-theme': 'dark_dimmed', 'data-light-theme': 'light' }
20
+ }
21
+ if (theme.startsWith('dark')) {
22
+ return { 'data-color-mode': 'dark', 'data-dark-theme': 'dark', 'data-light-theme': 'light' }
23
+ }
24
+ return { 'data-color-mode': 'light', 'data-light-theme': 'light', 'data-dark-theme': 'dark' }
25
+ }
26
+
27
+ function useToolbarTheme() {
28
+ const [attrs, setAttrs] = useState(getToolbarThemeAttrs)
29
+
30
+ useEffect(() => {
31
+ const update = () => setAttrs(getToolbarThemeAttrs())
32
+ const observer = new MutationObserver(update)
33
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-sb-toolbar-theme'] })
34
+ update()
35
+ return () => observer.disconnect()
36
+ }, [])
37
+
38
+ // Sync to document.body so BaseUI portals inherit Primer theme
39
+ useEffect(() => {
40
+ for (const [key, value] of Object.entries(attrs)) {
41
+ document.body.setAttribute(key, value)
42
+ }
43
+ }, [attrs])
44
+
45
+ return attrs
46
+ }
47
+
48
+ /* ─── localStorage helpers ─── */
49
+
50
+ const STARRED_KEY = 'sb-viewfinder-starred'
51
+ const RECENT_KEY = 'sb-viewfinder-recent'
52
+ const MAX_RECENT = 30
53
+ const GROUP_BY_FOLDERS_KEY = 'sb-viewfinder-group-folders'
54
+
55
+ function readJSON(key, fallback) {
56
+ try { return JSON.parse(localStorage.getItem(key)) || fallback }
57
+ catch { return fallback }
58
+ }
59
+
60
+ function writeJSON(key, value) {
61
+ localStorage.setItem(key, JSON.stringify(value))
62
+ window.dispatchEvent(new StorageEvent('storage', { key }))
63
+ }
64
+
65
+ function createLocalStorageStore(key, fallback) {
66
+ const subscribe = (cb) => {
67
+ const handler = (e) => { if (!e.key || e.key === key) cb() }
68
+ window.addEventListener('storage', handler)
69
+ return () => window.removeEventListener('storage', handler)
70
+ }
71
+ const getSnapshot = () => localStorage.getItem(key) || JSON.stringify(fallback)
72
+ return { subscribe, getSnapshot }
73
+ }
74
+
75
+ const starredStore = createLocalStorageStore(STARRED_KEY, [])
76
+ const recentStore = createLocalStorageStore(RECENT_KEY, [])
77
+
78
+ function useStarred() {
79
+ const raw = useSyncExternalStore(starredStore.subscribe, starredStore.getSnapshot)
80
+ const ids = JSON.parse(raw)
81
+ const toggle = useCallback((id) => {
82
+ const current = readJSON(STARRED_KEY, [])
83
+ const next = current.includes(id) ? current.filter(x => x !== id) : [...current, id]
84
+ writeJSON(STARRED_KEY, next)
85
+ }, [])
86
+ return { starred: new Set(ids), toggle }
87
+ }
88
+
89
+ function useRecent() {
90
+ const raw = useSyncExternalStore(recentStore.subscribe, recentStore.getSnapshot)
91
+ return JSON.parse(raw)
92
+ }
93
+
94
+ function trackRecent(id) {
95
+ const current = readJSON(RECENT_KEY, [])
96
+ const next = [id, ...current.filter(x => x !== id)].slice(0, MAX_RECENT)
97
+ writeJSON(RECENT_KEY, next)
98
+ }
99
+
100
+ /* ─── URL helpers ─── */
101
+
102
+ function withBase(basePath, route) {
103
+ const normalizedRoute = route.startsWith('/') ? route : `/${route}`
104
+ const normalizedBase = (basePath || '/').replace(/\/+$/, '')
105
+ if (!normalizedBase || normalizedBase === '/') return normalizedRoute
106
+ return `${normalizedBase}${normalizedRoute}`.replace(/\/+/g, '/')
107
+ }
108
+
109
+ /* ─── Thumbnail color from name hash ─── */
110
+
111
+ const THUMB_CLASSES = ['thumbBlue', 'thumbAmber', 'thumbGreen', 'thumbPurple', 'thumbRose', 'thumbSlate']
112
+
113
+ function thumbClass(name) {
114
+ let h = 0
115
+ for (let i = 0; i < name.length; i++) h = ((h << 5) - h + name.charCodeAt(i)) | 0
116
+ return css[THUMB_CLASSES[Math.abs(h) % THUMB_CLASSES.length]]
117
+ }
118
+
119
+ /* ─── Type helpers ─── */
120
+
121
+ function getTypeLabel(type) {
122
+ if (type === 'prototype') return 'PROTOTYPE'
123
+ if (type === 'canvas') return 'CANVAS'
124
+ if (type === 'component') return 'COMPONENT'
125
+ return type?.toUpperCase() || ''
126
+ }
127
+
128
+ function getTypeIcon(type, size = 14) {
129
+ if (type === 'prototype') return <Icon name="prototype" size={size} />
130
+ if (type === 'canvas') return <Icon name="canvas" size={size} />
131
+ if (type === 'component') return <Icon name="component" size={size} />
132
+ return null
133
+ }
134
+
135
+ /* ─── Avatar Stack ─── */
25
136
 
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])
137
+ function AvatarStack({ authors }) {
138
+ if (!authors || authors.length === 0) return null
139
+ const list = Array.isArray(authors) ? authors : [authors]
140
+ return (
141
+ <div className={css.avatarStack}>
142
+ {list.map(username => (
143
+ <img
144
+ key={username}
145
+ className={css.avatarImg}
146
+ src={`https://github.com/${username}.png?size=48`}
147
+ alt={username}
148
+ width={24}
149
+ height={24}
150
+ loading="lazy"
151
+ onError={(e) => { e.target.style.display = 'none' }}
152
+ />
153
+ ))}
154
+ </div>
155
+ )
156
+ }
157
+
158
+ /* ─── Star Button ─── */
159
+
160
+ function StarBtn({ active, onClick }) {
161
+ return (
162
+ <button
163
+ className={active ? css.iconBtnActive : css.iconBtn}
164
+ onClick={(e) => { e.preventDefault(); e.stopPropagation(); onClick() }}
165
+ aria-label={active ? 'Remove favorite' : 'Favorite'}
166
+ title={active ? 'Remove favorite' : 'Favorite'}
167
+ >
168
+ {active ? <StarFillIcon size={16} /> : <StarIcon size={16} />}
169
+ </button>
170
+ )
171
+ }
172
+
173
+ /* ─── Artifact Card ─── */
174
+
175
+ function ArtifactCard({ item, basePath, starred, onToggleStar }) {
176
+ const href = item.route ? withBase(basePath, item.route) : '#'
177
+ const isExternal = item.isExternal
178
+
179
+ const handleClick = () => {
180
+ trackRecent(item.id)
181
+ }
182
+
183
+ const Tag = isExternal ? 'a' : 'a'
184
+ const linkProps = isExternal
185
+ ? { href: item.externalUrl, target: '_blank', rel: 'noopener noreferrer' }
186
+ : { href }
187
+
188
+ const authorList = item.author
189
+ ? (Array.isArray(item.author) ? item.author : [item.author])
190
+ : item.gitAuthor ? [item.gitAuthor] : []
191
+
192
+ 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>}
207
+ </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>}
217
+ </div>
218
+ </div>
219
+ </div>
220
+ </div>
221
+ </Tag>
222
+ )
223
+ }
224
+
225
+ function formatRelativeTime(dateStr) {
226
+ if (!dateStr) return ''
227
+ const date = new Date(dateStr)
228
+ if (isNaN(date.getTime())) return ''
229
+ const now = Date.now()
230
+ const diff = now - date.getTime()
231
+ if (diff < 0) return ''
232
+ const mins = Math.floor(diff / 60000)
233
+ if (mins < 1) return 'Just now'
234
+ if (mins < 60) return `${mins}m ago`
235
+ const hours = Math.floor(mins / 60)
236
+ if (hours < 24) return `${hours}h ago`
237
+ const days = Math.floor(hours / 24)
238
+ if (days < 7) return `${days}d ago`
239
+ if (days < 30) return `${Math.floor(days / 7)}w ago`
240
+ return date.toLocaleDateString()
241
+ }
242
+
243
+ /* ─── Flows Dropdown ─── */
244
+
245
+ function FlowsDropdown({ flows, basePath }) {
246
+ if (!flows || flows.length === 0) return null
247
+ return (
248
+ <Menu.Root>
249
+ <Menu.Trigger
250
+ className={css.iconBtn}
251
+ onClick={(e) => { e.preventDefault(); e.stopPropagation() }}
252
+ aria-label="See flows"
253
+ title="See flows"
254
+ >
255
+ <Icon name="flow" size={16} />
256
+ </Menu.Trigger>
257
+ <Menu.Portal>
258
+ <Menu.Positioner className={css.flowsPositioner} side="bottom" align="end" sideOffset={4}>
259
+ <Menu.Popup className={css.flowsPopup}>
260
+ <div className={css.flowsTitle}>Flows</div>
261
+ {flows.map(flow => (
262
+ <Menu.Item
263
+ key={flow.key}
264
+ className={css.flowsItem}
265
+ onClick={(e) => {
266
+ e.preventDefault()
267
+ window.location.href = withBase(basePath, flow.route)
268
+ }}
269
+ >
270
+ {flow.meta?.title || flow.name}
271
+ </Menu.Item>
272
+ ))}
273
+ </Menu.Popup>
274
+ </Menu.Positioner>
275
+ </Menu.Portal>
276
+ </Menu.Root>
277
+ )
278
+ }
279
+
280
+ /* ─── Pages Dropdown ─── */
281
+
282
+ function PagesDropdown({ pages, basePath }) {
283
+ if (!pages || pages.length < 2) return null
284
+ return (
285
+ <Menu.Root>
286
+ <Menu.Trigger
287
+ className={css.iconBtn}
288
+ onClick={(e) => { e.preventDefault(); e.stopPropagation() }}
289
+ aria-label="See pages"
290
+ title="See pages"
291
+ >
292
+ <StackIcon size={16} />
293
+ </Menu.Trigger>
294
+ <Menu.Portal>
295
+ <Menu.Positioner className={css.flowsPositioner} side="bottom" align="end" sideOffset={4}>
296
+ <Menu.Popup className={css.flowsPopup}>
297
+ <div className={css.flowsTitle}>Pages</div>
298
+ {pages.map(page => (
299
+ <Menu.Item
300
+ key={page.route}
301
+ className={css.flowsItem}
302
+ onClick={(e) => {
303
+ e.preventDefault()
304
+ window.location.href = withBase(basePath, page.route)
305
+ }}
306
+ >
307
+ {page.name}
308
+ </Menu.Item>
309
+ ))}
310
+ </Menu.Popup>
311
+ </Menu.Positioner>
312
+ </Menu.Portal>
313
+ </Menu.Root>
314
+ )
315
+ }
316
+
317
+ /* ─── Folder Section ─── */
318
+
319
+ function FolderSection({ folder, collapsed, onToggle, basePath, starred, onToggleStar }) {
320
+ return (
321
+ <section className={collapsed ? css.folderSectionCollapsed : css.folderSection}>
322
+ <button className={css.folderHeader} onClick={onToggle}>
323
+ <Icon name={collapsed ? 'folder' : 'folder-open'} size={16} className={css.folderIcon} />
324
+ <span className={css.folderName}>{folder.name}</span>
325
+ <span className={css.folderCount}>{folder.items.length}</span>
326
+ <ChevronRightIcon
327
+ size={14}
328
+ className={collapsed ? css.folderChevron : css.folderChevronExpanded}
329
+ />
330
+ </button>
331
+ {!collapsed && (
332
+ <div className={css.grid}>
333
+ {folder.items.map(item => (
334
+ <ArtifactCard
335
+ key={item.id}
336
+ item={item}
337
+ basePath={basePath}
338
+ starred={starred.has(item.id)}
339
+ onToggleStar={onToggleStar}
340
+ />
341
+ ))}
342
+ </div>
343
+ )}
344
+ </section>
345
+ )
346
+ }
347
+
348
+ /* ─── Create Footer ─── */
349
+
350
+ function CreateTip() {
351
+ return (
352
+ <div className={css.createTip}>
353
+ <span className={css.createTipText}>
354
+ Tip: You can ask your AI assistant to create any of these artifacts: <code className={css.createTipCode}>Create a prototype</code>, <code className={css.createTipCode}>Create a canvas</code>, etc
355
+ </span>
356
+ </div>
357
+ )
358
+ }
359
+
360
+ function CreateFooter() {
361
+ return (
362
+ <div className={css.createFooter}>
363
+ <span className={css.createFooterDot} />
364
+ <span className={css.createFooterText}>Only available in dev environment</span>
365
+ </div>
366
+ )
367
+ }
368
+
369
+ /* ─── Create Form ─── */
370
+
371
+ function CreateForm({ type, onClose, basePath }) {
372
+ const [name, setName] = useState('')
373
+ const [title, setTitle] = useState('')
374
+ const [description, setDescription] = useState('')
375
+ const [url, setUrl] = useState('')
376
+ const [isExternal, setIsExternal] = useState(false)
377
+ const [prototype, setPrototype] = useState('')
378
+ const [prototypes, setPrototypes] = useState([])
379
+ const [error, setError] = useState('')
380
+ const [submitting, setSubmitting] = useState(false)
381
+
382
+ const needsPrototype = type === 'Flow' || type === 'Page'
30
383
 
31
384
  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,
385
+ if (!needsPrototype) return
386
+ const apiBase = (basePath || '/').replace(/\/+$/, '')
387
+ fetch(`${apiBase}/_storyboard/workshop/flows`)
388
+ .then(r => r.ok ? r.json() : null)
389
+ .then(data => {
390
+ if (data?.prototypes) setPrototypes(data.prototypes)
47
391
  })
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
- })
392
+ .catch(() => {})
393
+ }, [needsPrototype, basePath])
394
+
395
+ const handleSubmit = async (e) => {
396
+ e.preventDefault()
397
+ if (!name.trim()) { setError('Name is required'); return }
398
+ if (needsPrototype && !prototype) { setError('Select a prototype'); return }
399
+ setError('')
400
+ setSubmitting(true)
401
+
402
+ const apiBase = (basePath || '/').replace(/\/+$/, '')
403
+ let endpoint, body
404
+ if (type === 'Canvas') {
405
+ endpoint = `${apiBase}/_storyboard/canvas/create`
406
+ body = { name: name.trim(), title: title.trim(), description: description.trim(), grid: true, gridSize: 24 }
407
+ } else if (type === 'Prototype') {
408
+ endpoint = `${apiBase}/_storyboard/workshop/prototypes`
409
+ body = { name: name.trim(), title: title.trim(), description: description.trim() }
410
+ if (isExternal) { body.external = true; body.url = url.trim() }
411
+ } else if (type === 'Flow') {
412
+ endpoint = `${apiBase}/_storyboard/workshop/flows`
413
+ body = { name: name.trim(), title: title.trim(), prototype, description: description.trim() }
414
+ } else if (type === 'Page') {
415
+ endpoint = `${apiBase}/_storyboard/workshop/pages`
416
+ body = { name: name.trim(), prototype }
417
+ } else {
418
+ endpoint = `${apiBase}/_storyboard/canvas/create-story`
419
+ body = { name: name.trim(), location: 'src/components' }
420
+ }
421
+
422
+ try {
423
+ const res = await fetch(endpoint, {
424
+ method: 'POST',
425
+ headers: { 'Content-Type': 'application/json' },
426
+ body: JSON.stringify(body),
53
427
  })
428
+ if (!res.ok) {
429
+ const text = await res.text()
430
+ throw new Error(text || `Request failed (${res.status})`)
431
+ }
432
+ const data = await res.json().catch(() => ({}))
433
+ const route = data.route || data.path || `/${name.trim()}`
434
+ window.location.href = withBase(basePath, route)
435
+ } catch (err) {
436
+ setError(err.message)
437
+ setSubmitting(false)
438
+ }
439
+ }
440
+
441
+ const typeLabels = { Canvas: 'Canvas', Prototype: 'Prototype', Component: 'Component', Flow: 'Prototype Flow', Page: 'Prototype Page' }
442
+
443
+ return (
444
+ <form onSubmit={handleSubmit}>
445
+ <div className={css.createFormHeader}>
446
+ <div className={css.createMenuTitle}>New {typeLabels[type] || type}</div>
447
+ <button type="button" className={css.createFormClose} onClick={onClose} aria-label="Close">
448
+ <XIcon size={16} />
449
+ </button>
450
+ </div>
451
+
452
+ {needsPrototype && (
453
+ <div className={css.createFormField}>
454
+ <label className={css.createFormLabel}>Prototype *</label>
455
+ <select
456
+ className={css.createFormInput}
457
+ value={prototype}
458
+ onChange={e => setPrototype(e.target.value)}
459
+ >
460
+ <option value="">Select a prototype…</option>
461
+ {prototypes.map(p => (
462
+ <option key={p.name} value={p.name}>{p.name}</option>
463
+ ))}
464
+ </select>
465
+ </div>
466
+ )}
467
+
468
+ <div className={css.createFormField}>
469
+ <label className={css.createFormLabel}>Name *</label>
470
+ <input
471
+ className={css.createFormInput}
472
+ value={name}
473
+ onChange={e => setName(e.target.value)}
474
+ placeholder={type === 'Page' ? 'my-page' : `my-${type.toLowerCase()}`}
475
+ autoFocus={!needsPrototype}
476
+ />
477
+ </div>
478
+
479
+ {type !== 'Component' && type !== 'Page' && (
480
+ <>
481
+ <div className={css.createFormField}>
482
+ <label className={css.createFormLabel}>Title</label>
483
+ <input
484
+ className={css.createFormInput}
485
+ value={title}
486
+ onChange={e => setTitle(e.target.value)}
487
+ placeholder="Optional display title"
488
+ />
489
+ </div>
490
+ <div className={css.createFormField}>
491
+ <label className={css.createFormLabel}>Description</label>
492
+ <input
493
+ className={css.createFormInput}
494
+ value={description}
495
+ onChange={e => setDescription(e.target.value)}
496
+ placeholder="Optional description"
497
+ />
498
+ </div>
499
+ </>
500
+ )}
501
+
502
+ {type === 'Prototype' && (
503
+ <>
504
+ <div className={css.createFormField}>
505
+ <label className={css.createFormCheckbox}>
506
+ <input
507
+ type="checkbox"
508
+ checked={isExternal}
509
+ onChange={e => setIsExternal(e.target.checked)}
510
+ />
511
+ External prototype
512
+ </label>
513
+ </div>
514
+ {isExternal && (
515
+ <div className={css.createFormField}>
516
+ <label className={css.createFormLabel}>URL</label>
517
+ <input
518
+ className={css.createFormInput}
519
+ value={url}
520
+ onChange={e => setUrl(e.target.value)}
521
+ placeholder="https://example.com"
522
+ />
523
+ </div>
524
+ )}
525
+ </>
526
+ )}
527
+
528
+ {error && <div className={css.createFormError}>{error}</div>}
529
+
530
+ <div className={css.createFormActions}>
531
+ <button type="submit" className={css.createFormSubmit} disabled={submitting}>
532
+ {submitting ? 'Creating…' : `Create ${typeLabels[type] || type}`}
533
+ </button>
534
+ </div>
535
+ </form>
536
+ )
537
+ }
538
+
539
+ /* ─── Create Menu (Dropdown) ─── */
540
+
541
+ function CreateMenu({ onClose, basePath }) {
542
+ const [activeForm, setActiveForm] = useState(null)
543
+ const [showMore, setShowMore] = useState(false)
544
+
545
+ const items = [
546
+ { icon: <Icon name="canvas" size={18} />, title: 'Canvas', desc: 'Interactive board for prototypes, components, and documents' },
547
+ { icon: <Icon name="prototype" size={18} />, title: 'Prototype', desc: 'Interactive page flow' },
548
+ { icon: <Icon name="component" size={18} />, title: 'Component', desc: 'Reusable component' },
549
+ ]
550
+
551
+ const moreItems = [
552
+ { title: 'Prototype Flow', desc: 'A flow data file for a prototype', type: 'Flow' },
553
+ { title: 'Prototype Page', desc: 'A new page inside a prototype', type: 'Page' },
554
+ ]
555
+
556
+ if (activeForm) {
557
+ return (
558
+ <div className={css.createDropdownForm} onKeyDown={e => e.stopPropagation()}>
559
+ <CreateForm
560
+ type={activeForm}
561
+ onBack={() => setActiveForm(null)}
562
+ onClose={onClose}
563
+ basePath={basePath}
564
+ />
565
+ </div>
566
+ )
567
+ }
568
+
569
+ return (
570
+ <>
571
+ <div className={css.createDropdownTitle}>Create new artifact</div>
572
+ <div className={css.createDropdownGrid}>
573
+ {items.map(it => (
574
+ <button key={it.title} className={css.createMenuItem} onClick={() => setActiveForm(it.title)}>
575
+ <div className={css.createMenuIcon}>{it.icon}</div>
576
+ <div>
577
+ <div className={css.createMenuItemTitle}>{it.title}</div>
578
+ <div className={css.createMenuItemDesc}>{it.desc}</div>
579
+ </div>
580
+ </button>
581
+ ))}
582
+ </div>
583
+
584
+ {!showMore ? (
585
+ <button className={css.moreOptionsBtn} onClick={() => setShowMore(true)}>
586
+ More options <ChevronDownIcon size={12} />
587
+ </button>
588
+ ) : (
589
+ <div className={css.moreOptionsSection}>
590
+ {moreItems.map(it => (
591
+ <button key={it.title} className={css.moreOptionItem} onClick={() => setActiveForm(it.type)}>
592
+ <div className={css.moreOptionTitle}>{it.title}</div>
593
+ <div className={css.moreOptionDesc}>{it.desc}</div>
594
+ </button>
595
+ ))}
596
+ </div>
597
+ )}
598
+
599
+ <CreateTip />
600
+ <CreateFooter />
601
+ </>
602
+ )
603
+ }
604
+
605
+ /* ─── PAT Dialog ─── */
606
+
607
+ /* ─── Nav config ─── */
608
+
609
+ const NAV_ITEMS = [
610
+ { id: 'all', label: 'All artifacts', iconName: 'iconoir/view-grid' },
611
+ { id: 'prototypes', label: 'Prototypes', iconName: 'prototype' },
612
+ { id: 'canvases', label: 'Canvas', iconName: 'canvas' },
613
+ { id: 'components', label: 'Components', iconName: 'component' },
614
+ ]
615
+
616
+ const TAB_FILTERS = ['All', 'Recent', 'Starred']
617
+
618
+ /* ─── Branch Dropdown ─── */
619
+
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
656
+ 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)
661
+ const [switching, setSwitching] = useState(null)
662
+ const [switchError, setSwitchError] = useState(null)
663
+
664
+ if (!branches || branches.length === 0) return null
665
+
666
+ const twoWeeksAgo = Date.now() - 14 * 24 * 60 * 60 * 1000
667
+
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)
672
+
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 || ''))
681
+
682
+ const switchBranch = (branch) => {
683
+ setSwitching(branch)
684
+ const target = branches?.find(b => b.branch === branch)
685
+ window.location.href = `${branchBasePath}${target?.folder || (branch === 'main' ? '' : `branch--${branch}/`)}`
686
+ }
687
+
688
+ return (
689
+ <Menu.Root>
690
+ <Menu.Trigger className={css.branchBtn} disabled={!!switching}>
691
+ <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
+ )}
713
+
714
+ {myBranches.length > 0 && recentBranches.length > 0 && (
715
+ <div className={css.branchSeparator} />
716
+ )}
717
+
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
+ )}
737
+
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>
753
+ )
754
+ }
755
+
756
+ /* ─── Main Component ─── */
757
+
758
+ export default function Viewfinder({
759
+ pageModules = {},
760
+ basePath,
761
+ title = 'Storyboard',
762
+ subtitle,
763
+ hideDefaultFlow,
764
+ hideDefaultScene = false,
765
+ }) {
766
+ const shouldHideDefault = hideDefaultFlow ?? hideDefaultScene
767
+ const themeAttrs = useToolbarTheme()
768
+
769
+ // Build data index from real prototype/canvas/story data
770
+ const knownRoutes = useMemo(() =>
771
+ Object.keys(pageModules)
772
+ .map(p => p.replace('/src/prototypes/', '').replace('.jsx', ''))
773
+ .filter(n => !n.startsWith('_') && n !== 'index' && n !== 'viewfinder'),
774
+ [pageModules],
775
+ )
776
+
777
+ const prototypeIndex = useMemo(() => buildPrototypeIndex(knownRoutes), [knownRoutes])
778
+
779
+ // Build unified items list from all sources
780
+ const allItems = useMemo(() => {
781
+ const items = []
782
+
783
+ // Prototypes (ungrouped + from folders)
784
+ const addProto = (proto) => {
785
+ // For prototypes with flows, use the first flow's route
786
+ const route = proto.flows?.length > 0
787
+ ? proto.flows[0].route
788
+ : `/${proto.dirName}`
789
+
790
+ items.push({
791
+ id: `proto:${proto.dirName}`,
792
+ name: proto.name,
793
+ type: 'prototype',
794
+ author: proto.author,
795
+ gitAuthor: proto.gitAuthor,
796
+ lastModified: proto.lastModified,
797
+ route,
798
+ isExternal: proto.isExternal,
799
+ externalUrl: proto.externalUrl,
800
+ folder: proto.folder,
801
+ description: proto.description,
802
+ flows: proto.flows || [],
803
+ })
804
+ }
805
+
806
+ for (const proto of prototypeIndex.prototypes || []) addProto(proto)
807
+ for (const folder of prototypeIndex.folders || []) {
808
+ for (const proto of folder.prototypes || []) addProto(proto)
809
+ }
810
+
811
+ // Canvases (ungrouped + from folders)
812
+ const addCanvas = (canvas) => {
813
+ items.push({
814
+ id: `canvas:${canvas.dirName}`,
815
+ name: canvas.name,
816
+ type: 'canvas',
817
+ author: canvas.author,
818
+ gitAuthor: canvas.gitAuthor,
819
+ lastModified: null,
820
+ route: canvas.route,
821
+ isExternal: false,
822
+ externalUrl: null,
823
+ folder: canvas.folder,
824
+ description: canvas.description,
825
+ pages: canvas.pages || null,
826
+ })
827
+ }
828
+
829
+ for (const canvas of prototypeIndex.canvases || []) addCanvas(canvas)
830
+ for (const folder of prototypeIndex.folders || []) {
831
+ for (const canvas of folder.canvases || []) addCanvas(canvas)
832
+ }
833
+
834
+ // Components (stories)
835
+ const storyNames = listStories()
836
+ for (const name of storyNames) {
837
+ const data = getStoryData(name)
838
+ if (!data) continue
839
+ items.push({
840
+ id: `component:${name}`,
841
+ name: name.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '),
842
+ type: 'component',
843
+ author: null,
844
+ gitAuthor: null,
845
+ lastModified: null,
846
+ route: data._route || `/components/${name}`,
847
+ isExternal: false,
848
+ externalUrl: null,
849
+ folder: null,
850
+ description: null,
851
+ })
852
+ }
853
+
854
+ return items
855
+ }, [prototypeIndex])
856
+
857
+ const itemMap = useMemo(() => Object.fromEntries(allItems.map(i => [i.id, i])), [allItems])
858
+
859
+ // State
860
+ const [activeNav, setActiveNav] = useState('all')
861
+ const [activeTab, setActiveTab] = useState('All')
862
+ const [showCreate, setShowCreate] = useState(false)
863
+ const [sidebarOpen, setSidebarOpen] = useState(false)
864
+ const [groupByFolders, setGroupByFolders] = useState(() => {
865
+ try { return localStorage.getItem(GROUP_BY_FOLDERS_KEY) !== 'false' } catch { return true }
866
+ })
867
+ const [collapsedFolders, setCollapsedFolders] = useState(new Set())
868
+ const { starred, toggle: toggleStar } = useStarred()
869
+ const recentIds = useRecent()
870
+
871
+ // Filter by nav category
872
+ 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])
877
+
878
+ // Filter by tab
879
+ const items = useMemo(() => {
880
+ if (activeTab === 'Recent') {
881
+ const ordered = recentIds.map(id => itemMap[id]).filter(Boolean)
882
+ if (activeNav !== 'all') {
883
+ const typeMap = { prototypes: 'prototype', canvases: 'canvas', components: 'component' }
884
+ return ordered.filter(i => i.type === typeMap[activeNav])
885
+ }
886
+ return ordered
887
+ }
888
+ const base = activeTab === 'Starred'
889
+ ? navFiltered.filter(i => starred.has(i.id))
890
+ : navFiltered
891
+ return [...base].sort((a, b) => {
892
+ const aTime = a.lastModified ? new Date(a.lastModified).getTime() : 0
893
+ const bTime = b.lastModified ? new Date(b.lastModified).getTime() : 0
894
+ return bTime - aTime
54
895
  })
896
+ }, [activeTab, activeNav, navFiltered, recentIds, itemMap, starred])
55
897
 
56
- return () => {
57
- cancelled = true
58
- if (handleRef.current) {
59
- handleRef.current.destroy()
60
- handleRef.current = null
898
+ // Grouped items for folder view
899
+ const grouped = useMemo(() => {
900
+ if (!groupByFolders) return null
901
+ const folderItems = {}
902
+ const ungrouped = []
903
+ for (const item of items) {
904
+ if (item.folder) {
905
+ if (!folderItems[item.folder]) folderItems[item.folder] = []
906
+ folderItems[item.folder].push(item)
907
+ } else {
908
+ ungrouped.push(item)
61
909
  }
62
910
  }
63
- }, [title, subtitle, basePath, knownRoutes, showThumbnails, shouldHideDefault])
911
+ const folderMeta = {}
912
+ for (const f of prototypeIndex.folders || []) folderMeta[f.dirName] = f
913
+ const folders = Object.entries(folderItems).map(([dirName, fItems]) => ({
914
+ dirName,
915
+ name: folderMeta[dirName]?.name || dirName,
916
+ items: fItems,
917
+ }))
918
+ folders.sort((a, b) => {
919
+ const aMax = Math.max(0, ...a.items.map(i => i.lastModified ? new Date(i.lastModified).getTime() : 0))
920
+ const bMax = Math.max(0, ...b.items.map(i => i.lastModified ? new Date(i.lastModified).getTime() : 0))
921
+ return bMax - aMax
922
+ })
923
+ return { ungrouped, folders }
924
+ }, [items, groupByFolders, prototypeIndex])
64
925
 
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
- }
926
+ const toggleGrouping = useCallback(() => {
927
+ setGroupByFolders(prev => {
928
+ const next = !prev
929
+ try { localStorage.setItem(GROUP_BY_FOLDERS_KEY, String(next)) } catch {}
930
+ return next
931
+ })
932
+ }, [])
933
+
934
+ const toggleFolder = useCallback((dirName) => {
935
+ setCollapsedFolders(prev => {
936
+ const next = new Set(prev)
937
+ if (next.has(dirName)) next.delete(dirName)
938
+ else next.add(dirName)
939
+ return next
940
+ })
941
+ }, [])
72
942
 
943
+ // Counts
944
+ 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])
950
+
951
+ // Starred items for sidebar
952
+ const starredItems = useMemo(() => allItems.filter(i => starred.has(i.id)), [allItems, starred])
953
+
954
+ const pageTitle = NAV_ITEMS.find(n => n.id === activeNav)?.label || 'All artifacts'
955
+
956
+ return (
957
+ <div className={css.layout} {...themeAttrs}>
958
+ {/* ─── Full-width Header ─── */}
959
+ <header className={css.topBar}>
960
+ <div className={css.topBarLeft}>
961
+ <button
962
+ className={css.hamburgerBtn}
963
+ onClick={() => setSidebarOpen(prev => !prev)}
964
+ aria-label="Toggle menu"
965
+ >
966
+ {sidebarOpen ? <XIcon size={18} /> : <ThreeBarsIcon size={18} />}
967
+ </button>
968
+ <div className={`${css.logo} smooth-corners`}><Icon name="iconoir/key-command" size={22} color="#fff" /></div>
969
+ <div>
970
+ <div className={css.appName}>{title}</div>
971
+ {subtitle && <div className={css.appSubtitle}>{subtitle}</div>}
972
+ </div>
973
+ </div>
974
+ <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>
988
+ </div>
989
+ </header>
990
+
991
+ {/* ─── Body: Sidebar + Content ─── */}
992
+ <div className={css.body}>
993
+ {/* ─── Sidebar ─── */}
994
+ <aside className={`${css.sidebar}${sidebarOpen ? ` ${css.sidebarOpen}` : ''}`}>
995
+ <div className={css.sidebarContent}>
996
+ <nav className={css.navSection}>
997
+ {NAV_ITEMS.map(nav => (
998
+ <button
999
+ key={nav.id}
1000
+ className={activeNav === nav.id ? css.navItemActive : css.navItem}
1001
+ onClick={() => { setActiveNav(nav.id); setSidebarOpen(false) }}
1002
+ >
1003
+ <span className={css.navIcon}><Icon name={nav.iconName} size={16} /></span>
1004
+ {nav.label}
1005
+ <span className={css.navCount}>{counts[nav.id]}</span>
1006
+ </button>
1007
+ ))}
1008
+ </nav>
1009
+
1010
+ <div className={css.separator} />
1011
+
1012
+ <div className={css.sectionLabel}>Starred</div>
1013
+ {starredItems.length === 0 && (
1014
+ <div className={css.starredEmpty}>Star items to pin them here</div>
1015
+ )}
1016
+ {starredItems.map(s => (
1017
+ <a
1018
+ key={s.id}
1019
+ className={css.starredItem}
1020
+ href={s.isExternal ? s.externalUrl : withBase(basePath, s.route)}
1021
+ {...(s.isExternal ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
1022
+ onClick={() => trackRecent(s.id)}
1023
+ >
1024
+ <span className={css.starredIcon}>{getTypeIcon(s.type)}</span>
1025
+ {s.name}
1026
+ </a>
1027
+ ))}
1028
+ </div>
1029
+
1030
+ {/* User profile / login */}
1031
+ <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>
1037
+ </div>
1038
+ </button>
1039
+ </div>
1040
+ </aside>
1041
+
1042
+ {/* ─── Main ─── */}
1043
+ <main className={css.main}>
1044
+ {/* Tabs */}
1045
+ <div className={css.tabs}>
1046
+ {TAB_FILTERS.map(t => (
1047
+ <button
1048
+ key={t}
1049
+ className={activeTab === t ? css.tabActive : css.tab}
1050
+ onClick={() => setActiveTab(t)}
1051
+ >
1052
+ {t}
1053
+ </button>
1054
+ ))}
1055
+ <label className={css.groupByFolders}>
1056
+ <input
1057
+ type="checkbox"
1058
+ className={css.groupByFoldersCheckbox}
1059
+ checked={groupByFolders}
1060
+ onChange={toggleGrouping}
1061
+ />
1062
+ Group by folders
1063
+ </label>
1064
+ </div>
1065
+
1066
+ {/* Grid */}
1067
+ <div className={css.content}>
1068
+ {items.length === 0 ? (
1069
+ <div className={css.emptyState}>
1070
+ {activeTab === 'Recent' && 'No recently opened items yet.'}
1071
+ {activeTab === 'Starred' && 'No starred items. Click ☆ on a card to star it.'}
1072
+ {activeTab === 'All' && 'No items found. Create a prototype, canvas, or component to get started.'}
1073
+ </div>
1074
+ ) : groupByFolders && grouped && activeTab === 'All' && activeNav === 'all' ? (
1075
+ <>
1076
+ {grouped.folders.map(folder => (
1077
+ <FolderSection
1078
+ key={folder.dirName}
1079
+ folder={folder}
1080
+ collapsed={collapsedFolders.has(folder.dirName)}
1081
+ onToggle={() => toggleFolder(folder.dirName)}
1082
+ basePath={basePath}
1083
+ starred={starred}
1084
+ onToggleStar={toggleStar}
1085
+ />
1086
+ ))}
1087
+ {grouped.ungrouped.length > 0 && (
1088
+ <div className={css.grid}>
1089
+ {grouped.ungrouped.map(item => (
1090
+ <ArtifactCard
1091
+ key={item.id}
1092
+ item={item}
1093
+ basePath={basePath}
1094
+ starred={starred.has(item.id)}
1095
+ onToggleStar={toggleStar}
1096
+ />
1097
+ ))}
1098
+ </div>
1099
+ )}
1100
+ </>
1101
+ ) : (
1102
+ <div className={css.grid}>
1103
+ {items.map(item => (
1104
+ <ArtifactCard
1105
+ key={item.id}
1106
+ item={item}
1107
+ basePath={basePath}
1108
+ starred={starred.has(item.id)}
1109
+ onToggleStar={toggleStar}
1110
+ />
1111
+ ))}
1112
+ </div>
1113
+ )}
1114
+ </div>
1115
+ </main>
1116
+ </div>
1117
+ </div>
1118
+ )
1119
+ }