@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
@@ -19,6 +19,7 @@ import { serverFeatures as workshopFeatures } from '../workshop/features/registr
19
19
  import { docsHandler, collectFiles } from './docs-handler.js'
20
20
  import { createCanvasHandler } from '../canvas/server.js'
21
21
  import { setupSelectedWidgets } from '../canvas/selectedWidgets.js'
22
+ import { readAgentsConfig, readHotPoolConfig } from '../canvas/configReader.js'
22
23
  import { HotPoolManager } from '../canvas/hot-pool.js'
23
24
  import { createAutosyncHandler } from '../autosync/server.js'
24
25
  import { setupTerminalServer } from '../canvas/terminal-server.js'
@@ -122,6 +123,22 @@ export default function storyboardServer() {
122
123
  'highlight.js/lib/languages/xml',
123
124
  ],
124
125
  },
126
+ server: {
127
+ watch: {
128
+ // Never feed runtime-state directories to Vite's file watcher.
129
+ // These dirs are written to on sub-second cadence by terminals,
130
+ // canvas snapshots, agent state, etc. Letting them reach the
131
+ // watcher produces full-reload loops on any unguarded route.
132
+ // (server.watcher.unwatch() after the fact isn't enough — new
133
+ // files inside the dirs can still be re-added by chokidar.)
134
+ ignored: [
135
+ '**/.storyboard/**',
136
+ '**/assets/canvas/images/**',
137
+ '**/assets/canvas/snapshots/**',
138
+ '**/assets/.storyboard-public/**',
139
+ ],
140
+ },
141
+ },
125
142
  }
126
143
  },
127
144
 
@@ -133,6 +150,20 @@ export default function storyboardServer() {
133
150
  },
134
151
 
135
152
  configureServer(server) {
153
+ // --- Custom URL printer ----------------------------------------------------
154
+ // Vite calls server.printUrls() after its "ready in Xms" banner.
155
+ // Override to suppress Vite's default "➜ Local:" block and let the
156
+ // CLI render our own URL (and mascot) instead. Suppression is opt-in
157
+ // via STORYBOARD_QUIET_VITE=1 — we set this from storyboard dev.js
158
+ // when --verbose is OFF.
159
+ if (process.env.STORYBOARD_QUIET_VITE === '1') {
160
+ const originalPrintUrls = server.printUrls?.bind(server)
161
+ server.printUrls = () => {
162
+ // No-op: caller renders its own URL + mascot.
163
+ void originalPrintUrls
164
+ }
165
+ }
166
+
136
167
  // --- Reload guard ----------------------------------------------------------
137
168
  // Suppress full-reloads and HMR updates for guarded clients.
138
169
  //
@@ -140,7 +171,9 @@ export default function storyboardServer() {
140
171
  // 1. Canvas guard — canvas pages send heartbeats via storyboard:canvas-hmr-guard.
141
172
  // Controlled by the "canvas-auto-reload" feature flag (default: false = guard ON).
142
173
  // 2. Prototype guard — all pages send heartbeats via storyboard:prototype-reload-guard.
143
- // Controlled by the "prototype-auto-reload" feature flag (default: true = guard OFF).
174
+ // Controlled by the "prototype-auto-reload" feature flag (default: false = guard ON).
175
+ // The prototype guard only drops `full-reload` payloads; `update` (HMR module /
176
+ // React Fast Refresh) payloads still flow so component edits hot-update normally.
144
177
  //
145
178
  // Both guards auto-expire 5s after the last heartbeat so closed tabs never
146
179
  // leave them stuck. Custom storyboard events always pass through.
@@ -193,12 +226,14 @@ export default function storyboardServer() {
193
226
  server.httpServer?.on('close', () => clearInterval(cleanup))
194
227
  server.httpServer?.on('close', () => stopMaintenance())
195
228
 
196
- function isClientGuarded(client) {
229
+ function isCanvasClientGuarded(client) {
197
230
  const cu = canvasGuardedClients.get(client)
198
- if (cu != null && Date.now() < cu) return true
231
+ return cu != null && Date.now() < cu
232
+ }
233
+
234
+ function isPrototypeClientGuarded(client) {
199
235
  const pu = prototypeGuardedClients.get(client)
200
- if (pu != null && Date.now() < pu) return true
201
- return false
236
+ return pu != null && Date.now() < pu
202
237
  }
203
238
 
204
239
  const originalSend = server.ws.send.bind(server.ws)
@@ -217,10 +252,24 @@ export default function storyboardServer() {
217
252
  return originalSend(payload, ...rest)
218
253
  }
219
254
 
220
- // For reload/update payloads, send only to unguarded clients
221
- if (payload && (payload.type === 'full-reload' || payload.type === 'update')) {
255
+ // full-reload: drop for any guarded client (canvas OR prototype).
256
+ // Both guards exist to preserve in-page state across data file edits.
257
+ if (payload && payload.type === 'full-reload') {
258
+ for (const client of server.ws.clients) {
259
+ if (!isCanvasClientGuarded(client) && !isPrototypeClientGuarded(client)) {
260
+ client.send(payload)
261
+ }
262
+ }
263
+ return
264
+ }
265
+
266
+ // update (HMR module updates / React Fast Refresh): only drop for
267
+ // CANVAS-guarded clients (canvas state must not be disturbed by
268
+ // unrelated module updates). Prototype-guarded clients still
269
+ // receive updates so React Fast Refresh works while developing.
270
+ if (payload && payload.type === 'update') {
222
271
  for (const client of server.ws.clients) {
223
- if (!isClientGuarded(client)) {
272
+ if (!isCanvasClientGuarded(client)) {
224
273
  client.send(payload)
225
274
  }
226
275
  }
@@ -279,8 +328,8 @@ export default function storyboardServer() {
279
328
  routeHandlers.set('docs', docsHandler({ root, sendJson: sendJsonLogged }))
280
329
 
281
330
  // Create shared hot pool manager (per-type pre-warmed sessions)
282
- const hotPoolConfig = config.hotPool || {}
283
- const agentsConfig = config.canvas?.agents || {}
331
+ const hotPoolConfig = readHotPoolConfig(root)
332
+ const agentsConfig = readAgentsConfig(root)
284
333
  const wsSend = server.ws.send.bind(server.ws)
285
334
  const hotPool = new HotPoolManager({ root, config: hotPoolConfig, agentsConfig, wsSend })
286
335
  hotPool.start().catch((err) => {
@@ -759,6 +808,54 @@ export default function storyboardServer() {
759
808
  })
760
809
  }
761
810
 
811
+ // Auto-reload on Vite's "outdated optimize dep" 504 errors.
812
+ // Happens when the dep graph IDs in cached chunks no longer match
813
+ // what Vite is serving (after upgrades, dep additions, etc).
814
+ // We catch failed fetches inside the page and trigger a full reload
815
+ // once — the second load will see the freshly-built optimize deps.
816
+ if (isDev) {
817
+ tags.push({
818
+ tag: 'script',
819
+ children: `
820
+ (function(){
821
+ var reloaded = false;
822
+ function maybeReload(reason){
823
+ if (reloaded) return;
824
+ if (sessionStorage.getItem('__sb_outdated_reload__')) return;
825
+ reloaded = true;
826
+ sessionStorage.setItem('__sb_outdated_reload__', '1');
827
+ console.warn('[storyboard] Reloading: ' + reason);
828
+ setTimeout(function(){ sessionStorage.removeItem('__sb_outdated_reload__'); }, 5000);
829
+ location.reload();
830
+ }
831
+ // Clear stale guard from previous successful loads.
832
+ if (document.readyState === 'complete') {
833
+ sessionStorage.removeItem('__sb_outdated_reload__');
834
+ } else {
835
+ window.addEventListener('load', function(){
836
+ setTimeout(function(){ sessionStorage.removeItem('__sb_outdated_reload__'); }, 2000);
837
+ });
838
+ }
839
+ // Catch module load failures.
840
+ window.addEventListener('error', function(e){
841
+ var msg = (e && e.message) || '';
842
+ if (/Outdated Optimize Dep|Failed to fetch dynamically imported module|504/i.test(msg)) {
843
+ maybeReload('outdated dep / dynamic import failure');
844
+ }
845
+ }, true);
846
+ // Catch unhandled promise rejections from dynamic imports.
847
+ window.addEventListener('unhandledrejection', function(e){
848
+ var msg = (e && e.reason && (e.reason.message || String(e.reason))) || '';
849
+ if (/Outdated Optimize Dep|Failed to fetch dynamically imported module|504/i.test(msg)) {
850
+ maybeReload('outdated dep / dynamic import failure');
851
+ }
852
+ });
853
+ })();
854
+ `.trim(),
855
+ injectTo: 'head',
856
+ })
857
+ }
858
+
762
859
  // Inject base path so the inspector UI can resolve static assets
763
860
  // (e.g. inspector.json) when deployed under a subpath
764
861
  tags.push({
@@ -1412,7 +1412,7 @@ export default function StoryboardCommandPalette({ basePath }) {
1412
1412
  >
1413
1413
  <ItemIcon type={itemType} toolIcon={toolIcon} toolMeta={toolMeta} />
1414
1414
  <span style={{ display: 'flex', width: '100%', justifyContent: 'space-between', alignItems: 'center', gap: '8px' }}>
1415
- <span>{children}</span>
1415
+ <span style={{ flex: 1, minWidth: 0 }}>{children}</span>
1416
1416
  {tag && <span data-cmdk-item-tag="">{tag}</span>}
1417
1417
  </span>
1418
1418
  </Command.Item>
@@ -99,6 +99,7 @@ const STARRED_KEY = 'sb-workspace-starred'
99
99
  const RECENT_KEY = 'sb-workspace-recent'
100
100
  const MAX_RECENT = 30
101
101
  const GROUP_BY_FOLDERS_KEY = 'sb-workspace-group-folders'
102
+ const COLLAPSED_FOLDERS_KEY = 'sb-workspace-collapsed-folders'
102
103
 
103
104
  function readJSON(key, fallback) {
104
105
  try { return JSON.parse(localStorage.getItem(key)) || fallback }
@@ -471,7 +472,7 @@ function ArtifactCard({ item, basePath, starred, onToggleStar, onItemDeleted })
471
472
  <span className={css.cardBadge}>{getTypeLabel(item.type)}</span>
472
473
  <div className={css.cardActions}>
473
474
  <StarBtn active={starred} onClick={() => onToggleStar(item.id)} inline />
474
- {item.flows?.length > 0 && <FlowsDropdown flows={item.flows} basePath={basePath} />}
475
+ {item.flows?.length > 1 && <FlowsDropdown flows={item.flows} basePath={basePath} />}
475
476
  {item.pages?.length > 1 && <PagesDropdown pages={item.pages} basePath={basePath} />}
476
477
  {canEditDelete && (
477
478
  <CardActionsMenu
@@ -1240,7 +1241,13 @@ function WorkspaceImpl({
1240
1241
  const [groupByFolders, setGroupByFolders] = useState(() => {
1241
1242
  try { return localStorage.getItem(GROUP_BY_FOLDERS_KEY) !== 'false' } catch { return true }
1242
1243
  })
1243
- const [collapsedFolders, setCollapsedFolders] = useState(new Set())
1244
+ const [collapsedFolders, setCollapsedFolders] = useState(() => {
1245
+ try {
1246
+ const raw = localStorage.getItem(COLLAPSED_FOLDERS_KEY)
1247
+ const parsed = raw ? JSON.parse(raw) : []
1248
+ return new Set(Array.isArray(parsed) ? parsed : [])
1249
+ } catch { return new Set() }
1250
+ })
1244
1251
  const [hiddenItems, setHiddenItems] = useState(new Set())
1245
1252
  const { starred, toggle: toggleStar } = useStarred()
1246
1253
  const recentIds = useRecent()
@@ -1314,6 +1321,7 @@ function WorkspaceImpl({
1314
1321
  const next = new Set(prev)
1315
1322
  if (next.has(dirName)) next.delete(dirName)
1316
1323
  else next.add(dirName)
1324
+ try { localStorage.setItem(COLLAPSED_FOLDERS_KEY, JSON.stringify([...next])) } catch { /* empty */ }
1317
1325
  return next
1318
1326
  })
1319
1327
  }, [])
@@ -10,7 +10,7 @@ import { getFeatures, isResizable, isExpandable, getAnchorState, canAcceptConnec
10
10
  import { createPasteContext, resolvePaste } from './widgets/pasteRules.js'
11
11
  import { getPasteRules } from '../../core/index.js'
12
12
  import { isTerminalResizable, getTerminalDimensions } from '../../core/index.js'
13
- import { getFlag } from '../../core/index.js'
13
+ import { getFlag, subscribeToStorage } from '../../core/index.js'
14
14
  import { getCanvasZoom } from '../../core/index.js'
15
15
  import { registerSmoothCorners } from '../../core/utils/smoothCorners.js'
16
16
  import { registerHotPoolDevLogs } from './hotPoolDevLogs.js'
@@ -2115,21 +2115,42 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2115
2115
  // Controlled by the "canvas-auto-reload" feature flag (default: false = guard ON).
2116
2116
  // When the flag is true, the guard is skipped so canvas pages receive HMR updates.
2117
2117
  // Sends a heartbeat every 3s so the guard auto-expires if the tab closes.
2118
+ // Re-syncs when the flag is toggled at runtime (e.g. from devtools menu).
2118
2119
  useEffect(() => {
2119
2120
  if (!import.meta.hot) return
2120
- const autoReload = getFlag('canvas-auto-reload')
2121
- if (autoReload) return
2122
2121
 
2123
- const msg = { active: true }
2124
- import.meta.hot.send('storyboard:canvas-hmr-guard', msg)
2125
- const interval = setInterval(() => {
2122
+ let interval = null
2123
+
2124
+ function start() {
2125
+ if (interval) return
2126
+ const msg = { active: true }
2126
2127
  import.meta.hot.send('storyboard:canvas-hmr-guard', msg)
2127
- }, 3000)
2128
+ interval = setInterval(() => {
2129
+ import.meta.hot.send('storyboard:canvas-hmr-guard', msg)
2130
+ }, 3000)
2131
+ }
2128
2132
 
2129
- return () => {
2130
- clearInterval(interval)
2133
+ function stop() {
2134
+ if (interval) {
2135
+ clearInterval(interval)
2136
+ interval = null
2137
+ }
2131
2138
  import.meta.hot.send('storyboard:canvas-hmr-guard', { active: false })
2132
2139
  }
2140
+
2141
+ function sync() {
2142
+ if (getFlag('canvas-auto-reload')) stop()
2143
+ else start()
2144
+ }
2145
+
2146
+ sync()
2147
+
2148
+ const unsub = subscribeToStorage(() => sync())
2149
+
2150
+ return () => {
2151
+ stop()
2152
+ unsub()
2153
+ }
2133
2154
  }, [canvasId])
2134
2155
 
2135
2156
  // --- Selected widgets bridge ---
@@ -215,14 +215,13 @@ export function useWebGLSlot(widgetId, initialPriority) {
215
215
  [pool, widgetId],
216
216
  )
217
217
 
218
- // When there's no pool provider (e.g. standalone usage), always be live
219
- if (!pool || !slot) {
220
- return { isLive: true, generation: 0, setPriority: () => {} }
221
- }
222
-
218
+ // Gating disabled every widget is always live. The pool still tracks
219
+ // priority/registration for any future consumer (and so setPriority calls
220
+ // remain valid), but isLive ignores the cap and viewport demotion entirely.
221
+ void slot
223
222
  return {
224
- isLive: slot.live,
225
- generation: slot.generation,
223
+ isLive: true,
224
+ generation: 0,
226
225
  setPriority,
227
226
  }
228
227
  }
@@ -68,15 +68,14 @@ const colorMode = theme.startsWith('dark') ? 'night' : 'day'
68
68
  document.documentElement.setAttribute('data-color-mode', theme.startsWith('dark') ? 'dark' : 'light')
69
69
  document.documentElement.setAttribute('data-dark-theme', theme.startsWith('dark') ? theme : '')
70
70
  document.documentElement.setAttribute('data-light-theme', theme.startsWith('dark') ? '' : theme || 'light')
71
+ document.documentElement.setAttribute('data-sb-theme', theme || 'light')
71
72
 
72
- // Suppress HMR full-reloads this iframe is embedded inside a canvas page
73
- // that manages its own reload lifecycle. Without this guard, every file change
74
- // causes the iframe to flash/reload.
75
- if (import.meta.hot) {
76
- const msg = { active: true }
77
- import.meta.hot.send('storyboard:canvas-hmr-guard', msg)
78
- setInterval(() => import.meta.hot.send('storyboard:canvas-hmr-guard', msg), 3000)
79
- }
73
+ // Note: we deliberately do NOT install the canvas-hmr-guard here. The guard
74
+ // drops BOTH `full-reload` and `update` payloads server-side, which means
75
+ // edits to the story source file never reach this iframe no Fast Refresh,
76
+ // no reload, nothing. Story widgets are expected to refresh when the
77
+ // underlying .story.jsx is edited, so we let HMR through.
78
+ void import.meta.hot
80
79
 
81
80
  const root = createRoot(document.getElementById('root'))
82
81
 
@@ -240,15 +240,14 @@ const colorMode = theme.startsWith('dark') ? 'night' : 'day'
240
240
  document.documentElement.setAttribute('data-color-mode', theme.startsWith('dark') ? 'dark' : 'light')
241
241
  document.documentElement.setAttribute('data-dark-theme', theme.startsWith('dark') ? theme : '')
242
242
  document.documentElement.setAttribute('data-light-theme', theme.startsWith('dark') ? '' : theme || 'light')
243
+ document.documentElement.setAttribute('data-sb-theme', theme || 'light')
243
244
 
244
- // Suppress HMR full-reloads this iframe is embedded inside a canvas page
245
- // that manages its own reload lifecycle. Without this guard, every file change
246
- // causes the iframe to flash/reload.
247
- if (import.meta.hot) {
248
- const msg = { active: true }
249
- import.meta.hot.send('storyboard:canvas-hmr-guard', msg)
250
- setInterval(() => import.meta.hot.send('storyboard:canvas-hmr-guard', msg), 3000)
251
- }
245
+ // Note: we deliberately do NOT install the canvas-hmr-guard here. The guard
246
+ // drops BOTH `full-reload` and `update` payloads server-side, which means
247
+ // edits to the story source file never reach this iframe no Fast Refresh,
248
+ // no reload, nothing. Story/component-set widgets are expected to refresh
249
+ // when the underlying .story.jsx is edited, so we let HMR through.
250
+ void import.meta.hot
252
251
 
253
252
  const root = createRoot(document.getElementById('root'))
254
253
 
@@ -43,6 +43,8 @@ function resolveCanvasThemeFromStorage() {
43
43
  window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
44
44
  }
45
45
 
46
+ const HEADER_HEIGHT = 37
47
+
46
48
  export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdate, resizable }, ref) {
47
49
  const src = readProp(props, 'src', prototypeEmbedSchema)
48
50
  const width = readProp(props, 'width', prototypeEmbedSchema) || 800
@@ -412,7 +414,7 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
412
414
  className={styles.iframe}
413
415
  style={{
414
416
  width: width / scale,
415
- height: height / scale,
417
+ height: (height - HEADER_HEIGHT) / scale,
416
418
  transform: `scale(${scale})`,
417
419
  transformOrigin: '0 0',
418
420
  }}
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import { forwardRef, useImperativeHandle, useRef, useCallback, useState, useEffect, useMemo } from 'react'
14
14
  import { getStoryData } from '../../../core/index.js'
15
+ import { useThemeState, useThemeSyncTargets } from '../../hooks/useThemeState.js'
15
16
  import Icon from '../../Icon.jsx'
16
17
  import WidgetWrapper from './WidgetWrapper.jsx'
17
18
  import ResizeHandle from './ResizeHandle.jsx'
@@ -26,16 +27,23 @@ function GridIcon({ size = 16 }) {
26
27
  return <Icon name="iconoir/view-grid" size={size} />
27
28
  }
28
29
 
29
- function resolveStorySetUrl(storyId, layout, selected, density) {
30
+ function resolveStorySetUrl(storyId, layout, selected, density, theme) {
30
31
  const story = getStoryData(storyId)
31
32
  if (!story?._storyModule) return ''
32
33
  const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
33
34
  const params = new URLSearchParams()
34
- params.set('module', story._storyModule)
35
+ // Route via the real story page (works in dev AND prod). The dev-only
36
+ // `_storyboard/canvas/isolate-set` middleware doesn't exist in deployed
37
+ // builds, so we mount ComponentSetPage at the story's route with
38
+ // `_sb_component_set` instead. `_sb_embed` keeps the canvas chrome off.
39
+ params.set('_sb_embed', '')
40
+ params.set('_sb_component_set', '')
35
41
  if (layout) params.set('layout', layout)
36
42
  if (selected) params.set('selected', selected)
37
43
  if (density) params.set('density', density)
38
- return `${base}/_storyboard/canvas/isolate-set?${params}`
44
+ if (theme) params.set('theme', theme)
45
+ const route = story._route || `/components/${storyId}`
46
+ return `${base}${route}?${params}`
39
47
  }
40
48
 
41
49
  export default forwardRef(function StorySetWidget({ id: widgetId, props, onUpdate, resizable }, ref) {
@@ -101,7 +109,7 @@ export default forwardRef(function StorySetWidget({ id: widgetId, props, onUpdat
101
109
  if (typeof width === 'number' && typeof height === 'number') return
102
110
  const headerH = 37
103
111
  const newW = typeof width === 'number' ? width : Math.max(200, Math.ceil(e.data.width))
104
- const newH = typeof height === 'number' ? height : Math.max(120, Math.ceil(e.data.height) + headerH)
112
+ const newH = typeof height === 'number' ? height : Math.max(120, Math.ceil(e.data.height) + headerH + 8)
105
113
  onUpdate?.({ width: newW, height: newH })
106
114
  } else if (e.data?.type === 'storyboard:component-set:content-size') {
107
115
  contentSizeRef.current = {
@@ -126,7 +134,7 @@ export default forwardRef(function StorySetWidget({ id: widgetId, props, onUpdat
126
134
  const contentH = contentSizeRef.current.height
127
135
  const contentW = contentSizeRef.current.width
128
136
  if (!contentH && !contentW) return
129
- const fitH = contentH ? contentH + headerH : h
137
+ const fitH = contentH ? contentH + headerH + 8 : h
130
138
  const fitW = contentW || w
131
139
  const shouldSnapH = contentH && h > fitH + 2
132
140
  const shouldSnapW = contentW && w > fitW + 2
@@ -163,11 +171,15 @@ export default forwardRef(function StorySetWidget({ id: widgetId, props, onUpdat
163
171
  },
164
172
  }), [storyId, layout, onUpdate, setExpanded])
165
173
 
174
+ const { resolved: resolvedTheme } = useThemeState() || {}
175
+ const { prototype: prototypeSync } = useThemeSyncTargets() || {}
176
+ const effectiveTheme = prototypeSync ? (resolvedTheme || 'light') : 'light'
177
+
166
178
  const iframeSrc = useMemo(
167
- () => resolveStorySetUrl(storyId, layout, selected, density),
179
+ () => resolveStorySetUrl(storyId, layout, selected, density, effectiveTheme),
168
180
  // storyIndexKey forces re-evaluation when HMR mutates the story index
169
181
  // eslint-disable-next-line react-hooks/exhaustive-deps
170
- [storyId, layout, selected, density, storyIndexKey],
182
+ [storyId, layout, selected, density, storyIndexKey, effectiveTheme],
171
183
  )
172
184
 
173
185
  useIframeDevLogs({
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import { forwardRef, useImperativeHandle, useRef, useCallback, useState, useEffect, useMemo } from 'react'
12
12
  import { getStoryData } from '../../../core/index.js'
13
+ import { useThemeState, useThemeSyncTargets } from '../../hooks/useThemeState.js'
13
14
  import { getConfig } from '../../../core/stores/configStore.js'
14
15
  import { createInspectorHighlighter } from '../../../core/inspector/highlighter.js'
15
16
  import Icon from '../../Icon.jsx'
@@ -42,13 +43,14 @@ function isInlineStoriesEnabled() {
42
43
  } catch { return false }
43
44
  }
44
45
 
45
- function resolveStoryUrl(storyId, exportName) {
46
+ function resolveStoryUrl(storyId, exportName, theme) {
46
47
  const story = getStoryData(storyId)
47
48
  if (!story?._storyModule) return ''
48
49
  const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
49
50
  const params = new URLSearchParams()
50
51
  params.set('module', story._storyModule)
51
52
  if (exportName) params.set('export', exportName)
53
+ if (theme) params.set('theme', theme)
52
54
  return `${base}/_storyboard/canvas/isolate?${params}`
53
55
  }
54
56
 
@@ -194,9 +196,13 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
194
196
  },
195
197
  }), [storyId, showCode, toggleShowCode, copyCode, setExpandMode])
196
198
 
199
+ const { resolved: resolvedTheme } = useThemeState() || {}
200
+ const { prototype: prototypeSync } = useThemeSyncTargets() || {}
201
+ const effectiveTheme = prototypeSync ? (resolvedTheme || 'light') : 'light'
202
+
197
203
  const iframeSrc = useMemo(
198
- () => resolveStoryUrl(storyId, exportName),
199
- [storyId, exportName, storyIndexKey],
204
+ () => resolveStoryUrl(storyId, exportName, effectiveTheme),
205
+ [storyId, exportName, storyIndexKey, effectiveTheme],
200
206
  )
201
207
 
202
208
  const inlineEnabled = isInlineStoriesEnabled()
@@ -180,14 +180,34 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, multiSe
180
180
  const [waking, setWaking] = useState(false)
181
181
  const [resourceLimited, setResourceLimited] = useState(null)
182
182
  const [showDragHint, setShowDragHint] = useState(false)
183
+ // Set when the browser refuses to grant a WebGL context (hard limit ~8–16
184
+ // contexts depending on browser) or when an existing context is lost.
185
+ // Flips the widget back to the frozen overlay so it degrades gracefully
186
+ // instead of rendering as a broken/blank canvas.
187
+ const [webglUnavailable, setWebglUnavailable] = useState(false)
183
188
  const expandContainerRef = useRef(null)
184
189
  const dragHintTimer = useRef(null)
185
190
 
186
191
  // ── WebGL context pool integration ──
187
- // webglReady: PINNED (bypass cap, guaranteed live no frozen flash)
188
- // All others: VISIBLE (auto-requests a live slotno manual click needed)
189
- const initialPriority = props?.webglReady ? Priority.PINNED : Priority.VISIBLE
190
- const { isLive, generation, setPriority } = useWebGLSlot(id, initialPriority)
192
+ // All freshly-mounted terminals start at PINNED so they bypass the pool
193
+ // cap and come up live immediatelyotherwise a new spawn loses the
194
+ // tiebreak against existing live widgets (stable sort, equal lastVisible)
195
+ // and renders the frozen "Click to resume" overlay even though tmux is
196
+ // running. After SPAWN_GRACE_MS the priority is handed back to
197
+ // usePoolVisibilityUpdater (CanvasPage) which manages VISIBLE/NEAR/OFFSCREEN
198
+ // based on viewport overlap.
199
+ const SPAWN_GRACE_MS = 5000
200
+ const { isLive, generation, setPriority } = useWebGLSlot(id, Priority.PINNED)
201
+
202
+ // Release the spawn-grace pin after the window expires, unless the
203
+ // widget is currently expanded/interactive (those keep it PINNED).
204
+ useEffect(() => {
205
+ const t = setTimeout(() => {
206
+ if (!expanded && !interactive) setPriority(Priority.VISIBLE)
207
+ }, SPAWN_GRACE_MS)
208
+ return () => clearTimeout(t)
209
+ // eslint-disable-next-line react-hooks/exhaustive-deps
210
+ }, [])
191
211
 
192
212
  // Update pool priority based on widget state
193
213
  useEffect(() => {
@@ -199,6 +219,10 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, multiSe
199
219
 
200
220
  // Request activation when user clicks a frozen terminal
201
221
  const handleFrozenActivate = useCallback(() => {
222
+ // Clear any prior browser-WebGL-exhaustion flag and retry — the user
223
+ // may have closed other widgets/tabs since the original failure.
224
+ setWebglUnavailable(false)
225
+ setConnectAttempt((n) => n + 1)
202
226
  setPriority(Priority.PINNED)
203
227
  }, [setPriority])
204
228
 
@@ -269,6 +293,7 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, multiSe
269
293
  // Connect terminal + WebSocket (only when pool grants a live slot)
270
294
  useEffect(() => {
271
295
  if (!isLive) return
296
+ if (webglUnavailable) return
272
297
  if (!containerRef.current) return
273
298
 
274
299
  let disposed = false
@@ -309,7 +334,43 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, multiSe
309
334
  theme: { ...DEFAULT_THEME, ...cfg.theme },
310
335
  })
311
336
 
312
- term.open(containerRef.current)
337
+ try {
338
+ term.open(containerRef.current)
339
+ } catch (openErr) {
340
+ // Most commonly the browser refusing to allocate yet another
341
+ // WebGL context (hard cap ~8–16). Degrade to the frozen overlay
342
+ // instead of leaving a blank canvas behind.
343
+ console.warn('[TerminalWidget] ghostty.open failed — falling back to frozen overlay:', openErr)
344
+ try { term.dispose?.() } catch { /* empty */ }
345
+ term = null
346
+ termRef.current = null
347
+ if (!disposed) setWebglUnavailable(true)
348
+ return
349
+ }
350
+
351
+ // If ghostty silently failed to obtain a WebGL renderer, treat
352
+ // the same as a browser-cap miss and show the frozen overlay.
353
+ if (!term.renderer) {
354
+ console.warn('[TerminalWidget] ghostty has no renderer (likely WebGL exhausted) — frozen fallback')
355
+ try { term.dispose?.() } catch { /* empty */ }
356
+ term = null
357
+ termRef.current = null
358
+ if (!disposed) setWebglUnavailable(true)
359
+ return
360
+ }
361
+
362
+ // Listen for the browser killing this WebGL context later (it
363
+ // does this LRU-style when other tabs/widgets need a slot).
364
+ const canvas = containerRef.current?.querySelector('canvas')
365
+ if (canvas) {
366
+ const onLost = (e) => {
367
+ e.preventDefault?.()
368
+ console.warn('[TerminalWidget] WebGL context lost — frozen fallback')
369
+ if (!disposed) setWebglUnavailable(true)
370
+ }
371
+ canvas.addEventListener('webglcontextlost', onLost, { once: true })
372
+ }
373
+
313
374
  termRef.current = term
314
375
 
315
376
  // Expose ghostty's actual computed cell metrics as CSS variables
@@ -411,7 +472,7 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, multiSe
411
472
  setReady(false)
412
473
  setRevealed(false)
413
474
  }
414
- }, [id, isLive, generation, connectAttempt])
475
+ }, [id, isLive, generation, connectAttempt, webglUnavailable])
415
476
 
416
477
  // Resize terminal on dimension changes
417
478
  useEffect(() => {
@@ -604,19 +665,19 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, multiSe
604
665
  ref={terminalRef}
605
666
  className={styles.terminal}
606
667
  style={{
607
- ...(typeof (isLive ? (snappedWidth ?? width) : width) === 'number'
608
- ? { width: `${isLive ? (snappedWidth ?? width) : width}px` }
668
+ ...(typeof ((isLive && !webglUnavailable) ? (snappedWidth ?? width) : width) === 'number'
669
+ ? { width: `${(isLive && !webglUnavailable) ? (snappedWidth ?? width) : width}px` }
609
670
  : undefined),
610
- ...(typeof (isLive ? (snappedHeight ?? height) : height) === 'number'
611
- ? { height: `${isLive ? (snappedHeight ?? height) : height}px` }
671
+ ...(typeof ((isLive && !webglUnavailable) ? (snappedHeight ?? height) : height) === 'number'
672
+ ? { height: `${(isLive && !webglUnavailable) ? (snappedHeight ?? height) : height}px` }
612
673
  : undefined),
613
674
  }}
614
675
  onClick={handleClick}
615
676
  onPointerDown={handleTerminalPointerDown}
616
677
  onKeyDown={interactive ? (e) => e.stopPropagation() : undefined}
617
678
  >
618
- {/* ── Frozen state: WebGL context released, show snapshot ── */}
619
- {!isLive && (
679
+ {/* ── Frozen state: WebGL context released or unavailable ── */}
680
+ {(!isLive || webglUnavailable) && (
620
681
  <FrozenTerminalOverlay
621
682
  widgetId={id}
622
683
  onActivate={handleFrozenActivate}
@@ -624,7 +685,7 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, multiSe
624
685
  )}
625
686
 
626
687
  {/* ── Live state: ghostty WebGL terminal ── */}
627
- {isLive && (
688
+ {isLive && !webglUnavailable && (
628
689
  <>
629
690
  {showDragHint && (
630
691
  <div className={styles.dragHint}>
@@ -513,12 +513,14 @@ export function buildSecondaryIframeUrl(widget) {
513
513
  const storyData = getStoryData(storyId)
514
514
  if (storyData?._storyModule) {
515
515
  const params = new URLSearchParams()
516
- params.set('module', storyData._storyModule)
516
+ params.set('_sb_embed', '')
517
+ params.set('_sb_component_set', '')
517
518
  const layout = widget.props?.layout
518
519
  if (layout) params.set('layout', layout)
519
520
  const selected = widget.props?.selected
520
521
  if (selected) params.set('selected', selected)
521
- return `${baseClean}/_storyboard/canvas/isolate-set?${params}`
522
+ const route = storyData._route || `/components/${storyId}`
523
+ return `${baseClean}${route}?${params}`
522
524
  }
523
525
  return null
524
526
  }