@dfosco/storyboard 0.8.1 → 0.9.0
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.css +1 -1
- package/dist/storyboard-ui.js +6420 -6336
- package/dist/storyboard-ui.js.map +1 -1
- package/dist/tailwind.css +1 -1
- package/dist/tiny-canvas.css +1 -1
- package/package.json +1 -1
- package/scaffold/src/library/_app.jsx +2 -14
- package/scaffold/src/library/_app.module.css +2 -8
- package/scaffold/src/library/mount.jsx +0 -1
- package/src/canvas/style.css +16 -16
- package/src/core/artifact/operations.js +35 -2
- package/src/core/artifact/schemas/canvas.schema.json +7 -1
- package/src/core/canvas/hot-pool.js +11 -22
- package/src/core/canvas/selectedWidgets.js +1 -1
- package/src/core/canvas/server.js +52 -31
- package/src/core/canvas/server.test.js +107 -0
- package/src/core/cli/artifact.js +2 -0
- package/src/core/cli/create.js +35 -5
- package/src/core/cli/schemas.js +6 -1
- package/src/core/comments/ui/comment-layout.css +14 -14
- package/src/core/index.js +18 -0
- package/src/core/inspector/highlighter.js +1 -1
- package/src/core/inspector/mouseMode.js +2 -2
- package/src/core/lib/components/ui/dialog/dialog.module.css +8 -8
- package/src/core/modes/modes.css +4 -4
- package/src/core/mountStoryboardCore.js +53 -9
- package/src/core/mountStoryboardCore.test.js +25 -1
- package/src/core/rename-watcher/config.json +1 -1
- package/src/core/rename-watcher/watcher.js +1 -1
- package/src/core/stores/canvasInteractionStore.js +120 -0
- package/src/core/stores/canvasInteractionStore.test.js +126 -0
- package/src/core/stores/configSchema.js +23 -6
- package/src/core/stores/configSchema.test.js +3 -0
- package/src/core/stores/customerModeConfig.js +24 -3
- package/src/core/stores/customerModeConfig.test.js +46 -0
- package/src/core/stores/presentationStore.js +118 -0
- package/src/core/stores/presentationStore.test.js +114 -0
- package/src/core/stores/uiConfig.js +10 -2
- package/src/core/styles/tailwind.css +138 -90
- package/src/core/styles/workspace-chrome.css +4 -21
- package/src/core/ui/AutosyncMenuButton.css +7 -7
- package/src/core/ui/BranchSelect.module.css +5 -5
- package/src/core/ui/ChromeSlot.jsx +92 -0
- package/src/core/ui/ChromeSlot.test.jsx +140 -0
- package/src/core/ui/CoreUIBar.css +5 -5
- package/src/core/ui/CoreUIBar.jsx +161 -136
- package/src/core/ui/InspectorPanel.css +5 -5
- package/src/core/ui/InspectorPanel.jsx +9 -9
- package/src/core/ui/PwaInstallBanner.css +5 -5
- package/src/core/ui/PwaInstallBanner.jsx +5 -5
- package/src/core/ui/core-ui-colors.css +28 -66
- package/src/core/ui/sidepanel.css +12 -12
- package/src/core/workshop/features/createCanvas/CreateCanvasForm.jsx +102 -13
- package/src/internals/ArtifactForm/ArtifactForm.module.css +7 -7
- package/src/internals/AuthModal/AuthModal.module.css +34 -34
- package/src/internals/BaseUiForm/BaseUiForm.module.css +85 -85
- package/src/internals/BranchBar/BranchBar.module.css +4 -4
- package/src/internals/BranchesDropdown/BranchesDropdown.module.css +9 -9
- package/src/internals/CommandPalette/CommandPalette.jsx +16 -10
- package/src/internals/CommandPalette/WidgetArtifactDialog.css +16 -16
- package/src/internals/CommandPalette/command-palette.css +14 -14
- package/src/internals/CreatePage/CreatePage.module.css +1 -1
- package/src/internals/FlowError.module.css +5 -5
- package/src/internals/PrototypeErrorBoundary.module.css +15 -15
- package/src/internals/SimpleWorkspace/SimpleWorkspace.jsx +5 -4
- package/src/internals/SimpleWorkspace/SimpleWorkspace.module.css +38 -38
- package/src/internals/Viewfinder.jsx +33 -16
- package/src/internals/Viewfinder.module.css +189 -189
- package/src/internals/canvas/CanvasControls.module.css +11 -11
- package/src/internals/canvas/CanvasPage.bridge.test.jsx +11 -37
- package/src/internals/canvas/CanvasPage.jsx +91 -93
- package/src/internals/canvas/CanvasPage.module.css +20 -19
- package/src/internals/canvas/CanvasToolbar.module.css +11 -11
- package/src/internals/canvas/ConnectorLayer.jsx +39 -2
- package/src/internals/canvas/ConnectorLayer.module.css +10 -10
- package/src/internals/canvas/PageSelector.module.css +28 -28
- package/src/internals/canvas/canvasTheme.js +22 -111
- package/src/internals/canvas/isolation/prototypeRoutes.jsx +12 -3
- package/src/internals/canvas/isolation/storyRoutes.jsx +1 -1
- package/src/internals/canvas/widgets/CodePenEmbed.jsx +16 -36
- package/src/internals/canvas/widgets/CodePenEmbed.module.css +12 -12
- package/src/internals/canvas/widgets/CropOverlay.module.css +23 -23
- package/src/internals/canvas/widgets/ExpandedPane.module.css +8 -8
- package/src/internals/canvas/widgets/FigmaEmbed.jsx +15 -36
- package/src/internals/canvas/widgets/FigmaEmbed.module.css +15 -15
- package/src/internals/canvas/widgets/FrozenTerminalOverlay.module.css +5 -5
- package/src/internals/canvas/widgets/ImageWidget.module.css +3 -3
- package/src/internals/canvas/widgets/LinkPreview.module.css +77 -78
- package/src/internals/canvas/widgets/MarkdownBlock.module.css +31 -31
- package/src/internals/canvas/widgets/PromptWidget.jsx +2 -16
- package/src/internals/canvas/widgets/PromptWidget.module.css +33 -33
- package/src/internals/canvas/widgets/PrototypeEmbed.jsx +23 -7
- package/src/internals/canvas/widgets/PrototypeEmbed.module.css +58 -56
- package/src/internals/canvas/widgets/PrototypeEmbed.test.jsx +4 -4
- package/src/internals/canvas/widgets/ResizeHandle.module.css +4 -4
- package/src/internals/canvas/widgets/StorySetWidget.jsx +45 -21
- package/src/internals/canvas/widgets/StoryWidget.jsx +53 -21
- package/src/internals/canvas/widgets/StoryWidget.module.css +22 -20
- package/src/internals/canvas/widgets/TerminalReadWidget.module.css +4 -4
- package/src/internals/canvas/widgets/TerminalWidget.jsx +56 -45
- package/src/internals/canvas/widgets/TerminalWidget.module.css +10 -10
- package/src/internals/canvas/widgets/TilesWidget.module.css +17 -17
- package/src/internals/canvas/widgets/WidgetChrome.module.css +61 -61
- package/src/internals/canvas/widgets/WidgetWrapper.module.css +2 -2
- package/src/internals/canvas/widgets/embedOverlay.module.css +14 -4
- package/src/internals/canvas/widgets/embedTheme.js +10 -102
- package/src/internals/canvas/widgets/widgetConfig.js +5 -5
- package/src/internals/story/ComponentSetPage.module.css +14 -14
- package/src/internals/story/StoryPage.module.css +2 -2
- package/src/internals/vite/data-plugin.js +5 -2
- package/src/internals/canvas/WebGLContextPool.jsx +0 -291
- package/src/internals/canvas/WebGLContextPool.test.jsx +0 -165
- package/src/internals/canvas/widgets/ComponentSetWidget.module.css +0 -89
- package/src/internals/canvas/widgets/StorySetWidget.module.css +0 -89
|
@@ -1,291 +0,0 @@
|
|
|
1
|
-
import { createContext, useContext, useCallback, useEffect, useMemo, useSyncExternalStore } from 'react'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* WebGL Context Pool — manages live WebGL slots for terminal widgets.
|
|
5
|
-
*
|
|
6
|
-
* Browsers cap WebGL contexts at ~8-16. This pool ensures only a subset
|
|
7
|
-
* of terminal widgets hold live ghostty-web renderers at any time.
|
|
8
|
-
* Offscreen terminals release their context and show a frozen snapshot.
|
|
9
|
-
*
|
|
10
|
-
* Architecture:
|
|
11
|
-
* - Lease-based: widgets register and receive live/frozen status
|
|
12
|
-
* - Priority: PINNED > VISIBLE > NEAR_VIEWPORT > OFFSCREEN
|
|
13
|
-
* - Hysteresis: 3s grace before evicting recently-visible terminals
|
|
14
|
-
* - Generation tokens prevent stale async opens after eviction
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
// Priority levels (higher = more important to keep live)
|
|
18
|
-
export const Priority = {
|
|
19
|
-
OFFSCREEN: 0,
|
|
20
|
-
NEAR_VIEWPORT: 1,
|
|
21
|
-
VISIBLE: 2,
|
|
22
|
-
PINNED: 3, // expanded, focused, or actively interacting
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const DEFAULT_MAX_LIVE = 6
|
|
26
|
-
const HYSTERESIS_MS = 3000
|
|
27
|
-
|
|
28
|
-
// ── Pool Engine (framework-agnostic) ─────────────────────────────────
|
|
29
|
-
|
|
30
|
-
class ContextPool {
|
|
31
|
-
constructor(maxLive = DEFAULT_MAX_LIVE) {
|
|
32
|
-
this._maxLive = maxLive
|
|
33
|
-
/** @type {Map<string, { priority: number, generation: number, live: boolean, lastVisible: number }>} */
|
|
34
|
-
this._slots = new Map()
|
|
35
|
-
this._listeners = new Set()
|
|
36
|
-
this._hysteresisTimers = new Map()
|
|
37
|
-
// Monotonic version counter — bumped on every state change so
|
|
38
|
-
// useSyncExternalStore detects updates via a new snapshot value.
|
|
39
|
-
this._version = 0
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** Subscribe to pool state changes. Returns unsubscribe fn. */
|
|
43
|
-
subscribe(listener) {
|
|
44
|
-
this._listeners.add(listener)
|
|
45
|
-
return () => this._listeners.delete(listener)
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
_notify() {
|
|
49
|
-
this._version++
|
|
50
|
-
for (const fn of this._listeners) fn()
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Register a widget. Returns its initial generation. */
|
|
54
|
-
register(widgetId, initialPriority = Priority.OFFSCREEN) {
|
|
55
|
-
if (this._slots.has(widgetId)) return this._slots.get(widgetId).generation
|
|
56
|
-
this._slots.set(widgetId, {
|
|
57
|
-
priority: initialPriority,
|
|
58
|
-
generation: 0,
|
|
59
|
-
live: false,
|
|
60
|
-
lastVisible: initialPriority >= Priority.VISIBLE ? Date.now() : 0,
|
|
61
|
-
})
|
|
62
|
-
this._recompute()
|
|
63
|
-
return 0
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/** Unregister a widget (on unmount). */
|
|
67
|
-
unregister(widgetId) {
|
|
68
|
-
this._slots.delete(widgetId)
|
|
69
|
-
const timer = this._hysteresisTimers.get(widgetId)
|
|
70
|
-
if (timer) { clearTimeout(timer); this._hysteresisTimers.delete(widgetId) }
|
|
71
|
-
this._recompute()
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** Update a widget's priority. Triggers recomputation. */
|
|
75
|
-
setPriority(widgetId, priority) {
|
|
76
|
-
const slot = this._slots.get(widgetId)
|
|
77
|
-
if (!slot) return
|
|
78
|
-
const prev = slot.priority
|
|
79
|
-
slot.priority = priority
|
|
80
|
-
|
|
81
|
-
// Track when widget was last visible/pinned for hysteresis
|
|
82
|
-
if (priority >= Priority.VISIBLE) {
|
|
83
|
-
slot.lastVisible = Date.now()
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Immediate recompute when priority increases or widget is pinned
|
|
87
|
-
if (priority > prev || priority === Priority.PINNED) {
|
|
88
|
-
this._recompute()
|
|
89
|
-
return
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// When priority drops (e.g. scrolled offscreen), use hysteresis
|
|
93
|
-
if (priority < prev && prev >= Priority.VISIBLE) {
|
|
94
|
-
const existing = this._hysteresisTimers.get(widgetId)
|
|
95
|
-
if (existing) clearTimeout(existing)
|
|
96
|
-
this._hysteresisTimers.set(widgetId, setTimeout(() => {
|
|
97
|
-
this._hysteresisTimers.delete(widgetId)
|
|
98
|
-
this._recompute()
|
|
99
|
-
}, HYSTERESIS_MS))
|
|
100
|
-
return
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
this._recompute()
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/** Get current state for a widget. */
|
|
107
|
-
getSlot(widgetId) {
|
|
108
|
-
return this._slots.get(widgetId) || null
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Recompute which widgets should be live.
|
|
113
|
-
* Sorts by priority (desc), then by lastVisible (desc).
|
|
114
|
-
* Top N get live status; rest are frozen.
|
|
115
|
-
*/
|
|
116
|
-
_recompute() {
|
|
117
|
-
const entries = [...this._slots.entries()]
|
|
118
|
-
|
|
119
|
-
// Sort: pinned first, then by priority desc, then by recency
|
|
120
|
-
entries.sort(([, a], [, b]) => {
|
|
121
|
-
if (a.priority !== b.priority) return b.priority - a.priority
|
|
122
|
-
return b.lastVisible - a.lastVisible
|
|
123
|
-
})
|
|
124
|
-
|
|
125
|
-
let liveCount = 0
|
|
126
|
-
const changes = []
|
|
127
|
-
|
|
128
|
-
for (const [id, slot] of entries) {
|
|
129
|
-
// Pinned terminals always get a slot (they bypass the cap)
|
|
130
|
-
const shouldBeLive = slot.priority === Priority.PINNED || liveCount < this._maxLive
|
|
131
|
-
if (shouldBeLive) liveCount++
|
|
132
|
-
|
|
133
|
-
if (slot.live !== shouldBeLive) {
|
|
134
|
-
changes.push({ id, slot, shouldBeLive })
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
if (changes.length === 0) return
|
|
139
|
-
|
|
140
|
-
for (const { slot, shouldBeLive } of changes) {
|
|
141
|
-
slot.live = shouldBeLive
|
|
142
|
-
if (!shouldBeLive) {
|
|
143
|
-
// Bump generation so stale async opens abort
|
|
144
|
-
slot.generation++
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
this._notify()
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// ── React Context ────────────────────────────────────────────────────
|
|
153
|
-
|
|
154
|
-
const WebGLPoolContext = createContext(null)
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* Provider that creates and owns the context pool.
|
|
158
|
-
* Place this around the canvas widget tree in CanvasPage.
|
|
159
|
-
*
|
|
160
|
-
* @param {{ maxLive?: number, children: React.ReactNode }} props
|
|
161
|
-
*/
|
|
162
|
-
export function WebGLContextPoolProvider({ maxLive = DEFAULT_MAX_LIVE, children }) {
|
|
163
|
-
// useMemo is correct here: the pool is a stable singleton for this provider's lifetime.
|
|
164
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
165
|
-
const pool = useMemo(() => new ContextPool(maxLive), [])
|
|
166
|
-
return (
|
|
167
|
-
<WebGLPoolContext.Provider value={pool}>
|
|
168
|
-
{children}
|
|
169
|
-
</WebGLPoolContext.Provider>
|
|
170
|
-
)
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* Hook that manages a terminal widget's WebGL slot lifecycle.
|
|
175
|
-
*
|
|
176
|
-
* Returns:
|
|
177
|
-
* - `isLive`: whether this widget should create a live ghostty terminal
|
|
178
|
-
* - `generation`: counter that increments on each live→frozen transition;
|
|
179
|
-
* use as an effect dep to detect when to re-create the terminal
|
|
180
|
-
* - `setPriority(p)`: update this widget's priority
|
|
181
|
-
*
|
|
182
|
-
* @param {string} widgetId
|
|
183
|
-
* @returns {{ isLive: boolean, generation: number, setPriority: (p: number) => void }}
|
|
184
|
-
*/
|
|
185
|
-
export function useWebGLSlot(widgetId, initialPriority) {
|
|
186
|
-
const pool = useContext(WebGLPoolContext)
|
|
187
|
-
|
|
188
|
-
// Register on mount, unregister on unmount
|
|
189
|
-
useEffect(() => {
|
|
190
|
-
if (!pool) return
|
|
191
|
-
pool.register(widgetId, initialPriority)
|
|
192
|
-
return () => pool.unregister(widgetId)
|
|
193
|
-
}, [pool, widgetId]) // initialPriority intentionally excluded — only used on first register
|
|
194
|
-
|
|
195
|
-
// Subscribe to pool state for reactivity.
|
|
196
|
-
// getSnapshot returns the pool's version counter — a primitive that changes
|
|
197
|
-
// on every state mutation, satisfying useSyncExternalStore's identity check.
|
|
198
|
-
const subscribe = useCallback(
|
|
199
|
-
(cb) => pool ? pool.subscribe(cb) : () => {},
|
|
200
|
-
[pool],
|
|
201
|
-
)
|
|
202
|
-
const getSnapshot = useCallback(() => {
|
|
203
|
-
if (!pool) return -1
|
|
204
|
-
return pool._version
|
|
205
|
-
}, [pool])
|
|
206
|
-
|
|
207
|
-
// This triggers re-render whenever the pool version changes
|
|
208
|
-
useSyncExternalStore(subscribe, getSnapshot)
|
|
209
|
-
|
|
210
|
-
// Read the actual slot state (after subscription ensures freshness)
|
|
211
|
-
const slot = pool?.getSlot(widgetId)
|
|
212
|
-
|
|
213
|
-
const setPriority = useCallback(
|
|
214
|
-
(p) => pool?.setPriority(widgetId, p),
|
|
215
|
-
[pool, widgetId],
|
|
216
|
-
)
|
|
217
|
-
|
|
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
|
|
222
|
-
return {
|
|
223
|
-
isLive: true,
|
|
224
|
-
generation: 0,
|
|
225
|
-
setPriority,
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/**
|
|
230
|
-
* Hook for CanvasPage to batch-update visibility for all terminal widgets.
|
|
231
|
-
* Call this with the current viewport rect and widget positions.
|
|
232
|
-
*
|
|
233
|
-
* @returns {(viewportRect: {x:number,y:number,w:number,h:number}, widgets: Array<{id:string,type:string,position:{x:number,y:number},props?:object}>, selectedIds: Set<string>, expandedId: string|null) => void}
|
|
234
|
-
*/
|
|
235
|
-
export function usePoolVisibilityUpdater() {
|
|
236
|
-
const pool = useContext(WebGLPoolContext)
|
|
237
|
-
|
|
238
|
-
return useCallback((viewportRect, widgets, selectedIds, expandedId) => {
|
|
239
|
-
if (!pool) return
|
|
240
|
-
|
|
241
|
-
const NEAR_MARGIN = 400 // canvas-space pixels for "near viewport" zone
|
|
242
|
-
|
|
243
|
-
const terminalTypes = new Set(['terminal', 'agent', 'prompt'])
|
|
244
|
-
|
|
245
|
-
for (const w of widgets) {
|
|
246
|
-
if (!terminalTypes.has(w.type)) continue
|
|
247
|
-
|
|
248
|
-
// Determine priority
|
|
249
|
-
if (w.id === expandedId) {
|
|
250
|
-
pool.setPriority(w.id, Priority.PINNED)
|
|
251
|
-
continue
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
if (selectedIds?.has(w.id)) {
|
|
255
|
-
pool.setPriority(w.id, Priority.PINNED)
|
|
256
|
-
continue
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
const wx = w.position?.x ?? 0
|
|
260
|
-
const wy = w.position?.y ?? 0
|
|
261
|
-
const ww = w.props?.width ?? 800
|
|
262
|
-
const wh = w.props?.height ?? 450
|
|
263
|
-
|
|
264
|
-
// Check overlap with viewport
|
|
265
|
-
const visible = rectsOverlap(
|
|
266
|
-
viewportRect.x, viewportRect.y, viewportRect.w, viewportRect.h,
|
|
267
|
-
wx, wy, ww, wh,
|
|
268
|
-
)
|
|
269
|
-
|
|
270
|
-
if (visible) {
|
|
271
|
-
pool.setPriority(w.id, Priority.VISIBLE)
|
|
272
|
-
continue
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
// Check overlap with expanded viewport (near margin)
|
|
276
|
-
const near = rectsOverlap(
|
|
277
|
-
viewportRect.x - NEAR_MARGIN,
|
|
278
|
-
viewportRect.y - NEAR_MARGIN,
|
|
279
|
-
viewportRect.w + NEAR_MARGIN * 2,
|
|
280
|
-
viewportRect.h + NEAR_MARGIN * 2,
|
|
281
|
-
wx, wy, ww, wh,
|
|
282
|
-
)
|
|
283
|
-
|
|
284
|
-
pool.setPriority(w.id, near ? Priority.NEAR_VIEWPORT : Priority.OFFSCREEN)
|
|
285
|
-
}
|
|
286
|
-
}, [pool])
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
function rectsOverlap(ax, ay, aw, ah, bx, by, bw, bh) {
|
|
290
|
-
return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by
|
|
291
|
-
}
|
|
@@ -1,165 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { render, act } from '@testing-library/react'
|
|
3
|
-
import { WebGLContextPoolProvider, useWebGLSlot, usePoolVisibilityUpdater, Priority } from './WebGLContextPool.jsx'
|
|
4
|
-
|
|
5
|
-
function TestWidget({ widgetId, onSlot }) {
|
|
6
|
-
const slot = useWebGLSlot(widgetId)
|
|
7
|
-
onSlot?.(slot)
|
|
8
|
-
return <div data-testid={widgetId}>{slot.isLive ? 'live' : 'frozen'}</div>
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
function TestUpdater({ onUpdater }) {
|
|
12
|
-
const update = usePoolVisibilityUpdater()
|
|
13
|
-
onUpdater?.(update)
|
|
14
|
-
return null
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
describe('WebGLContextPool', () => {
|
|
18
|
-
it('grants live slots to widgets within the max limit', () => {
|
|
19
|
-
let slot1, slot2
|
|
20
|
-
render(
|
|
21
|
-
<WebGLContextPoolProvider maxLive={2}>
|
|
22
|
-
<TestWidget widgetId="t1" onSlot={(s) => { slot1 = s }} />
|
|
23
|
-
<TestWidget widgetId="t2" onSlot={(s) => { slot2 = s }} />
|
|
24
|
-
</WebGLContextPoolProvider>
|
|
25
|
-
)
|
|
26
|
-
|
|
27
|
-
// Both should be live since we're under the limit
|
|
28
|
-
expect(slot1.isLive).toBe(true)
|
|
29
|
-
expect(slot2.isLive).toBe(true)
|
|
30
|
-
})
|
|
31
|
-
|
|
32
|
-
it('freezes excess widgets when over the limit', () => {
|
|
33
|
-
let slot1, slot2, slot3
|
|
34
|
-
render(
|
|
35
|
-
<WebGLContextPoolProvider maxLive={2}>
|
|
36
|
-
<TestWidget widgetId="t1" onSlot={(s) => { slot1 = s }} />
|
|
37
|
-
<TestWidget widgetId="t2" onSlot={(s) => { slot2 = s }} />
|
|
38
|
-
<TestWidget widgetId="t3" onSlot={(s) => { slot3 = s }} />
|
|
39
|
-
</WebGLContextPoolProvider>
|
|
40
|
-
)
|
|
41
|
-
|
|
42
|
-
const liveCount = [slot1, slot2, slot3].filter(s => s.isLive).length
|
|
43
|
-
const frozenCount = [slot1, slot2, slot3].filter(s => !s.isLive).length
|
|
44
|
-
|
|
45
|
-
expect(liveCount).toBe(2)
|
|
46
|
-
expect(frozenCount).toBe(1)
|
|
47
|
-
})
|
|
48
|
-
|
|
49
|
-
it('always returns live when no provider is present', () => {
|
|
50
|
-
let slot
|
|
51
|
-
render(<TestWidget widgetId="t1" onSlot={(s) => { slot = s }} />)
|
|
52
|
-
expect(slot.isLive).toBe(true)
|
|
53
|
-
expect(slot.generation).toBe(0)
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
it('prioritizes PINNED widgets over OFFSCREEN', () => {
|
|
57
|
-
let slot3
|
|
58
|
-
render(
|
|
59
|
-
<WebGLContextPoolProvider maxLive={2}>
|
|
60
|
-
<TestWidget widgetId="t1" onSlot={() => {}} />
|
|
61
|
-
<TestWidget widgetId="t2" onSlot={() => {}} />
|
|
62
|
-
<TestWidget widgetId="t3" onSlot={(s) => { slot3 = s }} />
|
|
63
|
-
</WebGLContextPoolProvider>
|
|
64
|
-
)
|
|
65
|
-
|
|
66
|
-
// Pin t3 — it should become live, evicting one of the others
|
|
67
|
-
act(() => { slot3.setPriority(Priority.PINNED) })
|
|
68
|
-
|
|
69
|
-
expect(slot3.isLive).toBe(true)
|
|
70
|
-
})
|
|
71
|
-
|
|
72
|
-
it('PINNED widgets bypass the max limit', () => {
|
|
73
|
-
let slot1, slot2, slot3
|
|
74
|
-
render(
|
|
75
|
-
<WebGLContextPoolProvider maxLive={2}>
|
|
76
|
-
<TestWidget widgetId="t1" onSlot={(s) => { slot1 = s }} />
|
|
77
|
-
<TestWidget widgetId="t2" onSlot={(s) => { slot2 = s }} />
|
|
78
|
-
<TestWidget widgetId="t3" onSlot={(s) => { slot3 = s }} />
|
|
79
|
-
</WebGLContextPoolProvider>
|
|
80
|
-
)
|
|
81
|
-
|
|
82
|
-
// Pin all three
|
|
83
|
-
act(() => {
|
|
84
|
-
slot1.setPriority(Priority.PINNED)
|
|
85
|
-
slot2.setPriority(Priority.PINNED)
|
|
86
|
-
slot3.setPriority(Priority.PINNED)
|
|
87
|
-
})
|
|
88
|
-
|
|
89
|
-
// All should be live because PINNED bypasses the cap
|
|
90
|
-
expect(slot1.isLive).toBe(true)
|
|
91
|
-
expect(slot2.isLive).toBe(true)
|
|
92
|
-
expect(slot3.isLive).toBe(true)
|
|
93
|
-
})
|
|
94
|
-
|
|
95
|
-
it('tracks generation across live-frozen-live transitions', () => {
|
|
96
|
-
let slot3
|
|
97
|
-
render(
|
|
98
|
-
<WebGLContextPoolProvider maxLive={2}>
|
|
99
|
-
<TestWidget widgetId="t1" onSlot={() => {}} />
|
|
100
|
-
<TestWidget widgetId="t2" onSlot={() => {}} />
|
|
101
|
-
<TestWidget widgetId="t3" onSlot={(s) => { slot3 = s }} />
|
|
102
|
-
</WebGLContextPoolProvider>
|
|
103
|
-
)
|
|
104
|
-
|
|
105
|
-
// t3 starts frozen with generation 0 (never was live)
|
|
106
|
-
expect(slot3.isLive).toBe(false)
|
|
107
|
-
expect(slot3.generation).toBe(0)
|
|
108
|
-
|
|
109
|
-
// Pin t3 to make it live
|
|
110
|
-
act(() => { slot3.setPriority(Priority.PINNED) })
|
|
111
|
-
expect(slot3.isLive).toBe(true)
|
|
112
|
-
|
|
113
|
-
// Unpin t3 — it should be evicted and generation bumped
|
|
114
|
-
act(() => { slot3.setPriority(Priority.OFFSCREEN) })
|
|
115
|
-
// Hysteresis delays eviction; use fake timers if needed.
|
|
116
|
-
// For now, verify that generation bumps when eviction happens.
|
|
117
|
-
})
|
|
118
|
-
|
|
119
|
-
it('usePoolVisibilityUpdater updates priorities based on viewport', () => {
|
|
120
|
-
let slot1, slot2, updater
|
|
121
|
-
render(
|
|
122
|
-
<WebGLContextPoolProvider maxLive={1}>
|
|
123
|
-
<TestWidget widgetId="t1" onSlot={(s) => { slot1 = s }} />
|
|
124
|
-
<TestWidget widgetId="t2" onSlot={(s) => { slot2 = s }} />
|
|
125
|
-
<TestUpdater onUpdater={(u) => { updater = u }} />
|
|
126
|
-
</WebGLContextPoolProvider>
|
|
127
|
-
)
|
|
128
|
-
|
|
129
|
-
const widgets = [
|
|
130
|
-
{ id: 't1', type: 'terminal', position: { x: 100, y: 100 }, props: { width: 800, height: 450 } },
|
|
131
|
-
{ id: 't2', type: 'terminal', position: { x: 5000, y: 5000 }, props: { width: 800, height: 450 } },
|
|
132
|
-
]
|
|
133
|
-
|
|
134
|
-
// Viewport only covers t1
|
|
135
|
-
act(() => {
|
|
136
|
-
updater({ x: 0, y: 0, w: 1920, h: 1080 }, widgets, new Set(), null)
|
|
137
|
-
})
|
|
138
|
-
|
|
139
|
-
expect(slot1.isLive).toBe(true)
|
|
140
|
-
expect(slot2.isLive).toBe(false)
|
|
141
|
-
})
|
|
142
|
-
|
|
143
|
-
it('selected widgets get PINNED priority via visibility updater', () => {
|
|
144
|
-
let slot2, updater
|
|
145
|
-
render(
|
|
146
|
-
<WebGLContextPoolProvider maxLive={1}>
|
|
147
|
-
<TestWidget widgetId="t1" onSlot={() => {}} />
|
|
148
|
-
<TestWidget widgetId="t2" onSlot={(s) => { slot2 = s }} />
|
|
149
|
-
<TestUpdater onUpdater={(u) => { updater = u }} />
|
|
150
|
-
</WebGLContextPoolProvider>
|
|
151
|
-
)
|
|
152
|
-
|
|
153
|
-
const widgets = [
|
|
154
|
-
{ id: 't1', type: 'terminal', position: { x: 100, y: 100 }, props: { width: 800, height: 450 } },
|
|
155
|
-
{ id: 't2', type: 'terminal', position: { x: 5000, y: 5000 }, props: { width: 800, height: 450 } },
|
|
156
|
-
]
|
|
157
|
-
|
|
158
|
-
// t2 is offscreen but selected — should be pinned and live
|
|
159
|
-
act(() => {
|
|
160
|
-
updater({ x: 0, y: 0, w: 1920, h: 1080 }, widgets, new Set(['t2']), null)
|
|
161
|
-
})
|
|
162
|
-
|
|
163
|
-
expect(slot2.isLive).toBe(true)
|
|
164
|
-
})
|
|
165
|
-
})
|
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
/* ComponentSetWidget — canvas widget chrome */
|
|
2
|
-
|
|
3
|
-
.container {
|
|
4
|
-
position: relative;
|
|
5
|
-
overflow: hidden;
|
|
6
|
-
min-width: 200px;
|
|
7
|
-
min-height: 120px;
|
|
8
|
-
background: var(--bgColor-default, #ffffff);
|
|
9
|
-
border: 3px solid var(--borderColor-default, #d0d7de);
|
|
10
|
-
border-radius: 12px;
|
|
11
|
-
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
|
12
|
-
width: 100%;
|
|
13
|
-
height: 100%;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
.header {
|
|
17
|
-
display: flex;
|
|
18
|
-
align-items: center;
|
|
19
|
-
gap: 6px;
|
|
20
|
-
padding: 10px 10px;
|
|
21
|
-
font-size: 12px;
|
|
22
|
-
font-weight: 500;
|
|
23
|
-
color: var(--fgColor-muted, #656d76);
|
|
24
|
-
background: var(--bgColor-muted, #f6f8fa);
|
|
25
|
-
border-bottom: 1px solid var(--borderColor-muted, #d8dee4);
|
|
26
|
-
white-space: nowrap;
|
|
27
|
-
overflow: hidden;
|
|
28
|
-
text-overflow: ellipsis;
|
|
29
|
-
user-select: none;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
.headerIcon {
|
|
33
|
-
display: inline-flex;
|
|
34
|
-
flex-shrink: 0;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
.headerTitle {
|
|
38
|
-
overflow: hidden;
|
|
39
|
-
text-overflow: ellipsis;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
.headerSelected {
|
|
43
|
-
color: var(--fgColor-accent, #0969da);
|
|
44
|
-
font-weight: 600;
|
|
45
|
-
flex-shrink: 0;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
.headerLayout {
|
|
49
|
-
margin-left: auto;
|
|
50
|
-
font-size: 14px;
|
|
51
|
-
opacity: 0.5;
|
|
52
|
-
flex-shrink: 0;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
.content {
|
|
56
|
-
position: relative;
|
|
57
|
-
width: 100%;
|
|
58
|
-
height: calc(100% - 37px);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
.iframe {
|
|
62
|
-
position: absolute;
|
|
63
|
-
inset: 0;
|
|
64
|
-
display: block;
|
|
65
|
-
width: 100%;
|
|
66
|
-
height: 100%;
|
|
67
|
-
border: none;
|
|
68
|
-
z-index: 1;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
.error {
|
|
72
|
-
display: flex;
|
|
73
|
-
align-items: center;
|
|
74
|
-
gap: 8px;
|
|
75
|
-
padding: 16px;
|
|
76
|
-
color: var(--fgColor-danger, #cf222e);
|
|
77
|
-
font-family: system-ui, -apple-system, sans-serif;
|
|
78
|
-
font-size: 13px;
|
|
79
|
-
line-height: 1.5;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
.errorIcon {
|
|
83
|
-
font-size: 20px;
|
|
84
|
-
flex-shrink: 0;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
.errorText {
|
|
88
|
-
word-break: break-word;
|
|
89
|
-
}
|
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
/* StorySetWidget — canvas widget chrome */
|
|
2
|
-
|
|
3
|
-
.container {
|
|
4
|
-
position: relative;
|
|
5
|
-
overflow: hidden;
|
|
6
|
-
min-width: 200px;
|
|
7
|
-
min-height: 120px;
|
|
8
|
-
background: var(--bgColor-default, #ffffff);
|
|
9
|
-
border: 3px solid var(--borderColor-default, #d0d7de);
|
|
10
|
-
border-radius: 12px;
|
|
11
|
-
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
|
12
|
-
width: 100%;
|
|
13
|
-
height: 100%;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
.header {
|
|
17
|
-
display: flex;
|
|
18
|
-
align-items: center;
|
|
19
|
-
gap: 6px;
|
|
20
|
-
padding: 10px 10px;
|
|
21
|
-
font-size: 12px;
|
|
22
|
-
font-weight: 500;
|
|
23
|
-
color: var(--fgColor-muted, #656d76);
|
|
24
|
-
background: var(--bgColor-muted, #f6f8fa);
|
|
25
|
-
border-bottom: 1px solid var(--borderColor-muted, #d8dee4);
|
|
26
|
-
white-space: nowrap;
|
|
27
|
-
overflow: hidden;
|
|
28
|
-
text-overflow: ellipsis;
|
|
29
|
-
user-select: none;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
.headerIcon {
|
|
33
|
-
display: inline-flex;
|
|
34
|
-
flex-shrink: 0;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
.headerTitle {
|
|
38
|
-
overflow: hidden;
|
|
39
|
-
text-overflow: ellipsis;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
.headerSelected {
|
|
43
|
-
color: var(--fgColor-accent, #0969da);
|
|
44
|
-
font-weight: 600;
|
|
45
|
-
flex-shrink: 0;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
.headerLayout {
|
|
49
|
-
margin-left: auto;
|
|
50
|
-
font-size: 14px;
|
|
51
|
-
opacity: 0.5;
|
|
52
|
-
flex-shrink: 0;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
.content {
|
|
56
|
-
position: relative;
|
|
57
|
-
width: 100%;
|
|
58
|
-
height: calc(100% - 37px);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
.iframe {
|
|
62
|
-
position: absolute;
|
|
63
|
-
inset: 0;
|
|
64
|
-
display: block;
|
|
65
|
-
width: 100%;
|
|
66
|
-
height: 100%;
|
|
67
|
-
border: none;
|
|
68
|
-
z-index: 1;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
.error {
|
|
72
|
-
display: flex;
|
|
73
|
-
align-items: center;
|
|
74
|
-
gap: 8px;
|
|
75
|
-
padding: 16px;
|
|
76
|
-
color: var(--fgColor-danger, #cf222e);
|
|
77
|
-
font-family: system-ui, -apple-system, sans-serif;
|
|
78
|
-
font-size: 13px;
|
|
79
|
-
line-height: 1.5;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
.errorIcon {
|
|
83
|
-
font-size: 20px;
|
|
84
|
-
flex-shrink: 0;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
.errorText {
|
|
88
|
-
word-break: break-word;
|
|
89
|
-
}
|