@dfosco/storyboard-react 4.2.4 → 4.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,165 @@
1
+ import { describe, it, expect, vi } 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 slot1, slot2, slot3
58
+ render(
59
+ <WebGLContextPoolProvider maxLive={2}>
60
+ <TestWidget widgetId="t1" onSlot={(s) => { slot1 = s }} />
61
+ <TestWidget widgetId="t2" onSlot={(s) => { slot2 = s }} />
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 slot1, slot2, slot3
97
+ render(
98
+ <WebGLContextPoolProvider maxLive={2}>
99
+ <TestWidget widgetId="t1" onSlot={(s) => { slot1 = s }} />
100
+ <TestWidget widgetId="t2" onSlot={(s) => { slot2 = s }} />
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 slot1, slot2, updater
145
+ render(
146
+ <WebGLContextPoolProvider maxLive={1}>
147
+ <TestWidget widgetId="t1" onSlot={(s) => { slot1 = s }} />
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
+ })
@@ -89,11 +89,20 @@ document.documentElement.setAttribute('data-color-mode', theme.startsWith('dark'
89
89
  document.documentElement.setAttribute('data-dark-theme', theme.startsWith('dark') ? theme : '')
90
90
  document.documentElement.setAttribute('data-light-theme', theme.startsWith('dark') ? '' : theme || 'light')
91
91
 
92
+ // Suppress HMR full-reloads — this iframe is embedded inside a canvas page
93
+ // that manages its own reload lifecycle. Without this guard, every file change
94
+ // causes the iframe to flash/reload.
95
+ if (import.meta.hot) {
96
+ const msg = { active: true }
97
+ import.meta.hot.send('storyboard:canvas-hmr-guard', msg)
98
+ setInterval(() => import.meta.hot.send('storyboard:canvas-hmr-guard', msg), 3000)
99
+ }
100
+
92
101
  const root = createRoot(document.getElementById('root'))
93
102
 
94
103
  async function mount() {
95
- if (!modulePath || !exportName) {
96
- root.render(createElement('div', { style: errorStyle }, 'Missing module or export param'))
104
+ if (!modulePath) {
105
+ root.render(createElement('div', { style: errorStyle }, 'Missing module param'))
97
106
  return
98
107
  }
99
108
 
@@ -106,25 +115,46 @@ async function mount() {
106
115
  try {
107
116
  const resolved = resolveModulePath(modulePath)
108
117
  const mod = await import(/* @vite-ignore */ resolved)
109
- const Component = mod[exportName]
110
-
111
- if (!Component || typeof Component !== 'function') {
112
- throw new Error(`Export "${exportName}" not found or is not a component`)
113
- }
114
118
 
115
- root.render(
116
- createElement(ThemeProvider, { colorMode },
117
- createElement(BaseStyles, null,
118
- createElement(IsolateErrorBoundary, { name: exportName },
119
- createElement(Component),
119
+ if (exportName) {
120
+ // Single export mode
121
+ const Component = mod[exportName]
122
+ if (!Component || typeof Component !== 'function') {
123
+ throw new Error(`Export "${exportName}" not found or is not a component`)
124
+ }
125
+ root.render(
126
+ createElement(ThemeProvider, { colorMode },
127
+ createElement(BaseStyles, null,
128
+ createElement(IsolateErrorBoundary, { name: exportName },
129
+ createElement(Component),
130
+ ),
120
131
  ),
121
132
  ),
122
- ),
123
- )
133
+ )
134
+ } else {
135
+ // All exports mode — render every named function export stacked
136
+ const entries = Object.entries(mod).filter(
137
+ ([key, value]) => key !== 'default' && typeof value === 'function',
138
+ )
139
+ if (entries.length === 0) {
140
+ throw new Error('No named exports found in story module')
141
+ }
142
+ root.render(
143
+ createElement(ThemeProvider, { colorMode },
144
+ createElement(BaseStyles, null,
145
+ ...entries.map(([name, Component]) =>
146
+ createElement(IsolateErrorBoundary, { key: name, name },
147
+ createElement(Component),
148
+ ),
149
+ ),
150
+ ),
151
+ ),
152
+ )
153
+ }
124
154
  } catch (err) {
125
155
  root.render(
126
156
  createElement('div', { style: errorStyle },
127
- createElement('strong', null, exportName),
157
+ createElement('strong', null, exportName || 'Component'),
128
158
  createElement('br'),
129
159
  String(err.message || err),
130
160
  ),
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Canvas Component-Set Isolate — lightweight iframe entry point.
3
+ *
4
+ * Renders ALL named exports from a .story.jsx module in a grid layout,
5
+ * bypassing the full SPA bootstrap (router, StoryboardProvider, data index).
6
+ *
7
+ * This is the component-set equivalent of componentIsolate.jsx, which renders
8
+ * a single export. By avoiding the full app bootstrap, component-set widgets
9
+ * load significantly faster — especially important since each widget is an
10
+ * iframe that would otherwise need to initialize the entire app.
11
+ *
12
+ * Query params:
13
+ * module — absolute or base-relative path to the .story.jsx file
14
+ * layout — "horizontal" (default) | "vertical"
15
+ * selected — export name of the currently selected cell
16
+ * theme — canvas theme (light / dark / dark_dimmed)
17
+ */
18
+ import { createElement, Component as ReactComponent, useState, useEffect, useLayoutEffect, useCallback, useRef } from 'react'
19
+ import { createRoot } from 'react-dom/client'
20
+ import { ThemeProvider, BaseStyles } from '@primer/react'
21
+
22
+ // ── Primer Primitives CSS (required for CSS variables) ──────────────
23
+ import '@primer/primitives/dist/css/base/size/size.css'
24
+ import '@primer/primitives/dist/css/base/typography/typography.css'
25
+ import '@primer/primitives/dist/css/base/motion/motion.css'
26
+ import '@primer/primitives/dist/css/functional/size/border.css'
27
+ import '@primer/primitives/dist/css/functional/size/breakpoints.css'
28
+ import '@primer/primitives/dist/css/functional/size/size-coarse.css'
29
+ import '@primer/primitives/dist/css/functional/size/size-fine.css'
30
+ import '@primer/primitives/dist/css/functional/size/size.css'
31
+ import '@primer/primitives/dist/css/functional/size/viewport.css'
32
+ import '@primer/primitives/dist/css/functional/typography/typography.css'
33
+ import '@primer/primitives/dist/css/functional/themes/light.css'
34
+ import '@primer/primitives/dist/css/functional/themes/light-colorblind.css'
35
+ import '@primer/primitives/dist/css/functional/themes/dark.css'
36
+ import '@primer/primitives/dist/css/functional/themes/dark-colorblind.css'
37
+ import '@primer/primitives/dist/css/functional/themes/dark-high-contrast.css'
38
+ import '@primer/primitives/dist/css/functional/themes/dark-dimmed.css'
39
+
40
+ import styles from '../story/ComponentSetPage.module.css'
41
+
42
+ // ── Error Boundary ──────────────────────────────────────────────────
43
+ class IsolateErrorBoundary extends ReactComponent {
44
+ constructor(props) {
45
+ super(props)
46
+ this.state = { error: null }
47
+ }
48
+ static getDerivedStateFromError(error) {
49
+ return { error }
50
+ }
51
+ render() {
52
+ if (this.state.error) {
53
+ return createElement('div', { style: errorStyle },
54
+ createElement('strong', null, this.props.name || 'Component'),
55
+ createElement('br'),
56
+ String(this.state.error.message || this.state.error),
57
+ )
58
+ }
59
+ return this.props.children
60
+ }
61
+ }
62
+
63
+ const errorStyle = {
64
+ padding: '16px',
65
+ color: '#cf222e',
66
+ fontFamily: 'system-ui, -apple-system, sans-serif',
67
+ fontSize: '13px',
68
+ lineHeight: 1.5,
69
+ whiteSpace: 'pre-wrap',
70
+ wordBreak: 'break-word',
71
+ }
72
+
73
+ // ── Resolve module path ─────────────────────────────────────────────
74
+ function resolveModulePath(raw) {
75
+ if (!raw) return raw
76
+ if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(raw)) return raw
77
+ if (!raw.startsWith('/')) return raw
78
+ const base = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
79
+ if (!base) return raw
80
+ if (raw.startsWith(base)) return raw
81
+ return `${base}${raw}`
82
+ }
83
+
84
+ // ── Component-Set Grid (mirrors ComponentSetPage UI) ────────────────
85
+ function ComponentSetGrid({ exports, layout, initialSelected }) {
86
+ const [selected, setSelected] = useState(initialSelected)
87
+ const gridRef = useRef(null)
88
+
89
+ const handleSelect = useCallback((exportName) => {
90
+ const next = exportName === selected ? '' : exportName
91
+ setSelected(next)
92
+
93
+ // Update URL without navigation
94
+ const params = new URLSearchParams(window.location.search)
95
+ if (next) params.set('selected', next)
96
+ else params.delete('selected')
97
+ window.history.replaceState(null, '', `${window.location.pathname}?${params}`)
98
+
99
+ // Notify parent widget
100
+ if (window.parent !== window) {
101
+ window.parent.postMessage({
102
+ type: 'storyboard:component-set:select',
103
+ exportName: next || null,
104
+ }, '*')
105
+ }
106
+ }, [selected])
107
+
108
+ // Measure cells and post grid size to parent
109
+ useLayoutEffect(() => {
110
+ const grid = gridRef.current
111
+ if (!grid || !exports) return
112
+
113
+ const cells = grid.querySelectorAll('[data-cell-content]')
114
+ if (cells.length === 0) return
115
+
116
+ function measure() {
117
+ let maxW = 0
118
+ let maxH = 0
119
+ for (const el of cells) {
120
+ maxW = Math.max(maxW, el.scrollWidth)
121
+ maxH = Math.max(maxH, el.scrollHeight)
122
+ }
123
+ grid.style.setProperty('--cell-snap-w', `${maxW}px`)
124
+ grid.style.setProperty('--cell-snap-h', `${maxH}px`)
125
+
126
+ if (window.parent !== window) {
127
+ requestAnimationFrame(() => {
128
+ window.parent.postMessage({
129
+ type: 'storyboard:component-set:resize',
130
+ width: grid.scrollWidth,
131
+ height: grid.scrollHeight,
132
+ }, '*')
133
+ })
134
+ }
135
+ }
136
+
137
+ measure()
138
+ document.fonts.ready.then(() => requestAnimationFrame(measure))
139
+
140
+ const ro = new ResizeObserver(measure)
141
+ for (const el of cells) ro.observe(el)
142
+ return () => ro.disconnect()
143
+ }, [exports, layout])
144
+
145
+ // Signal snapshot-ready
146
+ useEffect(() => {
147
+ document.fonts.ready.then(() => {
148
+ requestAnimationFrame(() => requestAnimationFrame(() => {
149
+ window.__sbSnapshotReady?.()
150
+ }))
151
+ })
152
+ }, [exports])
153
+
154
+ const exportNames = Object.keys(exports)
155
+
156
+ // eslint-disable-next-line react-hooks/refs -- ref assigned to DOM element, not read during render
157
+ return createElement('div', {
158
+ ref: gridRef,
159
+ className: styles.grid,
160
+ 'data-layout': layout,
161
+ },
162
+ exportNames.map((exportName) => {
163
+ const Component = exports[exportName]
164
+ const isSelected = exportName === selected
165
+ return createElement('div', {
166
+ key: exportName,
167
+ className: styles.cell,
168
+ 'data-selected': isSelected || undefined,
169
+ },
170
+ createElement('button', {
171
+ className: styles.cellLabel,
172
+ onClick: () => handleSelect(exportName),
173
+ 'data-selected': isSelected || undefined,
174
+ 'aria-pressed': isSelected,
175
+ },
176
+ createElement('span', { className: styles.cellRadio, 'data-selected': isSelected || undefined }),
177
+ createElement('span', { className: styles.cellName }, exportName),
178
+ ),
179
+ createElement('div', { className: styles.cellContent, 'data-cell-content': '' },
180
+ createElement(IsolateErrorBoundary, { name: exportName },
181
+ createElement(Component),
182
+ ),
183
+ ),
184
+ )
185
+ }),
186
+ )
187
+ }
188
+
189
+ // ── Main ────────────────────────────────────────────────────────────
190
+ const params = new URLSearchParams(window.location.search)
191
+ const modulePath = params.get('module')
192
+ const layout = params.get('layout') || 'horizontal'
193
+ const selected = params.get('selected') || ''
194
+ const theme = params.get('theme') || 'light'
195
+
196
+ const colorMode = theme.startsWith('dark') ? 'night' : 'day'
197
+
198
+ document.documentElement.setAttribute('data-color-mode', theme.startsWith('dark') ? 'dark' : 'light')
199
+ document.documentElement.setAttribute('data-dark-theme', theme.startsWith('dark') ? theme : '')
200
+ document.documentElement.setAttribute('data-light-theme', theme.startsWith('dark') ? '' : theme || 'light')
201
+
202
+ // Suppress HMR full-reloads — this iframe is embedded inside a canvas page
203
+ // that manages its own reload lifecycle. Without this guard, every file change
204
+ // causes the iframe to flash/reload.
205
+ if (import.meta.hot) {
206
+ const msg = { active: true }
207
+ import.meta.hot.send('storyboard:canvas-hmr-guard', msg)
208
+ setInterval(() => import.meta.hot.send('storyboard:canvas-hmr-guard', msg), 3000)
209
+ }
210
+
211
+ const root = createRoot(document.getElementById('root'))
212
+
213
+ async function mount() {
214
+ if (!modulePath) {
215
+ root.render(createElement('div', { style: errorStyle }, 'Missing module param'))
216
+ return
217
+ }
218
+
219
+ if (!modulePath.match(/\.story\.(jsx|tsx)$/)) {
220
+ root.render(createElement('div', { style: errorStyle }, 'Invalid module path — only .story.jsx/.tsx files are allowed'))
221
+ return
222
+ }
223
+
224
+ try {
225
+ const resolved = resolveModulePath(modulePath)
226
+ const mod = await import(/* @vite-ignore */ resolved)
227
+
228
+ const namedExports = {}
229
+ for (const [key, value] of Object.entries(mod)) {
230
+ if (key !== 'default' && typeof value === 'function') {
231
+ namedExports[key] = value
232
+ }
233
+ }
234
+
235
+ if (Object.keys(namedExports).length === 0) {
236
+ throw new Error('No named exports found in story module')
237
+ }
238
+
239
+ root.render(
240
+ createElement(ThemeProvider, { colorMode },
241
+ createElement(BaseStyles, null,
242
+ createElement(ComponentSetGrid, { exports: namedExports, layout, initialSelected: selected }),
243
+ ),
244
+ ),
245
+ )
246
+ } catch (err) {
247
+ root.render(
248
+ createElement('div', { style: errorStyle },
249
+ createElement('strong', null, 'Component Set'),
250
+ createElement('br'),
251
+ String(err.message || err),
252
+ ),
253
+ )
254
+ }
255
+ }
256
+
257
+ mount()