@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
package/src/cli/setup.js CHANGED
@@ -6,16 +6,28 @@
6
6
 
7
7
  import * as p from '@clack/prompts'
8
8
  import { existsSync } from 'fs'
9
+ import path from 'path'
9
10
  import { execSync } from 'child_process'
10
11
  import { generateCaddyfile, isCaddyInstalled, isCaddyRunning, startCaddy, reloadCaddy } from './proxy.js'
12
+ import { gettingStartedLines, dim, magenta, bold, green, yellow } from './intro.js'
11
13
 
12
- // ANSI colors
13
- const dim = (s) => `\x1b[2m${s}\x1b[0m`
14
- const magenta = (s) => `\x1b[35m${s}\x1b[0m`
15
- const cyan = (s) => `\x1b[36m${s}\x1b[0m`
16
- const green = (s) => `\x1b[32m${s}\x1b[0m`
17
- const bold = (s) => `\x1b[1m${s}\x1b[0m`
18
- const white = (s) => `\x1b[97m${s}\x1b[0m`
14
+ /**
15
+ * Run a potentially slow task with a spinner that only appears after 500ms.
16
+ * If the task completes quickly, shows the done message immediately.
17
+ */
18
+ async function withSpin(label, doneMsg, fn) {
19
+ const spin = p.spinner()
20
+ const timer = setTimeout(() => spin.start(label), 500)
21
+ try {
22
+ await fn()
23
+ clearTimeout(timer)
24
+ spin.stop(doneMsg)
25
+ } catch (err) {
26
+ clearTimeout(timer)
27
+ spin.stop(`Failed: ${label}`)
28
+ throw err
29
+ }
30
+ }
19
31
 
20
32
  function mascot() {
21
33
  const d = dim('·')
@@ -114,7 +126,115 @@ if (hasBrew) {
114
126
  }
115
127
  }
116
128
 
117
- // 5. Proxy
129
+ // 5. VS Code CLI
130
+ if (isInstalled('code')) {
131
+ p.log.success('VS Code CLI installed')
132
+ } else {
133
+ // Try to install the `code` CLI from VS Code's known locations
134
+ const codePaths = [
135
+ '/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code',
136
+ '/usr/local/bin/code',
137
+ ]
138
+ let installed = false
139
+ for (const codePath of codePaths) {
140
+ if (existsSync(codePath)) {
141
+ p.log.success('VS Code CLI available (symlink exists)')
142
+ installed = true
143
+ break
144
+ }
145
+ }
146
+ if (!installed) {
147
+ // Try the VS Code shell command installer
148
+ const vsCodeApp = '/Applications/Visual Studio Code.app'
149
+ if (existsSync(vsCodeApp)) {
150
+ const shellScript = `${vsCodeApp}/Contents/Resources/app/bin/code`
151
+ if (existsSync(shellScript)) {
152
+ try {
153
+ // Create symlink in /usr/local/bin
154
+ run(`ln -sf "${shellScript}" /usr/local/bin/code`)
155
+ p.log.success('VS Code CLI installed (symlinked to /usr/local/bin/code)')
156
+ installed = true
157
+ } catch {
158
+ // Fall through to manual instructions
159
+ }
160
+ }
161
+ }
162
+ if (!installed) {
163
+ p.log.warning('VS Code CLI not found. Open VS Code and run:')
164
+ p.log.info(' Cmd+Shift+P → "Shell Command: Install \'code\' command in PATH"')
165
+ }
166
+ }
167
+ }
168
+
169
+ // 6. Git hooks
170
+ {
171
+ // Create .githooks/ if it doesn't exist — copy from scaffold template
172
+ if (!existsSync('.githooks')) {
173
+ const scaffoldHooks = path.resolve(import.meta.dirname, '..', '..', 'scaffold', 'githooks')
174
+ if (existsSync(scaffoldHooks)) {
175
+ try {
176
+ run(`cp -r "${scaffoldHooks}" .githooks`)
177
+ run('chmod +x .githooks/*')
178
+ } catch { /* fall through */ }
179
+ }
180
+ }
181
+
182
+ if (existsSync('.githooks')) {
183
+ try {
184
+ run('git config core.hooksPath .githooks')
185
+ if (existsSync('.githooks/pre-push')) {
186
+ run('chmod +x .githooks/pre-push')
187
+ }
188
+ p.log.success('Git hooks activated (.githooks/)')
189
+ } catch {
190
+ p.log.warning('Failed to set git hooks path')
191
+ }
192
+ } else {
193
+ p.log.info(dim('No .githooks/ directory — run storyboard-scaffold first'))
194
+ }
195
+ }
196
+
197
+ // 7. Asset directories
198
+ {
199
+ const dirs = ['assets/canvas/images', 'assets/canvas/snapshots']
200
+ for (const dir of dirs) {
201
+ if (!existsSync(dir)) {
202
+ try {
203
+ execSync(`mkdir -p ${dir}`, { stdio: 'pipe' })
204
+ } catch { /* ignore */ }
205
+ }
206
+ }
207
+ p.log.success('Canvas asset directories ready')
208
+ }
209
+
210
+ // 8. Playwright (for canvas snapshots)
211
+ {
212
+ let hasPlaywright = false
213
+ try {
214
+ run('node -e "require(\'playwright\')"')
215
+ hasPlaywright = true
216
+ } catch { /* not installed */ }
217
+
218
+ if (hasPlaywright) {
219
+ p.log.success('Playwright installed')
220
+ } else {
221
+ try {
222
+ await withSpin(
223
+ 'Installing Playwright + Chromium...',
224
+ 'Playwright installed',
225
+ () => {
226
+ run('npm install --save-dev playwright')
227
+ run('npx playwright install chromium')
228
+ }
229
+ )
230
+ } catch {
231
+ p.log.warning('Install manually: npm install --save-dev playwright && npx playwright install chromium')
232
+ p.log.info(dim('Playwright is needed for `storyboard snapshots`'))
233
+ }
234
+ }
235
+ }
236
+
237
+ // 9. Proxy
118
238
  if (isCaddyInstalled()) {
119
239
  const proxySpin = p.spinner()
120
240
  const caddyfilePath = generateCaddyfile()
@@ -131,19 +251,9 @@ if (isCaddyInstalled()) {
131
251
 
132
252
  p.note(
133
253
  [
134
- white(`Welcome! Storyboard is a design tool to build and`),
135
- white(`collaborate on prototypes. Here's how to get started:`),
136
- '',
137
- ` ${green('npx storyboard dev')} Start developing locally`,
138
- ` ${green('npx storyboard create prototype')} Create a prototype`,
139
- ` ${green('npx storyboard create canvas')} Create a canvas`,
140
- '',
141
- ` ${dim('Using an AI assistant? You can also ask it to')}`,
142
- ` ${dim('"create a prototype" or "create a canvas" for you!')}`,
143
- '',
144
- ` ${dim('Docs:')} ${cyan('https://github.com/dfosco/storyboard/blob/main/README.md')}`,
254
+ ...gettingStartedLines(),
145
255
  '',
146
- ` ${dim('PS: You can also use')} ${green('npx sb ...')} ${dim('for shorter commands')}`,
256
+ ` ${dim('Run')} ${yellow('npx storyboard')} ${dim('to see all commands')}`,
147
257
  ].join('\n'),
148
258
  'Getting started'
149
259
  )
@@ -0,0 +1,335 @@
1
+ /**
2
+ * storyboard snapshots — batch-generate preview snapshots for canvas widgets.
3
+ *
4
+ * Fully standalone: reads JSONL from disk, spins up a temporary Vite server
5
+ * on an ephemeral port, uses Playwright to capture each embed at the widget's
6
+ * dimensions (both light and dark themes), writes images to src/canvas/images/,
7
+ * and appends a widgets_replaced event to the JSONL.
8
+ *
9
+ * Does NOT touch the user's running dev server or browser.
10
+ *
11
+ * Requires: playwright installed (globally or locally).
12
+ *
13
+ * Usage:
14
+ * storyboard snapshots # all canvases
15
+ * storyboard snapshots <name> # specific canvas
16
+ * storyboard snapshots --force # regenerate even if snapshots exist
17
+ */
18
+
19
+ import fs from 'node:fs'
20
+ import path from 'node:path'
21
+ import { materializeFromText, serializeEvent } from '../canvas/materializer.js'
22
+ import * as p from '@clack/prompts'
23
+ import { bold, dim, green, yellow, cyan } from './intro.js'
24
+
25
+ const THEMES = ['light', 'dark']
26
+ const SNAPSHOT_PORT = 19876
27
+
28
+ // ── Disk I/O helpers ──
29
+
30
+ function findCanvasFiles(root) {
31
+ const results = []
32
+ const ignore = new Set(['node_modules', 'dist', '.git', '.worktrees'])
33
+ function walk(dir, rel = '') {
34
+ const entries = fs.readdirSync(dir, { withFileTypes: true })
35
+ for (const entry of entries) {
36
+ if (ignore.has(entry.name)) continue
37
+ const fullPath = path.join(dir, entry.name)
38
+ const relPath = rel ? `${rel}/${entry.name}` : entry.name
39
+ if (entry.isDirectory()) {
40
+ walk(fullPath, relPath)
41
+ } else if (entry.name.endsWith('.canvas.jsonl')) {
42
+ results.push({ relPath, absPath: fullPath })
43
+ }
44
+ }
45
+ }
46
+ walk(root)
47
+ return results
48
+ }
49
+
50
+ function toCanvasId(relPath) {
51
+ return relPath
52
+ .replace(/\.canvas\.jsonl$/, '')
53
+ .replace(/\\/g, '/')
54
+ }
55
+
56
+ function readCanvasState(absPath) {
57
+ const raw = fs.readFileSync(absPath, 'utf-8')
58
+ return materializeFromText(raw)
59
+ }
60
+
61
+ function writeImage(imagesDir, widgetId, theme, buffer) {
62
+ fs.mkdirSync(imagesDir, { recursive: true })
63
+ const now = new Date()
64
+ const pad = (n) => String(n).padStart(2, '0')
65
+ const dateStr = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}--${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`
66
+ const filename = `snapshot-${widgetId}--${theme}--${dateStr}.png`
67
+ fs.writeFileSync(path.join(imagesDir, filename), buffer)
68
+ return filename
69
+ }
70
+
71
+ function appendWidgetsReplaced(jsonlPath, widgets) {
72
+ const event = {
73
+ event: 'widgets_replaced',
74
+ timestamp: new Date().toISOString(),
75
+ widgets,
76
+ }
77
+ fs.appendFileSync(jsonlPath, serializeEvent(event) + '\n', 'utf-8')
78
+ }
79
+
80
+ // ── Main ──
81
+
82
+ async function run() {
83
+ const args = process.argv.slice(3)
84
+ const force = args.includes('--force')
85
+ const canvasFilter = args.find(a => !a.startsWith('--')) || null
86
+
87
+ p.intro(bold('storyboard snapshots'))
88
+
89
+ const root = process.cwd()
90
+ const imagesDir = path.join(root, 'assets', 'canvas', 'snapshots')
91
+
92
+ // Discover canvases from disk
93
+ const allFiles = findCanvasFiles(root)
94
+ const targets = canvasFilter
95
+ ? allFiles.filter(f => {
96
+ const id = toCanvasId(f.relPath)
97
+ return id === canvasFilter || id.includes(canvasFilter)
98
+ })
99
+ : allFiles
100
+
101
+ if (targets.length === 0) {
102
+ p.log.warn(canvasFilter
103
+ ? `No canvases matching "${canvasFilter}"`
104
+ : 'No canvases found')
105
+ process.exit(0)
106
+ }
107
+
108
+ p.log.info(`Found ${bold(targets.length)} canvas${targets.length > 1 ? 'es' : ''}`)
109
+
110
+ // Import playwright
111
+ let chromium
112
+ try {
113
+ const pw = await import('playwright')
114
+ chromium = pw.chromium
115
+ } catch {
116
+ p.log.error('Playwright is required for snapshot generation.')
117
+ p.log.info('Install: ' + yellow('npx storyboard setup'))
118
+ p.log.info('Or manually: ' + yellow('npm install --save-dev playwright && npx playwright install chromium'))
119
+ process.exit(1)
120
+ }
121
+
122
+ // Start a temporary Vite server on an ephemeral port
123
+ const serverSpin = p.spinner()
124
+ serverSpin.start('Starting temporary Vite server')
125
+ let server
126
+ try {
127
+ const { createServer } = await import('vite')
128
+ server = await createServer({
129
+ root,
130
+ server: { port: SNAPSHOT_PORT, strictPort: false },
131
+ logLevel: 'silent',
132
+ })
133
+ await server.listen()
134
+ const address = server.httpServer.address()
135
+ const serverUrl = `http://localhost:${address.port}`
136
+ serverSpin.stop(`Vite server ready at ${dim(serverUrl)}`)
137
+
138
+ const browser = await chromium.launch({ headless: true })
139
+
140
+ let totalWidgets = 0
141
+ let totalSnapshots = 0
142
+ let totalSkipped = 0
143
+
144
+ for (const { relPath, absPath } of targets) {
145
+ const canvasId = toCanvasId(relPath)
146
+ const spin = p.spinner()
147
+ spin.start(`Reading ${cyan(canvasId)}`)
148
+
149
+ let state
150
+ try {
151
+ state = readCanvasState(absPath)
152
+ } catch (err) {
153
+ spin.stop(`${canvasId}: failed to read — ${err.message}`)
154
+ continue
155
+ }
156
+
157
+ // Collect embeddable widgets (prototype + story + figma-embed)
158
+ const widgets = (state.widgets || []).filter(w =>
159
+ w.type === 'prototype' || w.type === 'story' || w.type === 'figma-embed'
160
+ )
161
+
162
+ if (widgets.length === 0) {
163
+ spin.stop(`${canvasId}: no embeddable widgets`)
164
+ continue
165
+ }
166
+
167
+ spin.stop(`${canvasId}: ${widgets.length} widget${widgets.length > 1 ? 's' : ''}`)
168
+ totalWidgets += widgets.length
169
+ let canvasDirty = false
170
+
171
+ for (const widget of widgets) {
172
+ const widgetLabel = widget.props?.label || widget.props?.exportName || widget.id
173
+ const rawW = widget.props?.width || 800
174
+ const rawH = widget.props?.height || 600
175
+ const isFigma = widget.type === 'figma-embed'
176
+
177
+ // Compute capture dimensions:
178
+ // - Story widgets have a 31px header above iframe content
179
+ // - Prototype widgets may have a zoom factor
180
+ // - Figma embeds use raw dimensions
181
+ const isStory = widget.type === 'story'
182
+ const zoom = widget.props?.zoom || 100
183
+ const scale = zoom / 100
184
+ const captureW = isStory ? rawW : isFigma ? rawW : Math.round(rawW / scale)
185
+ const captureH = isStory ? Math.max(rawH - 31, 100) : isFigma ? rawH : Math.round(rawH / scale)
186
+
187
+ // Figma embeds only need a single snapshot (no theme variants)
188
+ const themesNeeded = isFigma ? ['light'] : THEMES
189
+
190
+ // Check existing snapshots
191
+ const hasLight = !!widget.props?.snapshotLight
192
+ const hasDark = !!widget.props?.snapshotDark
193
+ const allExist = isFigma ? hasLight : (hasLight && hasDark)
194
+ if (allExist && !force) {
195
+ totalSkipped++
196
+ p.log.step(dim(` ${widgetLabel} — snapshots exist, skipping`))
197
+ continue
198
+ }
199
+
200
+ const themesToCapture = force
201
+ ? themesNeeded
202
+ : themesNeeded.filter(t => t === 'light' ? !hasLight : !hasDark)
203
+
204
+ const embedUrl = resolveEmbedUrl(serverUrl, widget)
205
+ if (!embedUrl) {
206
+ p.log.warn(` ${widgetLabel} — could not resolve embed URL, skipping`)
207
+ continue
208
+ }
209
+
210
+ const updates = {}
211
+
212
+ for (const theme of themesToCapture) {
213
+ const themeUrl = isFigma ? embedUrl : appendThemeParam(embedUrl, theme)
214
+ const wspin = p.spinner()
215
+ wspin.start(` ${widgetLabel}${isFigma ? '' : ` (${theme})`} ${dim(`${captureW}×${captureH}`)}`)
216
+
217
+ try {
218
+ const context = await browser.newContext({
219
+ viewport: { width: captureW, height: captureH },
220
+ deviceScaleFactor: 2,
221
+ colorScheme: theme === 'dark' ? 'dark' : 'light',
222
+ })
223
+ const page = await context.newPage()
224
+
225
+ await page.goto(themeUrl, { waitUntil: 'networkidle', timeout: 30000 })
226
+ await page.waitForTimeout(isFigma ? 4000 : 2000)
227
+
228
+ const buffer = await page.screenshot({ type: 'png' })
229
+ await context.close()
230
+
231
+ const filename = writeImage(imagesDir, widget.id, theme, buffer)
232
+ const imageUrl = `/_storyboard/canvas/images/${filename}`
233
+ const themeKey = theme === 'dark' ? 'snapshotDark' : 'snapshotLight'
234
+ updates[themeKey] = imageUrl
235
+ totalSnapshots++
236
+ wspin.stop(green(` ${widgetLabel} (${theme}) ✓`))
237
+ } catch (err) {
238
+ wspin.stop(` ${widgetLabel} (${theme}) — ${err.message}`)
239
+ }
240
+ }
241
+
242
+ if (Object.keys(updates).length > 0) {
243
+ widget.props = { ...widget.props, ...updates }
244
+ canvasDirty = true
245
+ }
246
+ }
247
+
248
+ // Persist all snapshot updates in a single widgets_replaced event
249
+ if (canvasDirty) {
250
+ try {
251
+ appendWidgetsReplaced(absPath, state.widgets)
252
+ } catch (err) {
253
+ p.log.warn(` ${canvasId}: failed to write JSONL — ${err.message}`)
254
+ }
255
+ }
256
+ }
257
+
258
+ await browser.close()
259
+
260
+ // Auto-commit snapshot files so they don't clutter git status
261
+ if (totalSnapshots > 0) {
262
+ try {
263
+ const { execSync } = await import(/* @vite-ignore */ 'node:child_process')
264
+ execSync('git add assets/canvas/snapshots/ src/canvas/', { cwd: root, stdio: 'pipe' })
265
+ execSync(
266
+ `git commit -m "chore: update canvas snapshots" --no-verify --allow-empty`,
267
+ { cwd: root, stdio: 'pipe' }
268
+ )
269
+ p.log.step(dim('Snapshots committed'))
270
+ } catch {
271
+ // Not in a git repo or nothing to commit — that's fine
272
+ }
273
+ }
274
+
275
+ p.outro([
276
+ green('Done!'),
277
+ `${bold(totalSnapshots)} snapshot${totalSnapshots !== 1 ? 's' : ''} generated`,
278
+ totalSkipped > 0 ? dim(`${totalSkipped} skipped (already exist)`) : '',
279
+ `across ${bold(totalWidgets)} widget${totalWidgets !== 1 ? 's' : ''}`,
280
+ ].filter(Boolean).join(' · '))
281
+ } finally {
282
+ if (server) await server.close()
283
+ }
284
+ }
285
+
286
+ // ── URL helpers ──
287
+
288
+ function resolveEmbedUrl(serverUrl, widget) {
289
+ const { type, props } = widget
290
+ if (type === 'prototype') {
291
+ const src = props?.src
292
+ if (!src) return null
293
+ if (/^https?:\/\//.test(src)) return null
294
+ const cleaned = src.replace(/^\/branch--[^/]+/, '')
295
+ const hashIdx = cleaned.indexOf('#')
296
+ const base = hashIdx >= 0 ? cleaned.slice(0, hashIdx) : cleaned
297
+ const hash = hashIdx >= 0 ? cleaned.slice(hashIdx) : ''
298
+ const sep = base.includes('?') ? '&' : '?'
299
+ return `${serverUrl}${base}${sep}_sb_embed${hash}`
300
+ }
301
+ if (type === 'story') {
302
+ const storyId = props?.storyId
303
+ const exportName = props?.exportName || ''
304
+ if (!storyId) return null
305
+ const params = new URLSearchParams({ _sb_embed: '1' })
306
+ if (exportName) params.set('export', exportName)
307
+ return `${serverUrl}/components/${storyId}?${params}`
308
+ }
309
+ if (type === 'figma-embed') {
310
+ const url = props?.url
311
+ if (!url) return null
312
+ // Convert figma.com URL to embed.figma.com URL
313
+ try {
314
+ const parsed = new URL(url)
315
+ if (!/^(www\.)?figma\.com$/.test(parsed.hostname)) return null
316
+ parsed.hostname = 'embed.figma.com'
317
+ parsed.searchParams.delete('t')
318
+ parsed.searchParams.set('embed-host', 'share')
319
+ return parsed.toString()
320
+ } catch {
321
+ return null
322
+ }
323
+ }
324
+ return null
325
+ }
326
+
327
+ function appendThemeParam(url, theme) {
328
+ const sep = url.includes('?') ? '&' : '?'
329
+ return `${url}${sep}_sb_theme_target=prototype&_sb_canvas_theme=${theme}`
330
+ }
331
+
332
+ run().catch((err) => {
333
+ p.log.error(err.message)
334
+ process.exit(1)
335
+ })
@@ -56,18 +56,69 @@ const tag = channel || (targetVersion ? undefined : 'latest')
56
56
  const suffix = targetVersion ? `@${targetVersion}` : `@${tag}`
57
57
  const packages = [...storyboardPkgs].map(name => `${name}${suffix}`).join(' ')
58
58
 
59
- const label = channel ? `to ${channel}` : targetVersion ? `to ${targetVersion}` : ''
59
+ // Resolve actual version from the registry when using a tag (channel or latest)
60
+ let resolvedVersion
61
+ if (tag) {
62
+ try {
63
+ const probe = [...storyboardPkgs][0]
64
+ resolvedVersion = execSync(`npm view ${probe}@${tag} version`, { encoding: 'utf8' }).trim()
65
+ } catch { /* fall back to showing the tag */ }
66
+ }
67
+
68
+ const displayVersion = resolvedVersion || targetVersion || tag
69
+ const label = resolvedVersion && channel ? `to ${resolvedVersion} (${channel})` : resolvedVersion ? `to ${resolvedVersion}` : channel ? `to ${channel}` : targetVersion ? `to ${targetVersion}` : ''
60
70
  p.intro(`storyboard ${command}`)
61
71
  p.log.info(`Updating ${storyboardPkgs.size} package(s)${label ? ` ${label}` : ''}…`)
62
72
  for (const name of storyboardPkgs) {
63
- p.log.message(` ${name}${suffix}`)
73
+ p.log.message(` ${name}@${displayVersion}`)
64
74
  }
65
75
 
66
76
  try {
67
77
  execSync(`npm install ${packages}`, { stdio: 'inherit', cwd: process.cwd() })
68
78
  p.log.success('All storyboard packages updated')
69
- p.outro('Done')
70
79
  } catch {
71
80
  p.log.error('Failed to update packages — see npm output above')
72
81
  process.exit(1)
73
82
  }
83
+
84
+ // Sync scaffold files (skills, scripts) from the updated package
85
+ p.log.info('Syncing scaffold files…')
86
+ try {
87
+ execSync('npx storyboard-scaffold', { stdio: 'inherit', cwd: process.cwd() })
88
+ } catch {
89
+ p.log.warn('Scaffold sync failed — run `npx storyboard-scaffold` manually')
90
+ }
91
+
92
+ // Auto-commit the version update (only if package.json or lock file changed)
93
+ try {
94
+ // Read the installed version from the core package
95
+ const corePkg = JSON.parse(readFileSync(resolve(process.cwd(), 'node_modules', '@dfosco', 'storyboard-core', 'package.json'), 'utf8'))
96
+ const installedVersion = corePkg.version || suffix.slice(1)
97
+ const commitMsg = `[storyboard-update] Update storyboard to ${installedVersion}`
98
+
99
+ // Only stage update-related files (package.json, lock files, scaffold outputs)
100
+ const filesToStage = [
101
+ 'package.json',
102
+ 'package-lock.json',
103
+ 'yarn.lock',
104
+ 'pnpm-lock.yaml',
105
+ '.github/skills',
106
+ 'scripts',
107
+ ]
108
+ for (const f of filesToStage) {
109
+ try { execSync(`git add ${f}`, { cwd: process.cwd(), stdio: 'pipe' }) } catch {}
110
+ }
111
+
112
+ // Only commit if there are staged changes
113
+ try {
114
+ execSync('git diff --cached --quiet', { cwd: process.cwd(), stdio: 'pipe' })
115
+ p.log.message('No changes to commit — already up to date')
116
+ } catch {
117
+ execSync(`git commit -m "${commitMsg}"`, { cwd: process.cwd(), stdio: 'pipe' })
118
+ p.log.success(`Committed: ${commitMsg}`)
119
+ }
120
+ } catch (err) {
121
+ p.log.warn(`Auto-commit failed: ${err.message}`)
122
+ }
123
+
124
+ p.outro('Done')