@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.
- package/dist/storyboard-ui.js +3112 -3098
- package/dist/storyboard-ui.js.map +1 -1
- package/mascot/frame-01-peek-left.txt +4 -0
- package/mascot/frame-02-eyes-open.txt +4 -0
- package/mascot/frame-03-peek-right.txt +4 -0
- package/mascot/frame-04-eyes-open.txt +4 -0
- package/mascot/frame-05-eyes-closed.txt +4 -0
- package/mascot/frame-06-eyes-open.txt +4 -0
- package/mascot.config.json +13 -0
- package/package.json +5 -2
- package/scaffold/AGENTS.md +1 -0
- package/scaffold/gitignore +12 -2
- package/scaffold/skills/design-system-catalog/SKILL.md +98 -0
- package/scaffold/skills/design-system-catalog/extract-components.mjs +441 -0
- package/scaffold/skills/design-system-catalog/generate-catalog.sh +255 -0
- package/scaffold/skills/migrate/SKILL.md +72 -50
- package/scaffold/terminal-agent.agent.md +8 -1
- package/src/core/canvas/agent-session.js +103 -17
- package/src/core/canvas/agent-session.test.js +29 -1
- package/src/core/canvas/collision.js +54 -45
- package/src/core/canvas/collision.test.js +39 -0
- package/src/core/canvas/configReader.js +110 -0
- package/src/core/canvas/hot-pool.js +5 -3
- package/src/core/canvas/server.js +32 -13
- package/src/core/canvas/terminal-server.js +156 -91
- package/src/core/cli/agent.js +86 -33
- package/src/core/cli/dev.js +303 -17
- package/src/core/cli/server.js +1 -1
- package/src/core/cli/setup.js +203 -60
- package/src/core/cli/terminal-welcome.js +5 -6
- package/src/core/cli/userState.js +63 -0
- package/src/core/stores/configSchema.js +1 -0
- package/src/core/stores/themeStore.ts +24 -0
- package/src/core/tools/handlers/devtools.test.js +1 -1
- package/src/core/vite/server-plugin.js +107 -10
- package/src/internals/CommandPalette/CommandPalette.jsx +1 -1
- package/src/internals/Viewfinder.jsx +10 -2
- package/src/internals/canvas/CanvasPage.jsx +30 -9
- package/src/internals/canvas/WebGLContextPool.jsx +6 -7
- package/src/internals/canvas/componentIsolate.jsx +7 -8
- package/src/internals/canvas/componentSetIsolate.jsx +7 -8
- package/src/internals/canvas/widgets/PrototypeEmbed.jsx +3 -1
- package/src/internals/canvas/widgets/StorySetWidget.jsx +19 -7
- package/src/internals/canvas/widgets/StoryWidget.jsx +9 -3
- package/src/internals/canvas/widgets/TerminalWidget.jsx +74 -13
- package/src/internals/canvas/widgets/expandUtils.js +4 -2
- package/src/internals/hooks/usePrototypeReloadGuard.js +9 -5
- package/src/internals/vite/data-plugin.js +126 -3
- 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:
|
|
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
|
|
229
|
+
function isCanvasClientGuarded(client) {
|
|
197
230
|
const cu = canvasGuardedClients.get(client)
|
|
198
|
-
|
|
231
|
+
return cu != null && Date.now() < cu
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function isPrototypeClientGuarded(client) {
|
|
199
235
|
const pu = prototypeGuardedClients.get(client)
|
|
200
|
-
|
|
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
|
-
//
|
|
221
|
-
|
|
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 (!
|
|
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 =
|
|
283
|
-
const agentsConfig =
|
|
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 >
|
|
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(
|
|
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
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
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
|
-
|
|
2128
|
+
interval = setInterval(() => {
|
|
2129
|
+
import.meta.hot.send('storyboard:canvas-hmr-guard', msg)
|
|
2130
|
+
}, 3000)
|
|
2131
|
+
}
|
|
2128
2132
|
|
|
2129
|
-
|
|
2130
|
-
|
|
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
|
-
//
|
|
219
|
-
|
|
220
|
-
|
|
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:
|
|
225
|
-
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
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
//
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
188
|
-
//
|
|
189
|
-
|
|
190
|
-
|
|
192
|
+
// All freshly-mounted terminals start at PINNED so they bypass the pool
|
|
193
|
+
// cap and come up live immediately — otherwise 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
|
-
|
|
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
|
|
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('
|
|
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
|
-
|
|
522
|
+
const route = storyData._route || `/components/${storyId}`
|
|
523
|
+
return `${baseClean}${route}?${params}`
|
|
522
524
|
}
|
|
523
525
|
return null
|
|
524
526
|
}
|