@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.
- package/dist/storyboard-ui.css +1 -1
- package/dist/storyboard-ui.js +11882 -11126
- package/dist/storyboard-ui.js.map +1 -1
- package/dist/tailwind.css +1 -1
- package/package.json +11 -3
- package/paste.config.json +54 -0
- package/scaffold/deploy.yml +101 -0
- package/scaffold/githooks/pre-push +114 -0
- package/scaffold/manifest.json +11 -0
- package/scaffold/storyboard.config.json +4 -1
- package/src/ActionMenuButton.svelte +12 -2
- package/src/CanvasCreateMenu.svelte +228 -10
- package/src/CanvasSnap.svelte +2 -0
- package/src/CoreUIBar.svelte +152 -3
- package/src/CreateMenuButton.svelte +4 -1
- package/src/InspectorPanel.svelte +2 -0
- package/src/PwaInstallBanner.svelte +124 -0
- package/src/autosync/server.js +99 -111
- package/src/autosync/server.test.js +0 -7
- package/src/canvas/collision.js +206 -0
- package/src/canvas/collision.test.js +271 -0
- package/src/canvas/deriveCanvasId.test.js +40 -0
- package/src/canvas/identity.js +107 -0
- package/src/canvas/identity.test.js +100 -0
- package/src/canvas/server.js +285 -31
- package/src/canvasConfig.js +56 -0
- package/src/canvasConfig.test.js +42 -0
- package/src/cli/canvasAdd.js +185 -0
- package/src/cli/canvasRead.js +208 -0
- package/src/cli/code.js +67 -0
- package/src/cli/create.js +339 -72
- package/src/cli/dev-helpers.js +53 -0
- package/src/cli/dev-helpers.test.js +53 -0
- package/src/cli/dev.js +245 -26
- package/src/cli/flags.js +174 -0
- package/src/cli/flags.test.js +155 -0
- package/src/cli/index.js +84 -13
- package/src/cli/intro.js +37 -0
- package/src/cli/proxy.js +127 -6
- package/src/cli/proxy.test.js +63 -0
- package/src/cli/schemas.js +200 -0
- package/src/cli/serverUrl.js +56 -0
- package/src/cli/setup.js +130 -20
- package/src/cli/snapshots.js +335 -0
- package/src/cli/updateVersion.js +54 -3
- package/src/configSchema.js +125 -0
- package/src/configSchema.test.js +68 -0
- package/src/index.js +5 -0
- package/src/inspector/highlighter.js +10 -2
- package/src/lib/components/ui/trigger-button/trigger-button.svelte +1 -1
- package/src/loader.js +21 -2
- package/src/loader.test.js +63 -1
- package/src/mobileViewport.js +57 -0
- package/src/mobileViewport.test.js +68 -0
- package/src/mountStoryboardCore.js +61 -7
- package/src/rename-watcher/config.json +23 -0
- package/src/rename-watcher/watcher.js +538 -0
- package/src/svelte-plugin-ui/components/Viewfinder.svelte +6 -17
- package/src/tools/handlers/flows.js +6 -7
- package/src/viewfinder.js +21 -9
- package/src/viewfinder.test.js +2 -2
- package/src/vite/server-plugin.js +150 -7
- package/src/workshop/features/createCanvas/CreateCanvasForm.svelte +8 -2
- package/src/workshop/features/createFlow/CreateFlowForm.svelte +1 -1
- package/src/workshop/features/createPage/CreatePageForm.svelte +1 -1
- package/src/workshop/features/createPrototype/CreatePrototypeForm.svelte +2 -2
- package/src/workshop/features/createStory/CreateStoryForm.svelte +160 -0
- package/src/workshop/features/createStory/index.js +14 -0
- package/src/workshop/features/registry.js +2 -0
- package/src/worktree/port.js +57 -1
- package/src/worktree/port.test.js +91 -1
- package/toolbar.config.json +3 -3
- 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
|
|
113
|
-
|
|
114
|
-
server.watcher.unwatch(
|
|
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 =
|
|
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 =
|
|
107
|
+
const basePath = window.__STORYBOARD_BASE_PATH__ || '/'
|
|
108
108
|
return basePath.replace(/\/$/, '') + '/_storyboard/workshop/flows'
|
|
109
109
|
}
|
|
110
110
|
|
|
@@ -88,7 +88,7 @@
|
|
|
88
88
|
let templateMenuOpen = $state(false)
|
|
89
89
|
|
|
90
90
|
function getApiUrl() {
|
|
91
|
-
const basePath =
|
|
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 =
|
|
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
|
}
|
package/src/worktree/port.js
CHANGED
|
@@ -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
|
+
})
|
package/toolbar.config.json
CHANGED
|
@@ -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
|
]
|