@dfosco/storyboard 0.6.0-beta.2 → 0.6.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 (49) hide show
  1. package/dist/storyboard-ui.js +3112 -3098
  2. package/dist/storyboard-ui.js.map +1 -1
  3. package/mascot/frame-01-peek-left.txt +4 -0
  4. package/mascot/frame-02-eyes-open.txt +4 -0
  5. package/mascot/frame-03-peek-right.txt +4 -0
  6. package/mascot/frame-04-eyes-open.txt +4 -0
  7. package/mascot/frame-05-eyes-closed.txt +4 -0
  8. package/mascot/frame-06-eyes-open.txt +4 -0
  9. package/mascot.config.json +13 -0
  10. package/package.json +5 -2
  11. package/scaffold/AGENTS.md +1 -0
  12. package/scaffold/gitignore +12 -2
  13. package/scaffold/skills/design-system-catalog/SKILL.md +98 -0
  14. package/scaffold/skills/design-system-catalog/extract-components.mjs +441 -0
  15. package/scaffold/skills/design-system-catalog/generate-catalog.sh +255 -0
  16. package/scaffold/skills/migrate/SKILL.md +72 -50
  17. package/scaffold/terminal-agent.agent.md +8 -1
  18. package/src/core/canvas/agent-session.js +103 -17
  19. package/src/core/canvas/agent-session.test.js +29 -1
  20. package/src/core/canvas/collision.js +54 -45
  21. package/src/core/canvas/collision.test.js +39 -0
  22. package/src/core/canvas/configReader.js +110 -0
  23. package/src/core/canvas/hot-pool.js +5 -3
  24. package/src/core/canvas/server.js +32 -13
  25. package/src/core/canvas/terminal-server.js +156 -91
  26. package/src/core/cli/agent.js +86 -33
  27. package/src/core/cli/dev.js +303 -17
  28. package/src/core/cli/server.js +1 -1
  29. package/src/core/cli/setup.js +203 -60
  30. package/src/core/cli/terminal-welcome.js +5 -6
  31. package/src/core/cli/userState.js +63 -0
  32. package/src/core/stores/configSchema.js +1 -0
  33. package/src/core/stores/themeStore.ts +24 -0
  34. package/src/core/tools/handlers/devtools.test.js +1 -1
  35. package/src/core/vite/server-plugin.js +107 -10
  36. package/src/internals/CommandPalette/CommandPalette.jsx +1 -1
  37. package/src/internals/Viewfinder.jsx +10 -2
  38. package/src/internals/canvas/CanvasPage.jsx +30 -9
  39. package/src/internals/canvas/WebGLContextPool.jsx +6 -7
  40. package/src/internals/canvas/componentIsolate.jsx +7 -8
  41. package/src/internals/canvas/componentSetIsolate.jsx +7 -8
  42. package/src/internals/canvas/widgets/PrototypeEmbed.jsx +3 -1
  43. package/src/internals/canvas/widgets/StorySetWidget.jsx +19 -7
  44. package/src/internals/canvas/widgets/StoryWidget.jsx +9 -3
  45. package/src/internals/canvas/widgets/TerminalWidget.jsx +74 -13
  46. package/src/internals/canvas/widgets/expandUtils.js +4 -2
  47. package/src/internals/hooks/usePrototypeReloadGuard.js +9 -5
  48. package/src/internals/vite/data-plugin.js +126 -3
  49. package/terminal.config.json +66 -0
@@ -15,15 +15,134 @@
15
15
 
16
16
  import * as p from '@clack/prompts'
17
17
  import { spawn } from 'node:child_process'
18
- import { resolve } from 'node:path'
18
+ import { resolve, join } from 'node:path'
19
19
  import { readFileSync, existsSync } from 'node:fs'
20
20
  import { detectWorktreeName, getPort, releasePort } from '../worktree/port.js'
21
21
  import { startRenameWatcher } from '../rename-watcher/watcher.js'
22
22
  import { compactAll } from '../canvas/compact.js'
23
23
  import { parseFlags } from './flags.js'
24
+ import { setupNeeded, writeUserState, getInstalledStoryboardVersion } from './userState.js'
25
+ import { dim, magenta, bold } from './intro.js'
26
+ import { rmSync } from 'node:fs'
27
+
28
+ /** Find the mascot directory shipped with the storyboard package. */
29
+ function mascotPaths(targetCwd) {
30
+ // Prefer a user override at project root, fall back to the library dir.
31
+ const userConfig = join(targetCwd, 'mascot.config.json')
32
+ const userDir = join(targetCwd, 'mascot')
33
+ if (existsSync(userConfig) && existsSync(userDir)) {
34
+ return { configPath: userConfig, framesDir: userDir }
35
+ }
36
+ // dev.js → src/core/cli/dev.js → package root is 3 dirs up.
37
+ const libRoot = resolve(import.meta.dirname, '..', '..', '..')
38
+ return {
39
+ configPath: join(libRoot, 'mascot.config.json'),
40
+ framesDir: join(libRoot, 'mascot'),
41
+ }
42
+ }
43
+
44
+ /** Apply magenta to the mascot's eye glyphs, dim to the dots/frame. */
45
+ function colorizeMascot(text) {
46
+ const eyes = /[●◠◡]/g
47
+ return text
48
+ .split('\n')
49
+ .map((line) => line.replace(eyes, (m) => magenta(m)).replace(/[·│╭╮╰╯─]/g, (m) => dim(m)))
50
+ .join('\n')
51
+ }
52
+
53
+ /**
54
+ * Render the mascot with an in-place loop animation, then settle on the
55
+ * configured final frame with the URL beside it.
56
+ *
57
+ * Called AFTER Vite prints "ready in Xms" and the storyboard-server plugin
58
+ * has suppressed Vite's own URL block. From this moment, Vite is in idle
59
+ * watch mode and won't print again unless code changes — so our cursor-up
60
+ * redraws can safely own the bottom of the screen.
61
+ */
62
+ function renderMascot({ configPath, framesDir }, urlLine, stopLine) {
63
+ if (!existsSync(configPath)) return false
64
+ let config
65
+ try { config = JSON.parse(readFileSync(configPath, 'utf8')) } catch { return false }
66
+ if (config.enabled === false) return false
67
+ const rawEntries = Array.isArray(config.frames) ? config.frames : []
68
+ if (rawEntries.length === 0) return false
69
+ const defaultDuration = Number(config.frameDurationMs) || 180
70
+ const loops = Math.max(1, Number(config.loops) || 1)
71
+
72
+ // Each entry is either a string filename or a [filename, delayMs] tuple.
73
+ // Per-frame delay falls back to frameDurationMs when missing.
74
+ const entries = rawEntries.map((e) => {
75
+ if (Array.isArray(e)) return { name: String(e[0]), delay: Number(e[1]) || defaultDuration }
76
+ return { name: String(e), delay: defaultDuration }
77
+ })
78
+ const settleName = config.settleFrame || entries[entries.length - 1].name
79
+
80
+ let rawFrames
81
+ try { rawFrames = entries.map((e) => readFileSync(join(framesDir, e.name), 'utf8')) } catch { return false }
82
+ let settleRaw
83
+ try { settleRaw = readFileSync(join(framesDir, settleName), 'utf8') } catch { settleRaw = rawFrames[rawFrames.length - 1] }
84
+
85
+ // Pad every frame to the same height/width so cursor-up redraws fully
86
+ // overwrite the previous frame.
87
+ const trim = (s) => s.replace(/\n+$/, '')
88
+ const split = [...rawFrames, settleRaw].map((f) => trim(f).split('\n'))
89
+ const maxLines = Math.max(...split.map((l) => l.length))
90
+ const maxCols = Math.max(...split.flatMap((lines) => lines.map((l) => l.length)))
91
+ const normalized = split.map((lines) => {
92
+ while (lines.length < maxLines) lines.push('')
93
+ return lines.map((l) => l.padEnd(maxCols, ' ')).join('\n')
94
+ })
95
+ const loopFrames = normalized.slice(0, -1)
96
+ const settleFrame = normalized[normalized.length - 1]
97
+ const lineCount = maxLines
98
+
99
+ const composeSettle = () => {
100
+ const lines = colorizeMascot(settleFrame).split('\n')
101
+ if (lines[1] != null) lines[1] = lines[1] + ' ' + urlLine
102
+ if (lines[2] != null) lines[2] = lines[2] + ' ' + stopLine
103
+ return lines.join('\n')
104
+ }
105
+
106
+ if (!process.stdout.isTTY) {
107
+ process.stdout.write(composeSettle() + '\n')
108
+ return true
109
+ }
110
+
111
+ // Reserve vertical space with blank lines so cursor-up redraws have a
112
+ // stable region to overwrite.
113
+ process.stdout.write('\n'.repeat(lineCount))
114
+ const draw = (frame) => {
115
+ process.stdout.write(`\x1b[${lineCount}A`)
116
+ for (const line of frame.split('\n')) {
117
+ process.stdout.write('\x1b[2K' + line + '\n')
118
+ }
119
+ }
120
+
121
+ return new Promise((resolveAnim) => {
122
+ let loopIdx = 0
123
+ let frameIdx = 0
124
+ // Recursive setTimeout so each frame can have its own delay.
125
+ const step = () => {
126
+ if (loopIdx >= loops) {
127
+ draw(composeSettle())
128
+ resolveAnim(true)
129
+ return
130
+ }
131
+ draw(colorizeMascot(loopFrames[frameIdx]))
132
+ const thisDelay = entries[frameIdx].delay
133
+ frameIdx++
134
+ if (frameIdx >= loopFrames.length) { frameIdx = 0; loopIdx++ }
135
+ const t = setTimeout(step, thisDelay)
136
+ if (typeof t.unref === 'function') t.unref()
137
+ }
138
+ step()
139
+ })
140
+ }
24
141
 
25
142
  const flagSchema = {
26
143
  port: { type: 'number', description: 'Override dev server port' },
144
+ 'no-buddy': { type: 'boolean', default: false, description: 'Omit the storyboard mascot' },
145
+ verbose: { type: 'boolean', default: false, description: 'Show full setup/Vite output' },
27
146
  }
28
147
 
29
148
  /** Read the fixed port from storyboard.config.json, if any. */
@@ -39,6 +158,28 @@ function readConfiguredPort(cwd) {
39
158
  }
40
159
  }
41
160
 
161
+ /**
162
+ * Resolve the Vite base path for URL display. Priority:
163
+ * 1. `VITE_BASE_PATH` env var (matches vite.config.js convention)
164
+ * 2. `basePath` key in storyboard.config.json (if user wants to override)
165
+ * 3. `/` (Vite default)
166
+ *
167
+ * Trailing slash is normalized so the rendered URL never double-slashes.
168
+ */
169
+ function resolveBasePath(cwd) {
170
+ let base = process.env.VITE_BASE_PATH || null
171
+ if (!base) {
172
+ try {
173
+ const cfg = JSON.parse(readFileSync(resolve(cwd, 'storyboard.config.json'), 'utf8'))
174
+ if (typeof cfg.basePath === 'string' && cfg.basePath) base = cfg.basePath
175
+ } catch { /* empty */ }
176
+ }
177
+ base = base || '/'
178
+ if (!base.startsWith('/')) base = '/' + base
179
+ if (!base.endsWith('/')) base = base + '/'
180
+ return base
181
+ }
182
+
42
183
  async function main() {
43
184
  const { flags } = parseFlags(process.argv.slice(3), flagSchema)
44
185
  const worktreeName = detectWorktreeName()
@@ -51,46 +192,191 @@ async function main() {
51
192
  const configuredPort = readConfiguredPort(targetCwd)
52
193
  const strictPort = flags.port == null && configuredPort != null
53
194
  const port = flags.port || configuredPort || getPort(worktreeName)
195
+ const basePath = resolveBasePath(targetCwd)
196
+ const devUrl = `http://localhost:${port}${basePath}`
54
197
 
55
- p.intro('storyboard dev')
56
- p.log.info(`worktree: ${worktreeName}`)
198
+ const verbose = flags.verbose
199
+
200
+ // Quiet header: just `worktree: …` and `port: …`. Everything else
201
+ // (setup logs, compaction, "strict from storyboard.config.json",
202
+ // intro/outro frames) is hidden unless --verbose.
203
+ if (verbose) {
204
+ p.intro('storyboard dev')
205
+ p.log.info(`worktree: ${worktreeName}`)
206
+ } else {
207
+ console.log(` ${dim('worktree:')} ${bold(worktreeName)}`)
208
+ }
209
+
210
+ // Re-run setup automatically if it has never run here, or if the installed
211
+ // @dfosco/storyboard version no longer matches the one setup was last run
212
+ // against. This lets `npm install` upgrades trigger fresh scaffolding
213
+ // without requiring `npx storyboard update`.
214
+ {
215
+ const need = setupNeeded(targetCwd)
216
+ if (need) {
217
+ const why = need.reason === 'first-run'
218
+ ? 'first run in this repo'
219
+ : `version changed ${need.from} → ${need.to}`
220
+ if (verbose) p.log.info(`Running setup (${why})…`)
221
+
222
+ // Invalidate Vite's optimize-deps cache when the storyboard version
223
+ // changes. Otherwise the browser hits 504 Outdated Optimize Dep
224
+ // because the dep graph IDs no longer match the cached chunks.
225
+ try { rmSync(join(targetCwd, 'node_modules', '.vite'), { recursive: true, force: true }) } catch { /* empty */ }
226
+ await new Promise((resolveSetup) => {
227
+ const setupChild = spawn(
228
+ process.platform === 'win32' ? 'npx.cmd' : 'npx',
229
+ ['storyboard', 'setup', '--skip-branch', '--no-buddy'],
230
+ {
231
+ cwd: targetCwd,
232
+ // In quiet mode swallow the spawned-setup output; verbose passes through.
233
+ stdio: verbose ? 'inherit' : 'ignore',
234
+ env: { ...process.env, STORYBOARD_NO_BUDDY: '1' },
235
+ }
236
+ )
237
+ setupChild.on('exit', () => resolveSetup())
238
+ setupChild.on('error', () => resolveSetup())
239
+ })
240
+ const version = getInstalledStoryboardVersion(targetCwd)
241
+ if (version) writeUserState({ setupVersion: version, setupRanAt: new Date().toISOString() }, targetCwd)
242
+ }
243
+ }
57
244
 
58
245
  // Compact bloated canvas JSONL files before booting Vite.
59
246
  const compacted = compactAll(targetCwd)
60
- for (const r of compacted) {
61
- p.log.info(`[compact] ${r.name}: ${(r.before / 1024).toFixed(0)}KB → ${(r.after / 1024).toFixed(0)}KB`)
247
+ if (verbose) {
248
+ for (const r of compacted) {
249
+ p.log.info(`[compact] ${r.name}: ${(r.before / 1024).toFixed(0)}KB → ${(r.after / 1024).toFixed(0)}KB`)
250
+ }
62
251
  }
63
252
 
64
253
  const renameWatcher = startRenameWatcher(targetCwd)
65
254
  const compactInterval = setInterval(() => {
66
255
  try {
67
256
  const r = compactAll(targetCwd)
68
- for (const x of r) p.log.info(`[compact] ${x.name}: ${(x.before / 1024).toFixed(0)}KB → ${(x.after / 1024).toFixed(0)}KB`)
257
+ if (verbose) {
258
+ for (const x of r) p.log.info(`[compact] ${x.name}: ${(x.before / 1024).toFixed(0)}KB → ${(x.after / 1024).toFixed(0)}KB`)
259
+ }
69
260
  } catch { /* non-critical */ }
70
261
  }, 15 * 60 * 1000)
71
262
 
72
263
  const npmBin = process.platform === 'win32' ? 'npx.cmd' : 'npx'
73
- // Without --strictPort: if the requested port is taken, Vite picks the next
74
- // free one. The server-plugin captures the actual port via
75
- // server.httpServer.address() and self-registers in .storyboard/servers.json.
76
- // With --strictPort (config.port is set): Vite exits if the port is taken,
77
- // honoring the user's intent that this instance owns that exact port.
78
264
  const viteArgs = ['vite', '--port', String(port)]
79
265
  if (strictPort) viteArgs.push('--strictPort')
80
- if (strictPort) p.log.info(`port ${port} (strict — from storyboard.config.json)`)
266
+ if (verbose) {
267
+ console.log(` ${dim('port:')} ${bold(port)} ${strictPort ? dim('(strict — from storyboard.config.json)') : ''}`)
268
+ } else {
269
+ console.log(` ${dim('port:')} ${bold(port)}`)
270
+ }
271
+
272
+ const showBuddy = !flags['no-buddy'] && process.env.STORYBOARD_NO_BUDDY !== '1'
273
+
274
+ // Spawn Vite with piped stdio so we can:
275
+ // - In quiet mode: suppress noisy plugin chatter ([storyboard]/[generouted]/etc)
276
+ // until Vite prints "ready in", then render the mascot + URL, then
277
+ // stream the rest through unchanged.
278
+ // - In verbose mode: stream everything through unchanged from the start.
81
279
  const child = spawn(npmBin, viteArgs, {
82
280
  cwd: targetCwd,
83
- stdio: 'inherit',
84
- env: { ...process.env, STORYBOARD_WORKTREE: worktreeName },
281
+ stdio: verbose ? 'inherit' : ['inherit', 'pipe', 'pipe'],
282
+ env: {
283
+ ...process.env,
284
+ STORYBOARD_WORKTREE: worktreeName,
285
+ // Tells the storyboard-server vite plugin to suppress its default
286
+ // "➜ Local:" URL block — we render our own URL beside the mascot.
287
+ ...(verbose ? {} : { STORYBOARD_QUIET_VITE: '1' }),
288
+ },
85
289
  })
86
290
 
87
- p.log.success(`http://localhost:${port}/storyboard/`)
88
- p.log.info('Stop with Ctrl+C')
291
+ if (!verbose) {
292
+ let mascotShown = false
293
+ let mascotDone = false
294
+ const queued = [] // [sink, line] pairs queued during animation
295
+ const flushQueue = () => {
296
+ while (queued.length) {
297
+ const [s, l] = queued.shift()
298
+ s.write(l + '\n')
299
+ }
300
+ }
301
+ const renderOnce = async () => {
302
+ if (mascotShown) return
303
+ mascotShown = true
304
+ console.log()
305
+ const animated = showBuddy && renderMascot(
306
+ mascotPaths(targetCwd),
307
+ bold(devUrl),
308
+ dim('Stop with Ctrl+C'),
309
+ )
310
+ if (!animated) {
311
+ console.log(` ${bold(devUrl)}`)
312
+ console.log(` ${dim('Stop with Ctrl+C')}`)
313
+ }
314
+ // Wait for animation to settle, then flush anything Vite emitted
315
+ // during the animation window so it doesn't shift the cursor mid-frame.
316
+ const isPromise = animated && typeof animated.then === 'function'
317
+ if (isPromise) await animated
318
+ mascotDone = true
319
+ console.log()
320
+ flushQueue()
321
+ }
322
+
323
+ const makeFilter = (sink) => {
324
+ let buf = ''
325
+ return (chunk) => {
326
+ buf += chunk.toString()
327
+ const lines = buf.split('\n')
328
+ buf = lines.pop()
329
+ for (const line of lines) {
330
+ if (mascotDone) {
331
+ sink.write(line + '\n')
332
+ continue
333
+ }
334
+ if (mascotShown) {
335
+ // Animation in flight — buffer subsequent Vite output so it
336
+ // can't shift our cursor mid-redraw.
337
+ queued.push([sink, line])
338
+ continue
339
+ }
340
+ // Pre-ready: only let the "ready in" line through, then start
341
+ // the mascot animation.
342
+ if (/ready in \d/.test(line)) {
343
+ sink.write(line + '\n')
344
+ renderOnce()
345
+ }
346
+ // else: swallow pre-ready chatter
347
+ }
348
+ }
349
+ }
350
+ child.stdout?.on('data', makeFilter(process.stdout))
351
+ child.stderr?.on('data', makeFilter(process.stderr))
352
+ // Safety net: if Vite never prints "ready in" within 8s, render anyway.
353
+ setTimeout(() => { renderOnce() }, 8000).unref?.()
354
+ }
89
355
 
356
+ let shuttingDown = false
90
357
  function shutdown() {
358
+ if (shuttingDown) {
359
+ // Second Ctrl+C → hard exit, kill child with SIGKILL.
360
+ try { child.kill('SIGKILL') } catch { /* empty */ }
361
+ process.exit(130)
362
+ }
363
+ shuttingDown = true
91
364
  clearInterval(compactInterval)
92
365
  renameWatcher.close()
93
- try { child.kill('SIGTERM') } catch { /* already dead */ }
366
+ // Suppress Vite's shutdown-time esbuild noise ("Pre-transform error:
367
+ // The service was stopped" for every in-flight transform) AND the
368
+ // orphan-archive log spam from the storyboard-server plugin teardown.
369
+ try { child.stdout?.removeAllListeners('data') } catch { /* empty */ }
370
+ try { child.stderr?.removeAllListeners('data') } catch { /* empty */ }
371
+ try { child.stdout?.destroy() } catch { /* empty */ }
372
+ try { child.stderr?.destroy() } catch { /* empty */ }
373
+ // SIGINT first (clean esbuild shutdown), then SIGTERM after 2s if
374
+ // Vite is still alive (handles plugins that loop on session teardown),
375
+ // then SIGKILL after 5s as last resort.
376
+ try { child.kill('SIGINT') } catch { /* already dead */ }
377
+ const term = setTimeout(() => { try { child.kill('SIGTERM') } catch { /* empty */ } }, 2000)
378
+ const kill = setTimeout(() => { try { child.kill('SIGKILL') } catch { /* empty */ } }, 5000)
379
+ term.unref?.(); kill.unref?.()
94
380
  releasePort(worktreeName)
95
381
  }
96
382
  process.on('SIGINT', shutdown)
@@ -16,7 +16,7 @@ import * as p from '@clack/prompts'
16
16
  import { spawn } from 'node:child_process'
17
17
  import { existsSync } from 'node:fs'
18
18
  import { resolve } from 'node:path'
19
- import { detectWorktreeName, repoRoot, worktreeDir, listWorktrees, releasePort } from '../worktree/port.js'
19
+ import { detectWorktreeName, repoRoot, worktreeDir, releasePort } from '../worktree/port.js'
20
20
  import { list, findByWorktree, findById, unregister } from '../worktree/serverRegistry.js'
21
21
  import { parseFlags } from './flags.js'
22
22
  import { dim, green } from './intro.js'