@dfosco/storyboard-core 4.2.3 → 4.2.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-core",
3
- "version": "4.2.3",
3
+ "version": "4.2.5",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -0,0 +1,64 @@
1
+ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2
+
3
+ # dependencies
4
+ /node_modules
5
+ /packages/**/node_modules
6
+ /.pnp
7
+ .pnp.js
8
+
9
+ # testing
10
+ /coverage
11
+
12
+ # production
13
+ /build
14
+ /dist
15
+
16
+ # misc
17
+ .DS_Store
18
+ .env.local
19
+ .env.development.local
20
+ .env.test.local
21
+ .env.production.local
22
+
23
+ npm-debug.log*
24
+ yarn-debug.log*
25
+ yarn-error.log*
26
+ .playwright-profile
27
+
28
+ .github/skills/_archive
29
+ .github/skills/primer-primitives
30
+ .github/skills/primer-components-catalog
31
+ .github/skills/primer-screenshot-builder
32
+ .github/skills/primer-screenshot-patterns
33
+ .github/skills/primer-url-builder
34
+ .github/skills/playwright-cli
35
+ .worktrees
36
+ # Agent symlinks are build targets — source of truth is .agents/agents/
37
+ # storyboard setup creates symlinks for Copilot CLI and Claude Code
38
+ .github/agents/_buddy-rails.md
39
+ .github/agents/_buddy.md
40
+ .github/agents/terminal-agent.md
41
+ .github/agents/prompt-agent.md
42
+ .claude/agents/
43
+ _*.md
44
+
45
+ # Compiled UI bundle (built before publish, not tracked in git)
46
+ packages/core/dist/storyboard-ui.*
47
+
48
+ .clips
49
+
50
+ # Agent Browser
51
+ agent-browser.json
52
+
53
+ # Selected widgets bridge (real-time canvas selection for Copilot)
54
+ .storyboard
55
+
56
+ # Private canvas images (tilde prefix = not committed)
57
+ src/canvas/images/~*
58
+ assets/canvas/images/~*
59
+ assets/canvas/snapshots/~*
60
+ assets/.storyboard-public/terminal-snapshots/~*
61
+ .sync-target
62
+
63
+ # Integration test results (ephemeral local artifacts)
64
+ test-results/
@@ -6,7 +6,7 @@
6
6
  "mode": "updateable"
7
7
  },
8
8
  {
9
- "source": "scaffold/.gitignore",
9
+ "source": "scaffold/gitignore",
10
10
  "target": ".gitignore",
11
11
  "mode": "updateable"
12
12
  },
@@ -16,6 +16,17 @@ export default function ActionMenuButton({ config = {}, data: _data, localOnly:
16
16
  return unsub
17
17
  }, [])
18
18
 
19
+ // Allow external callers (e.g. command palette) to open this menu
20
+ useEffect(() => {
21
+ const actionId = config.action
22
+ if (!actionId) return
23
+ function onTrigger(e) {
24
+ if (e.detail?.action === actionId) setMenuOpen(true)
25
+ }
26
+ window.addEventListener('storyboard:open-tool-menu', onTrigger)
27
+ return () => window.removeEventListener('storyboard:open-tool-menu', onTrigger)
28
+ }, [config.action])
29
+
19
30
  const children = config.action ? getActionChildren(config.action) : []
20
31
  const hasRadio = children.some((c) => c.type === 'radio')
21
32
  const activeValue = children.find((c) => c.type === 'radio' && c.active)?.id || ''
@@ -81,6 +81,7 @@ export class HotPool {
81
81
  #healthTimer = null
82
82
  #prereqsAvailable = null
83
83
  #wsSend = null
84
+ #webglReadySlots = 0
84
85
 
85
86
  // Agent config (null for bare shell pools)
86
87
  #agentConfig = null
@@ -100,7 +101,7 @@ export class HotPool {
100
101
  constructor({ root, poolId = 'terminal', config = {}, agentConfig = null, wsSend = null }) {
101
102
  this.#root = root
102
103
  this.#poolId = poolId
103
- this.#poolSize = Math.max(1, config.pool_size ?? DEFAULT_POOL_SIZE)
104
+ this.#poolSize = Math.max(0, config.pool_size ?? DEFAULT_POOL_SIZE)
104
105
  this.#maxPoolSize = Math.max(this.#poolSize, config.max_pool_size ?? DEFAULT_MAX_POOL_SIZE)
105
106
  this.#cooldownMs = (config.load_balancer_cooldown_mins ?? DEFAULT_COOLDOWN_MINS) * 60_000
106
107
  this.#enabled = config.enabled !== false
@@ -108,6 +109,7 @@ export class HotPool {
108
109
  this.#loadBalancer = config.load_balancer !== false
109
110
  this.#wsSend = wsSend
110
111
  this.#agentConfig = agentConfig
112
+ this.#webglReadySlots = Math.max(0, config.webgl_ready_slots ?? 0)
111
113
  }
112
114
 
113
115
  get poolId() { return this.#poolId }
@@ -177,8 +179,12 @@ export class HotPool {
177
179
  return null
178
180
  }
179
181
 
182
+ // Session is WebGL-ready if its queue position was within webgl_ready_slots
183
+ const webglReady = this.#webglReadySlots > 0 && idx < this.#webglReadySlots
184
+
180
185
  const session = this.#queue.splice(idx, 1)[0]
181
186
  session.state = 'acquired'
187
+ session.webglReady = webglReady
182
188
  this.#acquired.set(session.id, session)
183
189
  const age = ((Date.now() - session.createdAt) / 1000).toFixed(1)
184
190
  const readyCount = this.#queue.filter(s => s.state === 'ready').length
@@ -186,10 +192,10 @@ export class HotPool {
186
192
  // Scale-up: queue drained to 0 ready → enter pressure mode
187
193
  if (readyCount === 0 && !this.#pressured) {
188
194
  this.#pressured = true
189
- this.#log(`→ ACQUIRED ${session.id} tmux=${session.tmuxName} (age: ${age}s) — ⚡ PRESSURE ON (scaling to max_pool_size=${this.#maxPoolSize})`)
195
+ this.#log(`→ ACQUIRED ${session.id} tmux=${session.tmuxName} (age: ${age}s, webglReady: ${webglReady}) — ⚡ PRESSURE ON (scaling to max_pool_size=${this.#maxPoolSize})`)
190
196
  this.#resetCooldown()
191
197
  } else {
192
- this.#log(`→ ACQUIRED ${session.id} tmux=${session.tmuxName} (age: ${age}s, queue: ${readyCount}/${this.#fillTarget})`)
198
+ this.#log(`→ ACQUIRED ${session.id} tmux=${session.tmuxName} (age: ${age}s, webglReady: ${webglReady}, queue: ${readyCount}/${this.#fillTarget})`)
193
199
  this.#resetCooldown()
194
200
  }
195
201
 
@@ -237,6 +243,7 @@ export class HotPool {
237
243
  load_balancer: this.#loadBalancer,
238
244
  load_balancer_cooldown_mins: this.#cooldownMs / 60_000,
239
245
  verbose: this.#verbose,
246
+ webgl_ready_slots: this.#webglReadySlots,
240
247
  },
241
248
  agentConfig: this.#agentConfig ? {
242
249
  startupCommand: this.#agentConfig.startupCommand,
@@ -254,10 +261,11 @@ export class HotPool {
254
261
  }
255
262
 
256
263
  reconfigure(config) {
257
- if (config.max_pool_size !== undefined) this.#maxPoolSize = Math.max(1, config.max_pool_size)
264
+ if (config.max_pool_size !== undefined) this.#maxPoolSize = Math.max(0, config.max_pool_size)
258
265
  if (config.load_balancer_cooldown_mins !== undefined) this.#cooldownMs = config.load_balancer_cooldown_mins * 60_000
259
266
  if (config.load_balancer !== undefined) this.#loadBalancer = !!config.load_balancer
260
- const newSize = Math.min(Math.max(1, config.pool_size ?? this.#poolSize), this.#maxPoolSize)
267
+ if (config.webgl_ready_slots !== undefined) this.#webglReadySlots = Math.max(0, config.webgl_ready_slots)
268
+ const newSize = Math.min(Math.max(0, config.pool_size ?? this.#poolSize), this.#maxPoolSize)
261
269
  const newEnabled = config.enabled !== false
262
270
  if (config.verbose !== undefined) this.#verbose = !!config.verbose
263
271
 
@@ -644,6 +652,7 @@ export class HotPoolManager {
644
652
  load_balancer: config.load_balancer !== false,
645
653
  enabled: this.#enabled,
646
654
  verbose: !!config.verbose,
655
+ webgl_ready_slots: poolsConfig[poolId]?.webgl_ready_slots ?? 0,
647
656
  })
648
657
 
649
658
  // Terminal pool (bare shells)
@@ -738,6 +747,7 @@ export class HotPoolManager {
738
747
  load_balancer: config.load_balancer,
739
748
  enabled: config.enabled,
740
749
  verbose: config.verbose,
750
+ webgl_ready_slots: poolsConfig[id]?.webgl_ready_slots,
741
751
  }
742
752
  pool.reconfigure(poolConfig)
743
753
  }
@@ -770,7 +770,7 @@ export function createCanvasHandler(ctx) {
770
770
  })
771
771
 
772
772
  const response = { success: true, widget }
773
- if (hotSession) response.hotSession = { id: hotSession.id, tmuxName: hotSession.tmuxName || null }
773
+ if (hotSession) response.hotSession = { id: hotSession.id, tmuxName: hotSession.tmuxName || null, webglReady: !!hotSession.webglReady }
774
774
  sendJson(res, 201, response)
775
775
  pushCanvasUpdate(name, filePath, __viteWs)
776
776
  } catch (err) {
@@ -1334,7 +1334,7 @@ export function createCanvasHandler(ctx) {
1334
1334
  if (ref) refs[ref] = widgetId
1335
1335
 
1336
1336
  const result = { index: i, op: 'create-widget', ref: ref || undefined, widgetId, widget }
1337
- if (hotSession) result.hotSession = { id: hotSession.id, tmuxName: hotSession.tmuxName || null }
1337
+ if (hotSession) result.hotSession = { id: hotSession.id, tmuxName: hotSession.tmuxName || null, webglReady: !!hotSession.webglReady }
1338
1338
  results.push(result)
1339
1339
  break
1340
1340
  }
@@ -65,7 +65,7 @@
65
65
  * @property {number} [default_max_pool_size] — default surge cap per pool (default: 3)
66
66
  * @property {boolean} [load_balancer] — enable auto-scaling (default: true)
67
67
  * @property {number} [load_balancer_cooldown_mins] — minutes idle before scale-down (default: 10)
68
- * @property {Record<string, { pool_size?: number, max_pool_size?: number }>} [pools] — per-pool overrides (terminal, prompt, copilot, claude, codex)
68
+ * @property {Record<string, { pool_size?: number, max_pool_size?: number, webgl_ready_slots?: number }>} [pools] — per-pool overrides (terminal, prompt, copilot, claude, codex). webgl_ready_slots: how many front-of-queue sessions should start with PINNED WebGL priority (default: 0)
69
69
  */
70
70
 
71
71
  /**
package/src/index.js CHANGED
@@ -62,7 +62,7 @@ export { mountSceneDebug } from './sceneDebug.js'
62
62
  export { mountStoryboardCore } from './mountStoryboardCore.js'
63
63
 
64
64
  // Viewfinder utilities
65
- export { hash, resolveFlowRoute, getFlowMeta, buildPrototypeIndex } from './viewfinder.js'
65
+ export { hash, resolveFlowRoute, getFlowMeta, buildPrototypeIndex, appendTokens } from './viewfinder.js'
66
66
  // Deprecated aliases
67
67
  export { resolveSceneRoute, getSceneMeta } from './viewfinder.js'
68
68
 
package/src/viewfinder.js CHANGED
@@ -13,6 +13,26 @@ export function hash(str) {
13
13
  return Math.abs(h)
14
14
  }
15
15
 
16
+ /**
17
+ * Append `tokens` from flow/prototype data as URL search params.
18
+ * Filters out reserved keys (`flow`, `scene`) and non-scalar values.
19
+ *
20
+ * @param {string} url - Base URL (may already contain query params)
21
+ * @param {object|null|undefined} tokens - Key-value pairs to append
22
+ * @returns {string} URL with token params appended
23
+ */
24
+ export function appendTokens(url, tokens) {
25
+ if (!tokens || typeof tokens !== 'object') return url
26
+ const reserved = new Set(['flow', 'scene'])
27
+ const entries = Object.entries(tokens).filter(
28
+ ([k, v]) => v != null && !reserved.has(k) && typeof v !== 'object',
29
+ )
30
+ if (entries.length === 0) return url
31
+ const sep = url.includes('?') ? '&' : '?'
32
+ const qs = entries.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&')
33
+ return `${url}${sep}${qs}`
34
+ }
35
+
16
36
  /**
17
37
  * Resolve the target route path for a flow.
18
38
  *
@@ -23,42 +43,51 @@ export function hash(str) {
23
43
  * 4. Fall back to root "/"
24
44
  *
25
45
  * Flows with `meta.default: true` targeting a route omit the `?flow=` param.
46
+ * If the flow data contains a `tokens` object, its entries are appended as query params.
26
47
  *
27
48
  * @param {string} flowName
28
49
  * @param {string[]} knownRoutes - Array of route names (e.g. ["Dashboard", "Repositories"])
29
50
  * @returns {string} Full path with optional ?flow= param
30
51
  */
31
52
  export function resolveFlowRoute(flowName, knownRoutes = []) {
53
+ let routeUrl
54
+ let data = null
55
+
32
56
  // Case-insensitive match against known routes
33
- for (const route of knownRoutes) {
34
- if (route.toLowerCase() === flowName.toLowerCase()) {
35
- // Flow name matches the route — no ?flow= needed,
36
- // StoryboardProvider auto-matches by page name
37
- return `/${route}`
38
- }
39
- }
57
+ const matchedRoute = knownRoutes.find(r => r.toLowerCase() === flowName.toLowerCase())
40
58
 
41
- try {
42
- const data = loadFlow(flowName)
59
+ if (matchedRoute) {
60
+ routeUrl = `/${matchedRoute}`
61
+ } else {
62
+ try {
63
+ data = loadFlow(flowName)
43
64
 
44
- // Check for explicit route: top-level `route`, then meta.route, then legacy sceneMeta.route
45
- const explicitRoute = data?.route || data?.meta?.route || data?.flowMeta?.route || data?.sceneMeta?.route
46
- if (explicitRoute) {
47
- const normalized = explicitRoute.startsWith('/') ? explicitRoute : `/${explicitRoute}`
48
- if (data?.meta?.default === true) return normalized
49
- return `${normalized}?flow=${encodeURIComponent(flowName)}`
65
+ // Check for explicit route: top-level `route`, then meta.route, then legacy sceneMeta.route
66
+ const explicitRoute = data?.route || data?.meta?.route || data?.flowMeta?.route || data?.sceneMeta?.route
67
+ if (explicitRoute) {
68
+ const normalized = explicitRoute.startsWith('/') ? explicitRoute : `/${explicitRoute}`
69
+ routeUrl = data?.meta?.default === true
70
+ ? normalized
71
+ : `${normalized}?flow=${encodeURIComponent(flowName)}`
72
+ } else if (data?._route) {
73
+ // Use inferred route from file path (injected by Vite data plugin)
74
+ routeUrl = data?.meta?.default === true
75
+ ? data._route
76
+ : `${data._route}?flow=${encodeURIComponent(flowName)}`
77
+ } else {
78
+ routeUrl = `/?flow=${encodeURIComponent(flowName)}`
79
+ }
80
+ } catch {
81
+ routeUrl = `/?flow=${encodeURIComponent(flowName)}`
50
82
  }
83
+ }
51
84
 
52
- // Use inferred route from file path (injected by Vite data plugin)
53
- if (data?._route) {
54
- if (data?.meta?.default === true) return data._route
55
- return `${data._route}?flow=${encodeURIComponent(flowName)}`
56
- }
57
- } catch {
58
- // ignore load errors
85
+ // Load flow data for tokens if not already loaded (e.g. known-route early match)
86
+ if (!data) {
87
+ try { data = loadFlow(flowName) } catch { /* ignore */ }
59
88
  }
60
89
 
61
- return `/?flow=${encodeURIComponent(flowName)}`
90
+ return appendTokens(routeUrl, data?.tokens)
62
91
  }
63
92
 
64
93
  /** @deprecated Use resolveFlowRoute() */
@@ -1,5 +1,5 @@
1
1
  import { init } from './loader.js'
2
- import { hash, resolveFlowRoute, getFlowMeta, resolveSceneRoute, getSceneMeta, buildPrototypeIndex } from './viewfinder.js'
2
+ import { hash, resolveFlowRoute, getFlowMeta, resolveSceneRoute, getSceneMeta, buildPrototypeIndex, appendTokens } from './viewfinder.js'
3
3
 
4
4
  const makeIndex = () => ({
5
5
  flows: {
@@ -343,3 +343,114 @@ describe('buildPrototypeIndex', () => {
343
343
  expect(result.prototypes[0].lastModified).toBeNull()
344
344
  })
345
345
  })
346
+
347
+ // ── appendTokens ──
348
+
349
+ describe('appendTokens', () => {
350
+ it('returns url unchanged when tokens is null', () => {
351
+ expect(appendTokens('/foo', null)).toBe('/foo')
352
+ })
353
+
354
+ it('returns url unchanged when tokens is undefined', () => {
355
+ expect(appendTokens('/foo', undefined)).toBe('/foo')
356
+ })
357
+
358
+ it('returns url unchanged when tokens is empty', () => {
359
+ expect(appendTokens('/foo', {})).toBe('/foo')
360
+ })
361
+
362
+ it('appends single token with ?', () => {
363
+ expect(appendTokens('/foo', { token: 'abc' })).toBe('/foo?token=abc')
364
+ })
365
+
366
+ it('appends multiple tokens', () => {
367
+ const result = appendTokens('/foo', { token: 'abc', model: 'gpt-4' })
368
+ expect(result).toBe('/foo?token=abc&model=gpt-4')
369
+ })
370
+
371
+ it('uses & separator when url already has query params', () => {
372
+ expect(appendTokens('/foo?flow=bar', { token: 'abc' })).toBe('/foo?flow=bar&token=abc')
373
+ })
374
+
375
+ it('filters out reserved key "flow"', () => {
376
+ expect(appendTokens('/foo', { flow: 'bad', token: 'good' })).toBe('/foo?token=good')
377
+ })
378
+
379
+ it('filters out reserved key "scene"', () => {
380
+ expect(appendTokens('/foo', { scene: 'bad', token: 'good' })).toBe('/foo?token=good')
381
+ })
382
+
383
+ it('filters out null and undefined values', () => {
384
+ expect(appendTokens('/foo', { a: null, b: undefined, c: 'ok' })).toBe('/foo?c=ok')
385
+ })
386
+
387
+ it('filters out object and array values', () => {
388
+ expect(appendTokens('/foo', { a: { nested: true }, b: [1, 2], c: 'ok' })).toBe('/foo?c=ok')
389
+ })
390
+
391
+ it('encodes special characters', () => {
392
+ expect(appendTokens('/foo', { key: 'hello world' })).toBe('/foo?key=hello%20world')
393
+ })
394
+
395
+ it('handles numeric and boolean values', () => {
396
+ expect(appendTokens('/foo', { count: 42, active: true })).toBe('/foo?count=42&active=true')
397
+ })
398
+ })
399
+
400
+ // ── resolveFlowRoute with tokens ──
401
+
402
+ describe('resolveFlowRoute with tokens', () => {
403
+ it('appends tokens to a flow with explicit route', () => {
404
+ init({
405
+ flows: { 'with-tokens': { route: '/Overview', tokens: { token: 'sk-abc' } } },
406
+ objects: {},
407
+ records: {},
408
+ })
409
+ expect(resolveFlowRoute('with-tokens', [])).toBe('/Overview?flow=with-tokens&token=sk-abc')
410
+ })
411
+
412
+ it('appends tokens to a known-route match', () => {
413
+ init({
414
+ flows: { Dashboard: { heading: 'Hi', tokens: { model: 'gpt-4' } } },
415
+ objects: {},
416
+ records: {},
417
+ })
418
+ expect(resolveFlowRoute('Dashboard', ['Dashboard'])).toBe('/Dashboard?model=gpt-4')
419
+ })
420
+
421
+ it('appends tokens to inferred route', () => {
422
+ init({
423
+ flows: { 'inferred': { _route: '/Settings', tokens: { key: 'val' } } },
424
+ objects: {},
425
+ records: {},
426
+ })
427
+ expect(resolveFlowRoute('inferred', [])).toBe('/Settings?flow=inferred&key=val')
428
+ })
429
+
430
+ it('appends tokens to default flow fallback', () => {
431
+ init({
432
+ flows: { 'no-route': { title: 'No route', tokens: { api: '123' } } },
433
+ objects: {},
434
+ records: {},
435
+ })
436
+ expect(resolveFlowRoute('no-route', [])).toBe('/?flow=no-route&api=123')
437
+ })
438
+
439
+ it('does not append tokens when flow has none', () => {
440
+ init({
441
+ flows: { simple: { route: '/Page' } },
442
+ objects: {},
443
+ records: {},
444
+ })
445
+ expect(resolveFlowRoute('simple', [])).toBe('/Page?flow=simple')
446
+ })
447
+
448
+ it('appends tokens with meta.default route (no ?flow=)', () => {
449
+ init({
450
+ flows: { 'default-tokens': { _route: '/Home', meta: { default: true }, tokens: { key: 'val' } } },
451
+ objects: {},
452
+ records: {},
453
+ })
454
+ expect(resolveFlowRoute('default-tokens', [])).toBe('/Home?key=val')
455
+ })
456
+ })
@@ -567,6 +567,15 @@ export default function storyboardServer() {
567
567
  injectTo: 'head',
568
568
  })
569
569
 
570
+ // Inject dev domain name for branch bar display
571
+ if (config.devDomain) {
572
+ tags.push({
573
+ tag: 'script',
574
+ children: `window.__SB_DEV_DOMAIN__=${JSON.stringify(config.devDomain)}`,
575
+ injectTo: 'head',
576
+ })
577
+ }
578
+
570
579
  // Inject per-domain branch bar color (configurable via devDomainColor)
571
580
  if (config.devDomainColor) {
572
581
  tags.push({