@dfosco/storyboard-core 4.2.4 → 4.2.6

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.4",
3
+ "version": "4.2.6",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -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 || ''
package/src/CoreUIBar.jsx CHANGED
@@ -147,6 +147,9 @@ export default function CoreUIBar({ basePath = '/', toolbarConfig, customHandler
147
147
  const [visible, setVisible] = useState(
148
148
  () => !document.documentElement.classList.contains('storyboard-chrome-hidden')
149
149
  )
150
+ const [completelyHidden, setCompletelyHidden] = useState(
151
+ () => document.documentElement.classList.contains('storyboard-chrome-completely-hidden')
152
+ )
150
153
  const [toolComponents, setToolComponents] = useState({})
151
154
  const [toolData, setToolData] = useState({})
152
155
  const [navVersion, setNavVersion] = useState(0)
@@ -338,10 +341,25 @@ export default function CoreUIBar({ basePath = '/', toolbarConfig, customHandler
338
341
  setVisible((v) => {
339
342
  const next = !v
340
343
  document.documentElement.classList.toggle('storyboard-chrome-hidden', !next)
344
+ // Always clear completely-hidden when toggling via cmd+.
345
+ document.documentElement.classList.remove('storyboard-chrome-completely-hidden')
341
346
  return next
342
347
  })
343
348
  }, [])
344
349
 
350
+ const toggleCompletelyHidden = useCallback(() => {
351
+ const isAnyHidden = document.documentElement.classList.contains('storyboard-chrome-hidden')
352
+ if (isAnyHidden) {
353
+ document.documentElement.classList.remove('storyboard-chrome-hidden')
354
+ document.documentElement.classList.remove('storyboard-chrome-completely-hidden')
355
+ setVisible(true)
356
+ } else {
357
+ document.documentElement.classList.add('storyboard-chrome-hidden')
358
+ document.documentElement.classList.add('storyboard-chrome-completely-hidden')
359
+ setVisible(false)
360
+ }
361
+ }, [])
362
+
345
363
  function showFlowInfoDialog(name, json, error) {
346
364
  setFlowName(name)
347
365
  setFlowJson(json)
@@ -497,13 +515,15 @@ export default function CoreUIBar({ basePath = '/', toolbarConfig, customHandler
497
515
 
498
516
  setRoutingBasePath(basePath)
499
517
 
500
- // Sync visible state when storyboard-chrome-hidden is toggled externally
518
+ // Sync visible + completelyHidden state when classes are toggled externally
501
519
  const chromeObserver = new MutationObserver(() => {
502
520
  const hidden = document.documentElement.classList.contains('storyboard-chrome-hidden')
521
+ const fully = document.documentElement.classList.contains('storyboard-chrome-completely-hidden')
503
522
  setVisible((v) => {
504
523
  if (v === !hidden) return v
505
524
  return !hidden
506
525
  })
526
+ setCompletelyHidden(fully)
507
527
  })
508
528
  chromeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
509
529
 
@@ -690,10 +710,21 @@ export default function CoreUIBar({ basePath = '/', toolbarConfig, customHandler
690
710
  function handleKeydown(e) {
691
711
  const hideKey = shortcutsConfig.hideChrome?.key || '.'
692
712
 
693
- if (e.key === hideKey && (e.metaKey || e.ctrlKey)) {
694
- e.preventDefault()
695
- toggleToolsVisibility()
713
+ if ((e.metaKey || e.ctrlKey) && !e.shiftKey) {
714
+ // Alt+Cmd+. — completely hide (use e.code since alt changes e.key on macOS)
715
+ if (e.altKey && e.code === 'Period') {
716
+ e.preventDefault()
717
+ toggleCompletelyHidden()
718
+ return
719
+ }
720
+ // Cmd+. — regular hide/show
721
+ if (!e.altKey && e.key === hideKey) {
722
+ e.preventDefault()
723
+ toggleToolsVisibility()
724
+ return
725
+ }
696
726
  }
727
+
697
728
  for (const menu of cleanedMenus) {
698
729
  const shortcut = menu.shortcut
699
730
  if (!shortcut?.key) continue
@@ -711,14 +742,14 @@ export default function CoreUIBar({ basePath = '/', toolbarConfig, customHandler
711
742
 
712
743
  window.addEventListener('keydown', handleKeydown)
713
744
  return () => window.removeEventListener('keydown', handleKeydown)
714
- }, [shortcutsConfig, cleanedMenus, toggleToolsVisibility])
745
+ }, [shortcutsConfig, cleanedMenus, toggleToolsVisibility, toggleCompletelyHidden])
715
746
 
716
747
  if (isEmbed) return null
717
748
 
718
749
  return (
719
750
  <>
720
751
  {/* Canvas toolbar */}
721
- {canvasActive && canvasMenus.length > 0 && (
752
+ {canvasActive && !completelyHidden && canvasMenus.length > 0 && (
722
753
  <div
723
754
  className="fixed bottom-6 left-6 z-[9999] font-sans flex items-center gap-3"
724
755
  role="toolbar"
@@ -2,6 +2,7 @@
2
2
  * HideChromeTrigger — toolbar button that toggles toolbar/branch bar visibility.
3
3
  * Always visible (even in hide mode). Uses the lightbulb icon.
4
4
  * In hide mode: goes 50% opacity.
5
+ * In completely-hidden mode: not rendered at all.
5
6
  */
6
7
 
7
8
  import { useState, useEffect, useCallback } from 'react'
@@ -12,10 +13,14 @@ export default function HideChromeTrigger({ config = {}, tabindex }) {
12
13
  const [hidden, setHidden] = useState(
13
14
  () => document.documentElement.classList.contains('storyboard-chrome-hidden')
14
15
  )
16
+ const [completelyHidden, setCompletelyHidden] = useState(
17
+ () => document.documentElement.classList.contains('storyboard-chrome-completely-hidden')
18
+ )
15
19
 
16
20
  useEffect(() => {
17
21
  const observer = new MutationObserver(() => {
18
22
  setHidden(document.documentElement.classList.contains('storyboard-chrome-hidden'))
23
+ setCompletelyHidden(document.documentElement.classList.contains('storyboard-chrome-completely-hidden'))
19
24
  })
20
25
  observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
21
26
  return () => observer.disconnect()
@@ -23,8 +28,11 @@ export default function HideChromeTrigger({ config = {}, tabindex }) {
23
28
 
24
29
  const toggle = useCallback(() => {
25
30
  document.documentElement.classList.toggle('storyboard-chrome-hidden')
31
+ document.documentElement.classList.remove('storyboard-chrome-completely-hidden')
26
32
  }, [])
27
33
 
34
+ if (completelyHidden) return null
35
+
28
36
  return (
29
37
  <span style={{ opacity: hidden ? 0.5 : 1, transition: 'opacity 0.15s' }}>
30
38
  <TriggerButton
@@ -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
 
@@ -28,6 +28,7 @@ import {
28
28
  let _mounted = false
29
29
 
30
30
  const CHROME_HIDDEN_KEY = 'sb-chrome-hidden'
31
+ const CHROME_COMPLETELY_HIDDEN_KEY = 'sb-chrome-completely-hidden'
31
32
 
32
33
  /**
33
34
  * Migrate localStorage keys renamed in 4.3.0.
@@ -54,20 +55,26 @@ function migrateLocalStorageKeys() {
54
55
  function applyEarlyChromeState() {
55
56
  if (typeof document === 'undefined' || typeof localStorage === 'undefined') return
56
57
  const hidden = localStorage.getItem(CHROME_HIDDEN_KEY) === '1'
58
+ const completelyHidden = localStorage.getItem(CHROME_COMPLETELY_HIDDEN_KEY) === '1'
57
59
  if (hidden) {
58
60
  document.documentElement.classList.add('storyboard-chrome-hidden')
59
61
  }
62
+ if (completelyHidden) {
63
+ document.documentElement.classList.add('storyboard-chrome-completely-hidden')
64
+ }
60
65
  }
61
66
 
62
67
  /**
63
- * Watch for changes to the storyboard-chrome-hidden class and persist to
64
- * localStorage. Works regardless of which code path toggles the class.
68
+ * Watch for changes to chrome-hidden / chrome-completely-hidden classes
69
+ * and persist to localStorage. Works regardless of which code path toggles them.
65
70
  */
66
71
  function installChromeStatePersistence() {
67
72
  if (typeof document === 'undefined' || typeof localStorage === 'undefined') return
68
73
  const observer = new MutationObserver(() => {
69
74
  const hidden = document.documentElement.classList.contains('storyboard-chrome-hidden')
75
+ const completelyHidden = document.documentElement.classList.contains('storyboard-chrome-completely-hidden')
70
76
  localStorage.setItem(CHROME_HIDDEN_KEY, hidden ? '1' : '0')
77
+ localStorage.setItem(CHROME_COMPLETELY_HIDDEN_KEY, completelyHidden ? '1' : '0')
71
78
  })
72
79
  observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
73
80
  }
@@ -17,6 +17,7 @@ export async function handler() {
17
17
  execute: () => {
18
18
  const isHidden = document.documentElement.classList.contains('storyboard-chrome-hidden')
19
19
  document.documentElement.classList.toggle('storyboard-chrome-hidden', !isHidden)
20
+ document.documentElement.classList.remove('storyboard-chrome-completely-hidden')
20
21
  },
21
22
  }]
22
23
  },
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({