@dfosco/storyboard-core 4.0.0-beta.2 → 4.0.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 (73) hide show
  1. package/dist/storyboard-ui.css +1 -1
  2. package/dist/storyboard-ui.js +11882 -11126
  3. package/dist/storyboard-ui.js.map +1 -1
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +11 -3
  6. package/paste.config.json +54 -0
  7. package/scaffold/deploy.yml +101 -0
  8. package/scaffold/githooks/pre-push +114 -0
  9. package/scaffold/manifest.json +11 -0
  10. package/scaffold/storyboard.config.json +4 -1
  11. package/src/ActionMenuButton.svelte +12 -2
  12. package/src/CanvasCreateMenu.svelte +228 -10
  13. package/src/CanvasSnap.svelte +2 -0
  14. package/src/CoreUIBar.svelte +152 -3
  15. package/src/CreateMenuButton.svelte +4 -1
  16. package/src/InspectorPanel.svelte +2 -0
  17. package/src/PwaInstallBanner.svelte +124 -0
  18. package/src/autosync/server.js +99 -111
  19. package/src/autosync/server.test.js +0 -7
  20. package/src/canvas/collision.js +206 -0
  21. package/src/canvas/collision.test.js +271 -0
  22. package/src/canvas/deriveCanvasId.test.js +40 -0
  23. package/src/canvas/identity.js +107 -0
  24. package/src/canvas/identity.test.js +100 -0
  25. package/src/canvas/server.js +285 -31
  26. package/src/canvasConfig.js +56 -0
  27. package/src/canvasConfig.test.js +42 -0
  28. package/src/cli/canvasAdd.js +185 -0
  29. package/src/cli/canvasRead.js +208 -0
  30. package/src/cli/code.js +67 -0
  31. package/src/cli/create.js +339 -72
  32. package/src/cli/dev-helpers.js +53 -0
  33. package/src/cli/dev-helpers.test.js +53 -0
  34. package/src/cli/dev.js +245 -26
  35. package/src/cli/flags.js +174 -0
  36. package/src/cli/flags.test.js +155 -0
  37. package/src/cli/index.js +84 -13
  38. package/src/cli/intro.js +37 -0
  39. package/src/cli/proxy.js +127 -6
  40. package/src/cli/proxy.test.js +63 -0
  41. package/src/cli/schemas.js +200 -0
  42. package/src/cli/serverUrl.js +56 -0
  43. package/src/cli/setup.js +130 -20
  44. package/src/cli/snapshots.js +335 -0
  45. package/src/cli/updateVersion.js +54 -3
  46. package/src/configSchema.js +125 -0
  47. package/src/configSchema.test.js +68 -0
  48. package/src/index.js +5 -0
  49. package/src/inspector/highlighter.js +10 -2
  50. package/src/lib/components/ui/trigger-button/trigger-button.svelte +1 -1
  51. package/src/loader.js +21 -2
  52. package/src/loader.test.js +63 -1
  53. package/src/mobileViewport.js +57 -0
  54. package/src/mobileViewport.test.js +68 -0
  55. package/src/mountStoryboardCore.js +61 -7
  56. package/src/rename-watcher/config.json +23 -0
  57. package/src/rename-watcher/watcher.js +538 -0
  58. package/src/svelte-plugin-ui/components/Viewfinder.svelte +6 -17
  59. package/src/tools/handlers/flows.js +6 -7
  60. package/src/viewfinder.js +21 -9
  61. package/src/viewfinder.test.js +2 -2
  62. package/src/vite/server-plugin.js +150 -7
  63. package/src/workshop/features/createCanvas/CreateCanvasForm.svelte +8 -2
  64. package/src/workshop/features/createFlow/CreateFlowForm.svelte +1 -1
  65. package/src/workshop/features/createPage/CreatePageForm.svelte +1 -1
  66. package/src/workshop/features/createPrototype/CreatePrototypeForm.svelte +2 -2
  67. package/src/workshop/features/createStory/CreateStoryForm.svelte +160 -0
  68. package/src/workshop/features/createStory/index.js +14 -0
  69. package/src/workshop/features/registry.js +2 -0
  70. package/src/worktree/port.js +57 -1
  71. package/src/worktree/port.test.js +91 -1
  72. package/toolbar.config.json +3 -3
  73. package/widgets.config.json +132 -27
@@ -0,0 +1,538 @@
1
+ /**
2
+ * Rename Watcher
3
+ *
4
+ * Detects file and directory renames under watched directories and updates
5
+ * canvas embed URLs (prototype and canvas widgets) to stay current.
6
+ * Auto-commits the changes with a configurable prefix.
7
+ *
8
+ * Uses snapshot-based diffing: on any fs event, re-scans the watched
9
+ * directories and compares old vs new file sets. Only unambiguous renames
10
+ * (1:1 file or directory mappings) are acted on.
11
+ *
12
+ * Configuration is loaded from config.json in this directory.
13
+ */
14
+
15
+ import fs from 'node:fs'
16
+ import path from 'node:path'
17
+ import { execFileSync } from 'node:child_process'
18
+ import { materializeFromText } from '../canvas/materializer.js'
19
+
20
+ // ─── Logging ─────────────────────────────────────────────────────────
21
+
22
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`
23
+ const green = (s) => `\x1b[32m${s}\x1b[0m`
24
+ const yellow = (s) => `\x1b[33m${s}\x1b[0m`
25
+
26
+ function log(msg) { console.log(dim(` ◈ rename-watcher: ${msg}`)) }
27
+ function logSuccess(msg) { console.log(green(` ✓ rename-watcher: ${msg}`)) }
28
+ function logWarn(msg) { console.log(yellow(` ⚠ rename-watcher: ${msg}`)) }
29
+
30
+ // ─── Config ──────────────────────────────────────────────────────────
31
+
32
+ function loadConfig() {
33
+ const configPath = new URL('./config.json', import.meta.url)
34
+ return JSON.parse(fs.readFileSync(configPath, 'utf-8'))
35
+ }
36
+
37
+ // ─── File scanning ───────────────────────────────────────────────────
38
+
39
+ /**
40
+ * Scan a watched directory and return a Set of relative file paths
41
+ * matching the configured extensions and exclusions.
42
+ */
43
+ function scanDirectory(root, watchEntry, config) {
44
+ const results = new Set()
45
+ const absDir = path.join(root, watchEntry.path)
46
+
47
+ if (!fs.existsSync(absDir)) return results
48
+
49
+ const excludeDirs = new Set(config.exclude.directories)
50
+ const excludePrefixes = config.exclude.filePrefixes
51
+ const extensions = watchEntry.extensions
52
+
53
+ function walk(dir, rel) {
54
+ let entries
55
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
56
+
57
+ for (const entry of entries) {
58
+ if (excludeDirs.has(entry.name)) continue
59
+ const relPath = rel ? `${rel}/${entry.name}` : entry.name
60
+
61
+ if (entry.isDirectory()) {
62
+ walk(path.join(dir, entry.name), relPath)
63
+ } else {
64
+ if (excludePrefixes.some((p) => entry.name.startsWith(p))) continue
65
+ if (extensions.some((ext) => entry.name.endsWith(ext))) {
66
+ results.add(relPath)
67
+ }
68
+ }
69
+ }
70
+ }
71
+
72
+ walk(absDir, '')
73
+ return results
74
+ }
75
+
76
+ // ─── Route computation ───────────────────────────────────────────────
77
+
78
+ /**
79
+ * Compute the route path for a prototype file (relative to src/prototypes/).
80
+ * Mirrors the route regex in src/routes.jsx.
81
+ */
82
+ function prototypeRoute(relPath) {
83
+ let route = relPath
84
+ .replace(/[^/]*\.folder\//g, '')
85
+ .replace(/\.(jsx|tsx|mdx)$/, '')
86
+ .replace(/\/index$/, '')
87
+
88
+ if (!route.startsWith('/')) route = '/' + route
89
+ return route || '/'
90
+ }
91
+
92
+ /**
93
+ * Compute the route path for a canvas file (relative to src/canvas/).
94
+ * Mirrors the data-plugin.js route inference.
95
+ */
96
+ function canvasRoute(relPath) {
97
+ const name = path.basename(relPath).replace(/\.canvas\.jsonl$/, '')
98
+ const dirPath = path.dirname(relPath)
99
+ const routeBase = (dirPath === '.' ? '' : dirPath + '/')
100
+ .replace(/[^/]*\.folder\/?/g, '')
101
+ .replace(/\/$/, '')
102
+
103
+ let route = '/canvas/' + (routeBase ? routeBase + '/' : '') + name
104
+ route = route.replace(/\/+/g, '/').replace(/\/$/, '')
105
+ return route || '/canvas'
106
+ }
107
+
108
+ function computeRoute(relPath, watchType) {
109
+ if (watchType === 'prototype') return prototypeRoute(relPath)
110
+ if (watchType === 'canvas') return canvasRoute(relPath)
111
+ return null
112
+ }
113
+
114
+ // ─── Rename detection ────────────────────────────────────────────────
115
+
116
+ /**
117
+ * Get compound extension (e.g. '.canvas.jsonl' not just '.jsonl').
118
+ */
119
+ function getCompoundExt(filePath) {
120
+ const base = path.basename(filePath)
121
+ const parts = base.split('.')
122
+ if (parts.length >= 3) return '.' + parts.slice(-2).join('.')
123
+ return path.extname(filePath)
124
+ }
125
+
126
+ function groupByDir(paths) {
127
+ const groups = new Map()
128
+ for (const p of paths) {
129
+ const dir = path.dirname(p)
130
+ if (!groups.has(dir)) groups.set(dir, [])
131
+ groups.get(dir).push(p)
132
+ }
133
+ return groups
134
+ }
135
+
136
+ /**
137
+ * Find the longest common directory prefix of an array of paths.
138
+ */
139
+ function commonDirPrefix(paths) {
140
+ if (paths.length === 0) return ''
141
+ if (paths.length === 1) {
142
+ const dir = path.dirname(paths[0])
143
+ return dir === '.' ? '' : dir + '/'
144
+ }
145
+
146
+ const sorted = [...paths].sort()
147
+ const first = sorted[0]
148
+ const last = sorted[sorted.length - 1]
149
+
150
+ let i = 0
151
+ while (i < first.length && i < last.length && first[i] === last[i]) i++
152
+
153
+ const prefix = first.slice(0, i)
154
+ const lastSlash = prefix.lastIndexOf('/')
155
+ return lastSlash >= 0 ? prefix.slice(0, lastSlash + 1) : ''
156
+ }
157
+
158
+ /**
159
+ * Detect renames by diffing old and new file snapshots.
160
+ * Only returns unambiguous renames to avoid false positives.
161
+ *
162
+ * Phase 1 — File renames: same directory, same extension, exactly 1 removed + 1 added.
163
+ * Phase 2 — Directory renames: a single directory-prefix swap explains all remaining changes.
164
+ */
165
+ function detectRenames(oldSnapshot, newSnapshot, watchType) {
166
+ const removed = [...oldSnapshot].filter((f) => !newSnapshot.has(f))
167
+ const added = [...newSnapshot].filter((f) => !oldSnapshot.has(f))
168
+
169
+ if (removed.length === 0 || added.length === 0) return []
170
+
171
+ const renames = []
172
+ const matchedRemoved = new Set()
173
+ const matchedAdded = new Set()
174
+
175
+ // Phase 1: File-level renames
176
+ const removedByDir = groupByDir(removed)
177
+ const addedByDir = groupByDir(added)
178
+
179
+ for (const [dir, dirRemoved] of removedByDir) {
180
+ const dirAdded = addedByDir.get(dir) || []
181
+ if (dirRemoved.length !== 1 || dirAdded.length !== 1) continue
182
+ if (getCompoundExt(dirRemoved[0]) !== getCompoundExt(dirAdded[0])) continue
183
+
184
+ const oldRoute = computeRoute(dirRemoved[0], watchType)
185
+ const newRoute = computeRoute(dirAdded[0], watchType)
186
+
187
+ if (oldRoute === newRoute) continue
188
+
189
+ renames.push({ oldPath: dirRemoved[0], newPath: dirAdded[0], oldRoute, newRoute })
190
+ matchedRemoved.add(dirRemoved[0])
191
+ matchedAdded.add(dirAdded[0])
192
+ }
193
+
194
+ // Phase 2: Directory-level renames
195
+ const unmatchedRemoved = removed.filter((f) => !matchedRemoved.has(f))
196
+ const unmatchedAdded = added.filter((f) => !matchedAdded.has(f))
197
+
198
+ if (unmatchedRemoved.length > 0 && unmatchedRemoved.length === unmatchedAdded.length) {
199
+ const oldPrefix = commonDirPrefix(unmatchedRemoved)
200
+ const newPrefix = commonDirPrefix(unmatchedAdded)
201
+
202
+ if (oldPrefix && newPrefix && oldPrefix !== newPrefix) {
203
+ const expectedAdded = new Set(
204
+ unmatchedRemoved.map((f) => newPrefix + f.slice(oldPrefix.length)),
205
+ )
206
+ const allMatch = unmatchedAdded.every((f) => expectedAdded.has(f))
207
+
208
+ if (allMatch) {
209
+ for (const oldFile of unmatchedRemoved) {
210
+ const newFile = newPrefix + oldFile.slice(oldPrefix.length)
211
+ const oldRoute = computeRoute(oldFile, watchType)
212
+ const newRoute = computeRoute(newFile, watchType)
213
+ if (oldRoute !== newRoute) {
214
+ renames.push({ oldPath: oldFile, newPath: newFile, oldRoute, newRoute })
215
+ }
216
+ }
217
+ }
218
+ }
219
+ }
220
+
221
+ return renames
222
+ }
223
+
224
+ // ─── Canvas embed updating ───────────────────────────────────────────
225
+
226
+ /**
227
+ * Find all .canvas.jsonl files in the project.
228
+ */
229
+ function findAllCanvasFiles(root) {
230
+ const results = []
231
+ const ignore = new Set(['node_modules', 'dist', '.git', '.worktrees'])
232
+
233
+ function walk(dir) {
234
+ let entries
235
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
236
+ for (const entry of entries) {
237
+ if (ignore.has(entry.name)) continue
238
+ const fullPath = path.join(dir, entry.name)
239
+ if (entry.isDirectory()) walk(fullPath)
240
+ else if (entry.name.endsWith('.canvas.jsonl')) results.push(fullPath)
241
+ }
242
+ }
243
+
244
+ walk(root)
245
+ return results
246
+ }
247
+
248
+ /**
249
+ * Rewrite a URL's pathname if it matches a renamed route.
250
+ * Uses segment-boundary matching to avoid partial matches (e.g. /Signup vs /Signup2).
251
+ */
252
+ function rewriteUrl(src, renames) {
253
+ const hashIdx = src.indexOf('#')
254
+ const queryIdx = src.indexOf('?')
255
+
256
+ let pathname, suffix
257
+ if (hashIdx >= 0 && (queryIdx < 0 || hashIdx < queryIdx)) {
258
+ pathname = src.slice(0, hashIdx)
259
+ suffix = src.slice(hashIdx)
260
+ } else if (queryIdx >= 0) {
261
+ pathname = src.slice(0, queryIdx)
262
+ suffix = src.slice(queryIdx)
263
+ } else {
264
+ pathname = src
265
+ suffix = ''
266
+ }
267
+
268
+ for (const { oldRoute, newRoute } of renames) {
269
+ if (!oldRoute || !newRoute) continue
270
+
271
+ // Exact match
272
+ if (pathname === oldRoute) return newRoute + suffix
273
+ // Segment-boundary prefix match
274
+ if (pathname.startsWith(oldRoute + '/')) {
275
+ return newRoute + pathname.slice(oldRoute.length) + suffix
276
+ }
277
+ }
278
+
279
+ return src
280
+ }
281
+
282
+ /**
283
+ * Update widget embed refs for detected renames.
284
+ * Returns the updated widgets array if any changed, null if unchanged.
285
+ */
286
+ function updateWidgetRefs(widgets, renames) {
287
+ let changed = false
288
+
289
+ const updated = widgets.map((widget) => {
290
+ // Only process embed widget types with a src prop
291
+ if (widget.type !== 'prototype' && widget.type !== 'canvas') return widget
292
+
293
+ const src = widget.props?.src
294
+ if (!src || typeof src !== 'string') return widget
295
+
296
+ const newSrc = rewriteUrl(src, renames)
297
+ if (newSrc === src) return widget
298
+
299
+ changed = true
300
+ return { ...widget, props: { ...widget.props, src: newSrc } }
301
+ })
302
+
303
+ return changed ? updated : null
304
+ }
305
+
306
+ // ─── Auto-commit ─────────────────────────────────────────────────────
307
+
308
+ // Lock file placed in .git/ during commit. Both the rename watcher and
309
+ // autosync share the same working tree and git index, so this file
310
+ // lets autosync's isRepoBusy() (or any future tool) detect that the
311
+ // rename watcher is mid-commit and defer its own cycle.
312
+ const LOCK_FILENAME = 'storyboard-autofix.lock'
313
+
314
+ /**
315
+ * Resolve the .git directory, handling both regular repos and worktrees.
316
+ * In a worktree, `.git` is a file pointing to the real git dir.
317
+ */
318
+ function resolveGitDir(root) {
319
+ const dotGit = path.join(root, '.git')
320
+ try {
321
+ const stat = fs.statSync(dotGit)
322
+ if (stat.isDirectory()) return dotGit
323
+ // Worktree: .git is a file like "gitdir: /path/to/.git/worktrees/name"
324
+ const content = fs.readFileSync(dotGit, 'utf-8').trim()
325
+ const match = content.match(/^gitdir:\s*(.+)$/)
326
+ if (match) return path.resolve(root, match[1])
327
+ } catch { /* fallback */ }
328
+ return dotGit
329
+ }
330
+
331
+ /**
332
+ * Check if the repo is in a busy state that would conflict with a commit.
333
+ *
334
+ * Mirrors autosync's isRepoBusy() guards so both systems respect the same
335
+ * signals: index.lock, rebase, merge, cherry-pick, and the autofix lock file.
336
+ * Since both autosync and the rename watcher commit directly on the current
337
+ * branch (same working tree, same index), index.lock is the primary mutex.
338
+ */
339
+ function isRepoBusy(root) {
340
+ const gitDir = resolveGitDir(root)
341
+
342
+ if (fs.existsSync(path.join(gitDir, 'index.lock'))) {
343
+ logWarn('Auto-commit deferred: index.lock present')
344
+ return true
345
+ }
346
+ if (fs.existsSync(path.join(gitDir, 'MERGE_HEAD'))) {
347
+ logWarn('Auto-commit deferred: merge in progress')
348
+ return true
349
+ }
350
+ if (fs.existsSync(path.join(gitDir, 'CHERRY_PICK_HEAD'))) {
351
+ logWarn('Auto-commit deferred: cherry-pick in progress')
352
+ return true
353
+ }
354
+ if (fs.existsSync(path.join(gitDir, 'rebase-merge')) || fs.existsSync(path.join(gitDir, 'rebase-apply'))) {
355
+ logWarn('Auto-commit deferred: rebase in progress')
356
+ return true
357
+ }
358
+
359
+ // Check for our own stale lock (crash recovery)
360
+ const lockPath = path.join(gitDir, LOCK_FILENAME)
361
+ if (fs.existsSync(lockPath)) {
362
+ try {
363
+ const lockAge = Date.now() - fs.statSync(lockPath).mtimeMs
364
+ if (lockAge < 10_000) {
365
+ logWarn('Auto-commit deferred: previous autofix still in progress')
366
+ return true
367
+ }
368
+ // Stale lock (>10s) — remove it
369
+ fs.unlinkSync(lockPath)
370
+ } catch { /* race — fine */ }
371
+ }
372
+
373
+ return false
374
+ }
375
+
376
+ /**
377
+ * Auto-commit modified canvas files using git commit --only to isolate
378
+ * from any other staged or unstaged user work.
379
+ *
380
+ * Coordinates with autosync (which shares the same working tree + index) via:
381
+ * - Lock file (.git/storyboard-autofix.lock) to signal mid-commit
382
+ * - Same repo-busy guards autosync checks (index.lock, merge, rebase, cherry-pick)
383
+ * - git commit --only uses a temporary index, so it won't interfere with
384
+ * files autosync may have staged independently
385
+ *
386
+ * If the commit is deferred (busy repo), the canvas JSONL update is still
387
+ * persisted on disk — autosync's next cycle will pick it up naturally.
388
+ */
389
+ function autocommit(root, modifiedFiles, renames, config) {
390
+ if (!config.autocommit.enabled || modifiedFiles.length === 0) return
391
+
392
+ const relPaths = modifiedFiles.map((f) => path.relative(root, f))
393
+
394
+ if (isRepoBusy(root)) return
395
+
396
+ const gitDir = resolveGitDir(root)
397
+ const lockPath = path.join(gitDir, LOCK_FILENAME)
398
+
399
+ try {
400
+ // Acquire lock so autosync (or other tools) can defer
401
+ fs.writeFileSync(lockPath, `${process.pid}\n${Date.now()}\n`, 'utf-8')
402
+
403
+ execFileSync('git', ['add', '--', ...relPaths], { cwd: root, stdio: 'pipe' })
404
+
405
+ const summary = [...new Set(
406
+ renames
407
+ .filter((r) => r.oldRoute && r.newRoute)
408
+ .map((r) => `${r.oldRoute} → ${r.newRoute}`),
409
+ )].join(', ')
410
+
411
+ const message = `${config.autocommit.prefix} Update embed URLs: ${summary}`
412
+
413
+ execFileSync('git', ['commit', '--only', '--', ...relPaths, '-m', message], {
414
+ cwd: root,
415
+ stdio: 'pipe',
416
+ })
417
+
418
+ logSuccess(`Auto-committed: ${summary}`)
419
+ } catch (err) {
420
+ logWarn(`Auto-commit skipped: ${(err.message || '').split('\n')[0]}`)
421
+ } finally {
422
+ // Release lock
423
+ try { fs.unlinkSync(lockPath) } catch { /* already gone */ }
424
+ }
425
+ }
426
+
427
+ // ─── Main ────────────────────────────────────────────────────────────
428
+
429
+ /**
430
+ * Process a detected change — rescan, detect renames, update embeds, commit.
431
+ */
432
+ function processChange(root, snapshots, config) {
433
+ const allRenames = []
434
+
435
+ for (const entry of config.watch) {
436
+ const oldSnapshot = snapshots.get(entry.path)
437
+ const newSnapshot = scanDirectory(root, entry, config)
438
+ const renames = detectRenames(oldSnapshot, newSnapshot, entry.type)
439
+ if (renames.length > 0) allRenames.push(...renames)
440
+ snapshots.set(entry.path, newSnapshot)
441
+ }
442
+
443
+ if (allRenames.length === 0) return
444
+
445
+ // Deduplicate by route pair
446
+ const uniqueRenames = []
447
+ const seen = new Set()
448
+ for (const r of allRenames) {
449
+ const key = `${r.oldRoute}→${r.newRoute}`
450
+ if (!seen.has(key)) {
451
+ seen.add(key)
452
+ uniqueRenames.push(r)
453
+ log(`Detected: ${r.oldRoute} → ${r.newRoute}`)
454
+ }
455
+ }
456
+
457
+ // Scan all canvas files and update embed references
458
+ const canvasFiles = findAllCanvasFiles(root)
459
+ const modifiedFiles = []
460
+
461
+ for (const canvasFile of canvasFiles) {
462
+ try {
463
+ const text = fs.readFileSync(canvasFile, 'utf-8')
464
+ const state = materializeFromText(text)
465
+
466
+ if (!state.widgets || state.widgets.length === 0) continue
467
+
468
+ const updatedWidgets = updateWidgetRefs(state.widgets, uniqueRenames)
469
+ if (updatedWidgets) {
470
+ const event = {
471
+ event: 'widgets_replaced',
472
+ timestamp: new Date().toISOString(),
473
+ widgets: updatedWidgets,
474
+ }
475
+ fs.appendFileSync(canvasFile, JSON.stringify(event) + '\n', 'utf-8')
476
+ modifiedFiles.push(canvasFile)
477
+ log(`Updated embeds in ${path.relative(root, canvasFile)}`)
478
+ }
479
+ } catch (err) {
480
+ logWarn(`Failed to update ${path.basename(canvasFile)}: ${err.message}`)
481
+ }
482
+ }
483
+
484
+ autocommit(root, modifiedFiles, uniqueRenames, config)
485
+ }
486
+
487
+ /**
488
+ * Start the rename watcher.
489
+ * @param {string} root — project root directory
490
+ * @returns {{ close: () => void }}
491
+ */
492
+ export function startRenameWatcher(root) {
493
+ const config = loadConfig()
494
+ const watchers = []
495
+ const snapshots = new Map()
496
+
497
+ for (const entry of config.watch) {
498
+ snapshots.set(entry.path, scanDirectory(root, entry, config))
499
+ }
500
+
501
+ let debounceTimer = null
502
+
503
+ function handleChange() {
504
+ if (debounceTimer) clearTimeout(debounceTimer)
505
+ debounceTimer = setTimeout(() => {
506
+ try {
507
+ processChange(root, snapshots, config)
508
+ } catch (err) {
509
+ logWarn(`Error: ${err.message}`)
510
+ }
511
+ }, config.debounceMs)
512
+ }
513
+
514
+ for (const entry of config.watch) {
515
+ const absDir = path.join(root, entry.path)
516
+ if (!fs.existsSync(absDir)) {
517
+ logWarn(`Skipping ${entry.path} (not found)`)
518
+ continue
519
+ }
520
+
521
+ try {
522
+ const watcher = fs.watch(absDir, { recursive: true }, handleChange)
523
+ watcher.on('error', (err) => logWarn(`${entry.path}: ${err.message}`))
524
+ watchers.push(watcher)
525
+ } catch (err) {
526
+ logWarn(`Could not watch ${entry.path}: ${err.message}`)
527
+ }
528
+ }
529
+
530
+ log('Active')
531
+
532
+ return {
533
+ close() {
534
+ if (debounceTimer) clearTimeout(debounceTimer)
535
+ for (const w of watchers) w.close()
536
+ },
537
+ }
538
+ }
@@ -183,16 +183,14 @@
183
183
  return `<svg viewBox="0 0 320 200" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><rect width="320" height="200" fill="var(--sb--placeholder-bg)" />${lines}${rects}</svg>`
184
184
  }
185
185
 
186
- // Branch switching
186
+ // Branch switching — populated by Vite server plugin when available
187
187
  interface Branch { branch: string; folder: string }
188
188
 
189
- const MOCK_BRANCHES: Branch[] = [
190
- { branch: 'main', folder: '' },
191
- { branch: 'feat/comments-v2', folder: 'branch--feat-comments-v2' },
192
- { branch: 'fix/nav-overflow', folder: 'branch--fix-nav-overflow' },
193
- ]
194
-
195
- let branches: Branch[] | null = $state(null)
189
+ let branches: Branch[] | null = $state(
190
+ (typeof window !== 'undefined' && Array.isArray((window as any).__SB_BRANCHES__))
191
+ ? (window as any).__SB_BRANCHES__
192
+ : null
193
+ )
196
194
 
197
195
  const branchBasePath = $derived(
198
196
  (basePath || '/storyboard-source/').replace(/\/branch--[^/]*\/$/, '/')
@@ -205,15 +203,6 @@
205
203
  })()
206
204
  )
207
205
 
208
- $effect(() => {
209
- fetch(`${branchBasePath}branches.json`)
210
- .then(r => r.ok ? r.json() : null)
211
- .then((data: any) => {
212
- branches = Array.isArray(data) && data.length > 0 ? data : MOCK_BRANCHES
213
- })
214
- .catch(() => { branches = MOCK_BRANCHES })
215
- })
216
-
217
206
  function handleBranchChange(e: Event) {
218
207
  const folder = (e.target as HTMLSelectElement).value
219
208
  if (folder) {
@@ -45,18 +45,17 @@ export async function handler(ctx) {
45
45
 
46
46
  return flows.map(f => {
47
47
  const meta = vf.getFlowMeta(f.key)
48
+ let url = vf.resolveFlowRoute(f.key)
49
+ // Re-apply basePath and branch-- prefix so deployed branch builds stay on the correct path
50
+ const prefix = (base || '') + (branchSegment ? `/${branchSegment}` : '')
51
+ if (prefix) url = prefix + url
48
52
  return {
49
53
  id: f.key,
50
54
  label: meta?.title || f.name,
51
55
  type: 'radio',
52
56
  active: f.key === active,
53
- execute: () => {
54
- let url = vf.resolveFlowRoute(f.key)
55
- // Re-apply basePath and branch-- prefix so deployed branch builds stay on the correct path
56
- const prefix = (base || '') + (branchSegment ? `/${branchSegment}` : '')
57
- if (prefix) url = prefix + url
58
- window.location.href = url
59
- },
57
+ href: url,
58
+ execute: () => { window.location.href = url },
60
59
  }
61
60
  })
62
61
  },
package/src/viewfinder.js CHANGED
@@ -114,7 +114,7 @@ export function buildPrototypeIndex(knownRoutes = []) {
114
114
  icon: meta.icon || null,
115
115
  team: meta.team || null,
116
116
  tags: meta.tags || null,
117
- hideFlows: meta.hideFlows ?? raw?.hideFlows ?? false,
117
+ hideFlows: meta.hideFlows ?? raw?.hideFlows ?? true,
118
118
  folder: raw?.folder || null,
119
119
  isExternal,
120
120
  externalUrl: isExternal ? raw.url : null,
@@ -139,7 +139,7 @@ export function buildPrototypeIndex(knownRoutes = []) {
139
139
  icon: null,
140
140
  team: null,
141
141
  tags: null,
142
- hideFlows: false,
142
+ hideFlows: true,
143
143
  folder: null,
144
144
  isExternal: false,
145
145
  externalUrl: null,
@@ -199,21 +199,33 @@ export function buildPrototypeIndex(knownRoutes = []) {
199
199
  const folders = Object.values(folderMap)
200
200
  const prototypes = ungrouped
201
201
 
202
- // Build canvas entries
202
+ // Build canvas entries — collapse grouped pages into a single entry per group
203
203
  const canvasEntries = []
204
+ const seenGroups = new Map() // group name → index in canvasEntries
204
205
  for (const canvasName of listCanvases()) {
205
206
  const data = getCanvasData(canvasName)
206
207
  if (!data) continue
207
- canvasEntries.push({
208
- name: data.title || canvasName,
208
+ const meta = data._canvasMeta
209
+ const group = data._group || null
210
+
211
+ // If this canvas belongs to a group we've already seen, skip it
212
+ // (the first page in the group represents the whole canvas)
213
+ if (group && seenGroups.has(group)) continue
214
+
215
+ const entry = {
216
+ name: meta?.title || data.title || canvasName,
209
217
  dirName: canvasName,
210
- description: data.description || null,
211
- route: data._route || `/${canvasName}`,
218
+ description: meta?.description || data.description || null,
219
+ route: data._route || `/canvas/${canvasName}`,
212
220
  folder: data._folder || null,
213
221
  isCanvas: true,
214
- author: data.author || null,
222
+ author: meta?.author || data.author || null,
215
223
  gitAuthor: data.gitAuthor || null,
216
- })
224
+ _canvasMeta: meta || null,
225
+ _group: group,
226
+ }
227
+ if (group) seenGroups.set(group, canvasEntries.length)
228
+ canvasEntries.push(entry)
217
229
  }
218
230
 
219
231
  // Add canvases to their folders or to ungrouped
@@ -197,7 +197,7 @@ describe('buildPrototypeIndex', () => {
197
197
  expect(proto.flows).toHaveLength(1)
198
198
  })
199
199
 
200
- it('defaults hideFlows to false when not set', () => {
200
+ it('defaults hideFlows to true when not set', () => {
201
201
  init({
202
202
  flows: { 'Other/flow-a': { meta: { title: 'A' } } },
203
203
  objects: {},
@@ -208,7 +208,7 @@ describe('buildPrototypeIndex', () => {
208
208
  })
209
209
  const { prototypes } = buildPrototypeIndex([])
210
210
  const proto = prototypes.find(p => p.dirName === 'Other')
211
- expect(proto.hideFlows).toBe(false)
211
+ expect(proto.hideFlows).toBe(true)
212
212
  })
213
213
 
214
214
  it('reads hideFlows from top-level prototype metadata (outside meta key)', () => {