@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
@@ -13,6 +13,7 @@
13
13
  import fs from 'node:fs'
14
14
  import path from 'node:path'
15
15
  import { parse as parseJsonc } from 'jsonc-parser'
16
+ import { getConfig } from '../configSchema.js'
16
17
  import { serverFeatures as workshopFeatures } from '../workshop/features/registry-server.js'
17
18
  import { docsHandler, collectFiles } from './docs-handler.js'
18
19
  import { createCanvasHandler } from '../canvas/server.js'
@@ -45,16 +46,16 @@ function sendJson(res, status, data) {
45
46
  }
46
47
 
47
48
  /**
48
- * Read storyboard.config.json from the project root.
49
+ * Read storyboard.config.json from the project root and apply defaults.
49
50
  */
50
51
  function readConfig(root) {
51
52
  const configPath = path.join(root, 'storyboard.config.json')
52
- if (!fs.existsSync(configPath)) return {}
53
+ if (!fs.existsSync(configPath)) return getConfig({})
53
54
  try {
54
55
  const raw = fs.readFileSync(configPath, 'utf-8')
55
- return parseJsonc(raw) || {}
56
+ return getConfig(parseJsonc(raw) || {})
56
57
  } catch {
57
- return {}
58
+ return getConfig({})
58
59
  }
59
60
  }
60
61
 
@@ -74,6 +75,19 @@ export default function storyboardServer() {
74
75
  return {
75
76
  name: 'storyboard-server',
76
77
 
78
+ config() {
79
+ return {
80
+ optimizeDeps: {
81
+ include: [
82
+ 'highlight.js/lib/core',
83
+ 'highlight.js/lib/languages/javascript',
84
+ 'highlight.js/lib/languages/typescript',
85
+ 'highlight.js/lib/languages/xml',
86
+ ],
87
+ },
88
+ }
89
+ },
90
+
77
91
  configResolved(viteConfig) {
78
92
  root = viteConfig.root
79
93
  base = viteConfig.base || '/'
@@ -82,6 +96,82 @@ export default function storyboardServer() {
82
96
  },
83
97
 
84
98
  configureServer(server) {
99
+ // --- Canvas reload guard ---------------------------------------------------
100
+ // Suppress full-reloads and HMR updates for clients on canvas routes.
101
+ // Canvas pages send heartbeats via import.meta.hot; the guard auto-expires
102
+ // 5s after the last heartbeat so closed tabs never leave it stuck.
103
+ // Opt out with ?canvas-hmr in the URL when developing canvas UI code.
104
+ {
105
+ let recentCanvasMutationAt = 0
106
+ const CANVAS_WINDOW_MS = 1500
107
+ const GUARD_TTL_MS = 5000
108
+ const isCanvasFile = (file = '') => /\.canvas\.jsonl$/i.test(file.replace(/\\/g, '/'))
109
+
110
+ const markCanvasMutation = (file = '') => {
111
+ if (isCanvasFile(file)) recentCanvasMutationAt = Date.now()
112
+ }
113
+
114
+ server.watcher.on('change', markCanvasMutation)
115
+ server.watcher.on('add', markCanvasMutation)
116
+ server.watcher.on('unlink', markCanvasMutation)
117
+
118
+ const guardedClients = new Map()
119
+
120
+ server.hot.on('storyboard:canvas-hmr-guard', (data, client) => {
121
+ if (data.active && !data.hmrEnabled) {
122
+ guardedClients.set(client, Date.now() + GUARD_TTL_MS)
123
+ } else {
124
+ guardedClients.delete(client)
125
+ }
126
+ })
127
+
128
+ const cleanup = setInterval(() => {
129
+ const now = Date.now()
130
+ for (const [client, until] of guardedClients) {
131
+ if (now > until || !server.ws.clients.has(client)) {
132
+ guardedClients.delete(client)
133
+ }
134
+ }
135
+ }, 10000)
136
+ server.httpServer?.on('close', () => clearInterval(cleanup))
137
+
138
+ function isClientGuarded(client) {
139
+ const until = guardedClients.get(client)
140
+ return until != null && Date.now() < until
141
+ }
142
+
143
+ const originalSend = server.ws.send.bind(server.ws)
144
+ server.ws.send = (payload, ...rest) => {
145
+ // Suppress broadcast reloads within the canvas mutation window
146
+ if (
147
+ payload &&
148
+ payload.type === 'full-reload' &&
149
+ Date.now() - recentCanvasMutationAt < CANVAS_WINDOW_MS
150
+ ) {
151
+ return
152
+ }
153
+
154
+ // No guarded clients → broadcast normally
155
+ if (guardedClients.size === 0) {
156
+ return originalSend(payload, ...rest)
157
+ }
158
+
159
+ // For reload/update payloads, send only to unguarded clients
160
+ if (payload && (payload.type === 'full-reload' || payload.type === 'update')) {
161
+ for (const client of server.ws.clients) {
162
+ if (!isClientGuarded(client)) {
163
+ client.send(payload)
164
+ }
165
+ }
166
+ return
167
+ }
168
+
169
+ // Everything else (custom events, errors) broadcasts normally
170
+ return originalSend(payload, ...rest)
171
+ }
172
+ }
173
+ // --- End canvas reload guard -----------------------------------------------
174
+
85
175
  const workshopConfig = config.workshop || {}
86
176
  const enabledFeatures = workshopConfig.features || {}
87
177
 
@@ -109,9 +199,9 @@ export default function storyboardServer() {
109
199
  // Wire canvas API routes (always enabled — CRUD for .canvas.jsonl files)
110
200
  routeHandlers.set('canvas', createCanvasHandler({ root, sendJson }))
111
201
 
112
- // Ignore src/canvas/images/ so pasted image writes don't trigger reloads
113
- const canvasImagesDir = path.join(root, 'src', 'canvas', 'images')
114
- server.watcher.unwatch(canvasImagesDir)
202
+ // Ignore assets/canvas/ so image/snapshot writes don't trigger reloads
203
+ server.watcher.unwatch(path.join(root, 'assets', 'canvas', 'images'))
204
+ server.watcher.unwatch(path.join(root, 'assets', 'canvas', 'snapshots'))
115
205
 
116
206
  // Wire autosync API routes (always enabled — git automation for dev)
117
207
  routeHandlers.set('autosync', createAutosyncHandler({ root, sendJson }))
@@ -300,6 +390,47 @@ export default function storyboardServer() {
300
390
  })
301
391
  }
302
392
 
393
+ // Emit story sources JSON so the "show code" widget action works in
394
+ // deployed builds. In dev, StoryWidget uses Vite's ?raw import; in prod
395
+ // it fetches this static JSON instead.
396
+ const storySources = {}
397
+ const storyExts = ['.story.jsx', '.story.tsx', '.story.js', '.story.ts']
398
+ for (const relPath of allSrcFiles) {
399
+ if (storyExts.some(ext => relPath.endsWith(ext))) {
400
+ storySources[relPath] = sources[relPath] || ''
401
+ }
402
+ }
403
+ if (Object.keys(storySources).length > 0) {
404
+ this.emitFile({
405
+ type: 'asset',
406
+ fileName: '_storyboard/stories/sources.json',
407
+ source: JSON.stringify(storySources),
408
+ })
409
+ }
410
+
411
+ // Emit canvas images and snapshots so they're available in deployed (static) builds.
412
+ // Dev server serves these dynamically; production needs the static files.
413
+ // Private images (prefixed with _) are excluded from the build.
414
+ for (const dir of [
415
+ path.join(root, 'assets', 'canvas', 'images'),
416
+ path.join(root, 'assets', 'canvas', 'snapshots'),
417
+ ]) {
418
+ try {
419
+ const imageFiles = await fs.promises.readdir(dir)
420
+ for (const file of imageFiles) {
421
+ if (file.startsWith('_') || file.startsWith('.')) continue
422
+ try {
423
+ const data = await fs.promises.readFile(path.join(dir, file))
424
+ this.emitFile({
425
+ type: 'asset',
426
+ fileName: `_storyboard/canvas/images/${file}`,
427
+ source: data,
428
+ })
429
+ } catch { /* skip unreadable files */ }
430
+ }
431
+ } catch { /* directory doesn't exist */ }
432
+ }
433
+
303
434
  // GitHub Pages uses Jekyll which ignores _-prefixed directories.
304
435
  // Emit .nojekyll to ensure _storyboard/ is served.
305
436
  this.emitFile({
@@ -307,6 +438,18 @@ export default function storyboardServer() {
307
438
  fileName: '.nojekyll',
308
439
  source: '',
309
440
  })
441
+
442
+ // Emit CNAME for GitHub Pages custom domain if configured.
443
+ // Without this, deploy scripts that clean the gh-pages root will
444
+ // delete the CNAME on every push, causing intermittent 404s.
445
+ const customDomain = (config.customDomain || '').trim()
446
+ if (customDomain && !customDomain.includes('/') && !customDomain.includes(':') && !customDomain.includes(' ')) {
447
+ this.emitFile({
448
+ type: 'asset',
449
+ fileName: 'CNAME',
450
+ source: customDomain + '\n',
451
+ })
452
+ }
310
453
  },
311
454
  }
312
455
  }
@@ -18,6 +18,7 @@
18
18
  let name = $state('')
19
19
  let title = $state('')
20
20
  let titleTouched = $state(false)
21
+ let description = $state('')
21
22
  let folder = $state('')
22
23
  let includeJsx = $state(false)
23
24
  let grid = $state(true)
@@ -45,7 +46,7 @@
45
46
  const canSubmit = $derived(!!kebabName && !nameError && !submitting)
46
47
 
47
48
  function getApiUrl() {
48
- const basePath = document.querySelector('base')?.getAttribute('href') || '/'
49
+ const basePath = window.__STORYBOARD_BASE_PATH__ || '/'
49
50
  return basePath.replace(/\/$/, '') + '/_storyboard/canvas'
50
51
  }
51
52
 
@@ -81,7 +82,7 @@
81
82
  try {
82
83
  const res = await fetch(getApiUrl() + '/create', {
83
84
  method: 'POST', headers: { 'Content-Type': 'application/json' },
84
- body: JSON.stringify({ name: kebabName, title: displayTitle, folder: folder || undefined, grid, includeJsx }),
85
+ body: JSON.stringify({ name: kebabName, title: displayTitle, description: description || undefined, folder: folder || undefined, grid, includeJsx }),
85
86
  })
86
87
  const data = await res.json()
87
88
  if (!res.ok) { error = data.error || 'Failed to create canvas'; return }
@@ -116,6 +117,11 @@
116
117
  <Input id="sb-canvas-title" placeholder={autoTitle || 'Auto-derived from name'} value={displayTitle} oninput={handleTitleInput} onblur={handleTitleBlur} />
117
118
  </div>
118
119
 
120
+ <div class="space-y-1">
121
+ <Label for="sb-canvas-description">Description <span class="text-muted-foreground font-normal">(optional)</span></Label>
122
+ <Input id="sb-canvas-description" placeholder="A brief description of this canvas" bind:value={description} />
123
+ </div>
124
+
119
125
  <div class="space-y-1">
120
126
  <Label for="sb-canvas-folder">Folder</Label>
121
127
  <select class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50" id="sb-canvas-folder" bind:value={folder} disabled={loading}>
@@ -104,7 +104,7 @@
104
104
  )
105
105
 
106
106
  function getApiUrl() {
107
- const basePath = document.querySelector('base')?.getAttribute('href') || '/'
107
+ const basePath = window.__STORYBOARD_BASE_PATH__ || '/'
108
108
  return basePath.replace(/\/$/, '') + '/_storyboard/workshop/flows'
109
109
  }
110
110
 
@@ -67,7 +67,7 @@
67
67
  )
68
68
 
69
69
  function getApiUrl() {
70
- const basePath = document.querySelector('base')?.getAttribute('href') || '/'
70
+ const basePath = window.__STORYBOARD_BASE_PATH__ || '/'
71
71
  return basePath.replace(/\/$/, '') + '/_storyboard/workshop/pages'
72
72
  }
73
73
 
@@ -88,7 +88,7 @@
88
88
  let templateMenuOpen = $state(false)
89
89
 
90
90
  function getApiUrl() {
91
- const basePath = document.querySelector('base')?.getAttribute('href') || '/'
91
+ const basePath = window.__STORYBOARD_BASE_PATH__ || '/'
92
92
  return basePath.replace(/\/$/, '') + '/_storyboard/workshop/prototypes'
93
93
  }
94
94
 
@@ -136,7 +136,7 @@
136
136
  // External prototype — no local route to navigate to, just close after a moment
137
137
  setTimeout(() => onClose?.(), 1500)
138
138
  } else {
139
- setTimeout(() => { const base = document.querySelector('base')?.href || '/'; window.location.href = base + data.route.slice(1) }, 1500)
139
+ setTimeout(() => { const base = (window.__STORYBOARD_BASE_PATH__ || '/').replace(/\/$/, ''); window.location.href = base + data.route }, 1500)
140
140
  }
141
141
  } catch (err: any) { error = err.message || 'Network error' } finally { submitting = false }
142
142
  }
@@ -0,0 +1,160 @@
1
+ <!--
2
+ CreateStoryForm — workshop form for creating a new .story.jsx/.tsx file.
3
+ Scaffolds a story file via the canvas server API.
4
+ -->
5
+
6
+ <script lang="ts">
7
+ import { onMount } from 'svelte'
8
+ import { Button } from '../../../lib/components/ui/button/index.js'
9
+ import { Input } from '../../../lib/components/ui/input/index.js'
10
+ import { Label } from '../../../lib/components/ui/label/index.js'
11
+ import * as Panel from '../../../lib/components/ui/panel/index.js'
12
+ import * as Alert from '../../../lib/components/ui/alert/index.js'
13
+
14
+ interface Props { onClose?: () => void }
15
+ let { onClose }: Props = $props()
16
+
17
+ let name = $state('')
18
+ let location = $state('canvas')
19
+ let format = $state('jsx')
20
+
21
+ let submitting = $state(false)
22
+ let error: string | null = $state(null)
23
+ let success: string | null = $state(null)
24
+ let createdPath: string | null = $state(null)
25
+
26
+ // Pre-fill canvasName from the active canvas (set by CanvasPage bridge)
27
+ let canvasName = $state('')
28
+
29
+ const kebabName = $derived(
30
+ name.replace(/[^a-zA-Z0-9\s_-]/g, '').trim().replace(/[\s_]+/g, '-').toLowerCase().replace(/-+/g, '-').replace(/^-|-$/g, '')
31
+ )
32
+
33
+ const nameError = $derived(
34
+ name.trim() && !kebabName ? 'Name must contain at least one alphanumeric character'
35
+ : name.trim() && !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(kebabName) ? 'Name must be kebab-case'
36
+ : null
37
+ )
38
+
39
+ const filePreview = $derived(
40
+ kebabName ? `${kebabName}.story.${format}` : ''
41
+ )
42
+
43
+ const canSubmit = $derived(!!kebabName && !nameError && !submitting)
44
+
45
+ const STORY_SUCCESS_KEY = 'sb-story-created'
46
+
47
+ function getApiUrl() {
48
+ const basePath = (window as any).__STORYBOARD_BASE_PATH__ || '/'
49
+ return basePath.replace(/\/$/, '') + '/_storyboard/canvas'
50
+ }
51
+
52
+ onMount(() => {
53
+ // Read active canvas name from the bridge state (window, not sessionStorage)
54
+ try {
55
+ const bridgeState = (window as any).__storyboardCanvasBridgeState
56
+ if (bridgeState?.name) canvasName = bridgeState.name
57
+ } catch {}
58
+
59
+ // Restore success state after Vite full-reload
60
+ try {
61
+ const saved = sessionStorage.getItem(STORY_SUCCESS_KEY)
62
+ if (saved) {
63
+ const parsed = JSON.parse(saved)
64
+ success = parsed.success
65
+ createdPath = parsed.path
66
+ sessionStorage.removeItem(STORY_SUCCESS_KEY)
67
+ }
68
+ } catch {}
69
+ })
70
+
71
+ async function submit() {
72
+ if (!canSubmit) return
73
+ submitting = true; error = null; success = null; createdPath = null
74
+ try {
75
+ const res = await fetch(getApiUrl() + '/create-story', {
76
+ method: 'POST',
77
+ headers: { 'Content-Type': 'application/json' },
78
+ body: JSON.stringify({
79
+ name: kebabName,
80
+ location,
81
+ format,
82
+ canvasName: location === 'canvas' ? canvasName : undefined,
83
+ }),
84
+ })
85
+ const data = await res.json()
86
+ if (!res.ok) { error = data.error || 'Failed to create story'; return }
87
+ success = `Created ${data.path}`
88
+ createdPath = data.path
89
+ // Persist for toast after Vite reload
90
+ try {
91
+ sessionStorage.setItem(STORY_SUCCESS_KEY, JSON.stringify({
92
+ success: `Created ${data.name}.story.${format}`,
93
+ path: data.path,
94
+ }))
95
+ } catch {}
96
+ } catch (err: any) { error = err.message || 'Network error' } finally { submitting = false }
97
+ }
98
+
99
+ function handleKeydown(e: KeyboardEvent) { if (e.key === 'Enter' && canSubmit) submit() }
100
+ </script>
101
+
102
+ <Panel.Header>
103
+ <Panel.Title>Create story</Panel.Title>
104
+ <Panel.Close />
105
+ </Panel.Header>
106
+
107
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
108
+ <div class="p-4 pt-2 space-y-3" onkeydown={handleKeydown}>
109
+ <div class="space-y-1">
110
+ <Label for="sb-story-name">Component name</Label>
111
+ <Input id="sb-story-name" placeholder="e.g. user-card" autocomplete="off" spellcheck="false" bind:value={name} />
112
+ {#if nameError}<p class="text-sm text-destructive">{nameError}</p>{/if}
113
+ {#if filePreview}<p class="text-xs text-muted-foreground">File: <code class="px-1 py-0.5 bg-muted rounded font-mono text-foreground text-xs">{filePreview}</code></p>{/if}
114
+ </div>
115
+
116
+ <fieldset class="space-y-1.5">
117
+ <Label>Location</Label>
118
+ <div class="flex flex-col gap-1.5">
119
+ <label class="flex items-center gap-2 text-sm cursor-pointer">
120
+ <input type="radio" name="sb-story-location" value="canvas" bind:group={location} class="accent-primary" />
121
+ This canvas directory
122
+ </label>
123
+ <label class="flex items-center gap-2 text-sm cursor-pointer">
124
+ <input type="radio" name="sb-story-location" value="components" bind:group={location} class="accent-primary" />
125
+ <code class="text-xs bg-muted px-1 py-0.5 rounded">src/components/</code>
126
+ </label>
127
+ </div>
128
+ </fieldset>
129
+
130
+ <fieldset class="space-y-1.5">
131
+ <Label>Format</Label>
132
+ <div class="flex gap-3">
133
+ <label class="flex items-center gap-2 text-sm cursor-pointer">
134
+ <input type="radio" name="sb-story-format" value="jsx" bind:group={format} class="accent-primary" />
135
+ JSX
136
+ </label>
137
+ <label class="flex items-center gap-2 text-sm cursor-pointer">
138
+ <input type="radio" name="sb-story-format" value="tsx" bind:group={format} class="accent-primary" />
139
+ TSX
140
+ </label>
141
+ </div>
142
+ </fieldset>
143
+
144
+ {#if error}<Alert.Root variant="destructive"><Alert.Description>{error}</Alert.Description></Alert.Root>{/if}
145
+ {#if success}
146
+ <Alert.Root>
147
+ <Alert.Description class="text-success">
148
+ {success}
149
+ {#if createdPath}
150
+ <br /><span class="text-xs text-muted-foreground">To edit your component, go to <code class="px-1 py-0.5 bg-muted rounded font-mono text-xs">{createdPath}</code></span>
151
+ {/if}
152
+ </Alert.Description>
153
+ </Alert.Root>
154
+ {/if}
155
+ </div>
156
+
157
+ <Panel.Footer>
158
+ <Button variant="outline" onclick={onClose}>Cancel</Button>
159
+ <Button onclick={submit} disabled={!canSubmit}>{submitting ? 'Creating\u2026' : 'Create'}</Button>
160
+ </Panel.Footer>
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Create Story feature — workshop form for creating .story.jsx/.tsx files.
3
+ *
4
+ * Server routes are handled by the canvas handler at /_storyboard/canvas/create-story.
5
+ * This feature provides the workshop UI overlay.
6
+ */
7
+
8
+ import CreateStoryForm from './CreateStoryForm.svelte'
9
+
10
+ export const name = 'createStory'
11
+ export const label = 'Create story'
12
+ export const icon = '⚡'
13
+ export const overlayId = 'createStory'
14
+ export const overlay = CreateStoryForm
@@ -14,6 +14,7 @@ import * as createPrototype from './createPrototype/index.js'
14
14
  import * as createFlow from './createFlow/index.js'
15
15
  import * as createPage from './createPage/index.js'
16
16
  import * as createCanvas from './createCanvas/index.js'
17
+ import * as createStory from './createStory/index.js'
17
18
 
18
19
  /**
19
20
  * All available workshop features, keyed by config name.
@@ -23,4 +24,5 @@ export const features = {
23
24
  createFlow,
24
25
  createPage,
25
26
  createCanvas,
27
+ createStory,
26
28
  }
@@ -11,7 +11,7 @@
11
11
  * import { getPort, detectWorktreeName, resolvePort } from '@dfosco/storyboard-core/worktree/port'
12
12
  */
13
13
 
14
- import { readFileSync, writeFileSync, existsSync, mkdirSync, realpathSync } from 'fs'
14
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, realpathSync } from 'fs'
15
15
  import { join, dirname, basename } from 'path'
16
16
  import { execSync } from 'child_process'
17
17
 
@@ -60,6 +60,10 @@ export function detectWorktreeName() {
60
60
  const worktreeMatch = realCwd.match(/\.worktrees[/\\]([^/\\]+)/)
61
61
  if (worktreeMatch) return worktreeMatch[1]
62
62
 
63
+ // Not a worktree — check the current branch name
64
+ const branch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8' }).trim()
65
+ if (branch && branch !== 'main' && branch !== 'master') return branch
66
+
63
67
  return 'main'
64
68
  } catch {
65
69
  return 'main'
@@ -138,3 +142,55 @@ export function slugify(name) {
138
142
  .map((s) => s.replace(/^-+|-+$/g, ''))
139
143
  .join('/')
140
144
  }
145
+
146
+ /**
147
+ * Resolve the repo root — the directory that contains `.worktrees/`.
148
+ *
149
+ * Works whether cwd is the repo root itself or inside `.worktrees/<name>/`.
150
+ *
151
+ * @param {string} [cwd]
152
+ * @returns {string} absolute path to repo root
153
+ */
154
+ export function repoRoot(cwd = process.cwd()) {
155
+ const realCwd = realpathSync(cwd)
156
+
157
+ const worktreeMatch = realCwd.match(/^(.+)[/\\]\.worktrees[/\\][^/\\]+/)
158
+ if (worktreeMatch) return worktreeMatch[1]
159
+
160
+ return realCwd
161
+ }
162
+
163
+ /**
164
+ * Resolve the full path to a worktree directory.
165
+ *
166
+ * Returns repo root for 'main', `.worktrees/<name>` otherwise.
167
+ *
168
+ * @param {string} name — worktree name
169
+ * @param {string} [cwd]
170
+ * @returns {string} absolute path
171
+ */
172
+ export function worktreeDir(name, cwd) {
173
+ const root = repoRoot(cwd)
174
+ if (name === 'main') return root
175
+ return join(root, '.worktrees', name)
176
+ }
177
+
178
+ /**
179
+ * List existing worktree directory names from `.worktrees/`.
180
+ *
181
+ * Only returns directories that look like real worktrees (contain a `.git` file).
182
+ * Does not include 'main'.
183
+ *
184
+ * @param {string} [cwd]
185
+ * @returns {string[]}
186
+ */
187
+ export function listWorktrees(cwd) {
188
+ const root = repoRoot(cwd)
189
+ const worktreesDir = join(root, '.worktrees')
190
+
191
+ if (!existsSync(worktreesDir)) return []
192
+
193
+ return readdirSync(worktreesDir, { withFileTypes: true })
194
+ .filter((d) => d.isDirectory() && existsSync(join(worktreesDir, d.name, '.git')))
195
+ .map((d) => d.name)
196
+ }
@@ -4,7 +4,7 @@ import { join } from 'path'
4
4
  import { tmpdir } from 'os'
5
5
 
6
6
  // We test the pure functions by importing and overriding cwd
7
- import { portsFilePath, getPort, resolvePort, slugify } from './port.js'
7
+ import { portsFilePath, getPort, resolvePort, slugify, repoRoot, worktreeDir, listWorktrees } from './port.js'
8
8
 
9
9
  describe('slugify', () => {
10
10
  it('lowercases and replaces dots with hyphens', () => {
@@ -130,3 +130,93 @@ describe('getPort / resolvePort', () => {
130
130
  expect(port).toBe(1235)
131
131
  })
132
132
  })
133
+
134
+ describe('repoRoot', () => {
135
+ let tempRoot
136
+
137
+ beforeEach(() => {
138
+ tempRoot = realpathSync(mkdtempSync(join(tmpdir(), 'sb-root-test-')))
139
+ mkdirSync(join(tempRoot, '.worktrees', 'my-branch'), { recursive: true })
140
+ })
141
+
142
+ afterEach(() => {
143
+ rmSync(tempRoot, { recursive: true, force: true })
144
+ })
145
+
146
+ it('returns cwd when at repo root', () => {
147
+ expect(repoRoot(tempRoot)).toBe(tempRoot)
148
+ })
149
+
150
+ it('returns parent of .worktrees when inside a worktree', () => {
151
+ const wt = join(tempRoot, '.worktrees', 'my-branch')
152
+ expect(repoRoot(wt)).toBe(tempRoot)
153
+ })
154
+ })
155
+
156
+ describe('worktreeDir', () => {
157
+ let tempRoot
158
+
159
+ beforeEach(() => {
160
+ tempRoot = realpathSync(mkdtempSync(join(tmpdir(), 'sb-wtdir-test-')))
161
+ mkdirSync(join(tempRoot, '.worktrees'), { recursive: true })
162
+ })
163
+
164
+ afterEach(() => {
165
+ rmSync(tempRoot, { recursive: true, force: true })
166
+ })
167
+
168
+ it('returns repo root for main', () => {
169
+ expect(worktreeDir('main', tempRoot)).toBe(tempRoot)
170
+ })
171
+
172
+ it('returns .worktrees/<name> for branches', () => {
173
+ expect(worktreeDir('my-feature', tempRoot)).toBe(join(tempRoot, '.worktrees', 'my-feature'))
174
+ })
175
+ })
176
+
177
+ describe('listWorktrees', () => {
178
+ let tempRoot
179
+
180
+ beforeEach(() => {
181
+ tempRoot = realpathSync(mkdtempSync(join(tmpdir(), 'sb-list-test-')))
182
+ })
183
+
184
+ afterEach(() => {
185
+ rmSync(tempRoot, { recursive: true, force: true })
186
+ })
187
+
188
+ it('returns empty array when .worktrees does not exist', () => {
189
+ expect(listWorktrees(tempRoot)).toEqual([])
190
+ })
191
+
192
+ it('returns only directories with a .git file', () => {
193
+ const wtDir = join(tempRoot, '.worktrees')
194
+ mkdirSync(wtDir)
195
+
196
+ // Valid worktree — has .git file
197
+ mkdirSync(join(wtDir, 'valid'))
198
+ writeFileSync(join(wtDir, 'valid', '.git'), 'gitdir: /some/path')
199
+
200
+ // Not a worktree — no .git file
201
+ mkdirSync(join(wtDir, 'no-git'))
202
+
203
+ // Not a directory — file
204
+ writeFileSync(join(wtDir, 'ports.json'), '{}')
205
+
206
+ const result = listWorktrees(tempRoot)
207
+ expect(result).toEqual(['valid'])
208
+ })
209
+
210
+ it('returns multiple worktrees', () => {
211
+ const wtDir = join(tempRoot, '.worktrees')
212
+ mkdirSync(wtDir)
213
+
214
+ for (const name of ['alpha', 'beta', 'gamma']) {
215
+ mkdirSync(join(wtDir, name))
216
+ writeFileSync(join(wtDir, name, '.git'), 'gitdir: /some/path')
217
+ }
218
+
219
+ const result = listWorktrees(tempRoot)
220
+ expect(result.sort()).toEqual(['alpha', 'beta', 'gamma'])
221
+ })
222
+ })
@@ -63,7 +63,7 @@
63
63
  "handler": "core:inspector",
64
64
  "modes": ["*"],
65
65
  "prod": true,
66
- "excludeRoutes": ["^/$", "/viewfinder", "/canvas/"],
66
+ "excludeRoutes": ["^/$", "/viewfinder", "/canvas/", "/components/"],
67
67
  "meta": { "strokeWeight": 2, "scale": 1.1 },
68
68
  "shortcut": { "key": "i", "label": "⌘I" }
69
69
  },
@@ -80,8 +80,8 @@
80
80
  "actions": [
81
81
  { "type": "header", "label": "Create" },
82
82
  { "id": "workshop/create-prototype", "label": "New prototype", "type": "default", "modes": ["*"], "feature": "createPrototype" },
83
- { "id": "workshop/create-flow", "label": "New flow", "type": "default", "modes": ["*"], "feature": "createFlow" },
84
- { "id": "workshop/create-page", "label": "New page", "type": "default", "modes": ["*"], "feature": "createPage" },
83
+ { "id": "workshop/create-flow", "label": "New flow", "type": "default", "modes": ["*"], "feature": "createFlow", "excludeRoutes": ["/canvas/", "/components/"] },
84
+ { "id": "workshop/create-page", "label": "New page", "type": "default", "modes": ["*"], "feature": "createPage", "excludeRoutes": ["/canvas/", "/components/"] },
85
85
  { "id": "workshop/create-canvas", "label": "New canvas", "type": "default", "modes": ["*"], "feature": "createCanvas" },
86
86
  { "type": "footer", "label": "Only available in dev environment" }
87
87
  ]