@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.
@@ -5,7 +5,6 @@ import { describe, it, expect, vi } from 'vitest'
5
5
  import { render, fireEvent, screen } from '@testing-library/react'
6
6
  import PrototypeEmbed from './PrototypeEmbed.jsx'
7
7
  import FigmaEmbed from './FigmaEmbed.jsx'
8
- import ComponentWidget from './ComponentWidget.jsx'
9
8
  import StoryWidget from './StoryWidget.jsx'
10
9
 
11
10
  // Mock buildPrototypeIndex for PrototypeEmbed
@@ -24,7 +23,7 @@ vi.mock('@dfosco/storyboard-core', () => ({
24
23
  globalFlows: [],
25
24
  sorted: { title: { prototypes: [], folders: [] } },
26
25
  }),
27
- getStoryData: (storyId) => ({ _route: `/components/${storyId}` }),
26
+ getStoryData: (storyId) => ({ _storyModule: `/src/canvas/${storyId}.story.jsx`, _route: `/components/${storyId}` }),
28
27
  }))
29
28
 
30
29
  // Simple mock wrapper for WidgetWrapper
@@ -43,11 +42,6 @@ vi.mock('./ResizeHandle.jsx', () => ({
43
42
  default: () => <div data-testid="resize-handle" />,
44
43
  }))
45
44
 
46
- // Mock ComponentErrorBoundary
47
- vi.mock('../ComponentErrorBoundary.jsx', () => ({
48
- default: ({ children }) => <div data-testid="error-boundary">{children}</div>,
49
- }))
50
-
51
45
  describe('Embed interaction overlay', () => {
52
46
  describe('PrototypeEmbed', () => {
53
47
  const defaultProps = {
@@ -176,58 +170,4 @@ describe('Embed interaction overlay', () => {
176
170
  expect(container.querySelector('iframe')).toBeInTheDocument()
177
171
  })
178
172
  })
179
-
180
- describe('ComponentWidget', () => {
181
- const MockComponent = () => <div>Mock Component</div>
182
-
183
- const defaultProps = {
184
- component: MockComponent,
185
- jsxModule: null,
186
- exportName: 'MockComponent',
187
- canvasTheme: 'light',
188
- isLocalDev: false,
189
- width: 200,
190
- height: 150,
191
- onUpdate: vi.fn(),
192
- resizable: false,
193
- }
194
-
195
- it('renders "Click to interact" hint', () => {
196
- render(<ComponentWidget {...defaultProps} />)
197
-
198
- const hint = screen.getByText('Click to interact')
199
- expect(hint).toBeInTheDocument()
200
- })
201
-
202
- it('enters interactive mode on single click', () => {
203
- render(<ComponentWidget {...defaultProps} />)
204
-
205
- const overlay = screen.getByRole('button', { name: /click to interact/i })
206
- fireEvent.click(overlay)
207
-
208
- expect(screen.queryByRole('button', { name: /click to interact/i })).not.toBeInTheDocument()
209
- })
210
-
211
- it('mounts dev iframe only after user activation', () => {
212
- const { container } = render(
213
- <ComponentWidget
214
- {...defaultProps}
215
- isLocalDev
216
- jsxModule="/src/canvas/mock.story.jsx"
217
- exportName="MockComponent"
218
- />
219
- )
220
-
221
- const overlay = screen.getByRole('button', { name: /click to interact with component/i })
222
- expect(container.querySelector('iframe')).not.toBeInTheDocument()
223
-
224
- fireEvent.click(overlay)
225
-
226
- expect(container.querySelector('iframe')).toBeInTheDocument()
227
-
228
- fireEvent.pointerDown(document.body)
229
- expect(screen.getByRole('button', { name: /click to interact with component/i })).toBeInTheDocument()
230
- expect(container.querySelector('iframe')).not.toBeInTheDocument()
231
- })
232
- })
233
173
  })
@@ -470,12 +470,11 @@ export function buildSecondaryIframeUrl(widget) {
470
470
  const exportName = widget.props?.exportName
471
471
  if (!storyId) return null
472
472
  const storyData = getStoryData(storyId)
473
- if (storyData?._route) {
473
+ if (storyData?._storyModule) {
474
474
  const params = new URLSearchParams()
475
+ params.set('module', storyData._storyModule)
475
476
  if (exportName) params.set('export', exportName)
476
- params.set('_sb_embed', '')
477
- params.set('_sb_hide_branch_bar', '')
478
- return `${baseClean}${storyData._route}?${params}`
477
+ return `${baseClean}/_storyboard/canvas/isolate?${params}`
479
478
  }
480
479
  return null
481
480
  }
@@ -6,7 +6,7 @@ import ImageWidget from './ImageWidget.jsx'
6
6
  import FigmaEmbed from './FigmaEmbed.jsx'
7
7
  import CodePenEmbed from './CodePenEmbed.jsx'
8
8
  import StoryWidget from './StoryWidget.jsx'
9
- import ComponentSetWidget from './ComponentSetWidget.jsx'
9
+ import StorySetWidget from './StorySetWidget.jsx'
10
10
  import TerminalWidget from './TerminalWidget.jsx'
11
11
  import TerminalReadWidget from './TerminalReadWidget.jsx'
12
12
  import PromptWidget from './PromptWidget.jsx'
@@ -25,7 +25,7 @@ export const widgetRegistry = {
25
25
  'figma-embed': FigmaEmbed,
26
26
  'codepen-embed': CodePenEmbed,
27
27
  'story': StoryWidget,
28
- 'component-set': ComponentSetWidget,
28
+ 'component-set': StorySetWidget,
29
29
  'terminal': TerminalWidget,
30
30
  'terminal-read': TerminalReadWidget,
31
31
  'agent': TerminalWidget,
@@ -21,7 +21,7 @@ vi.mock('@dfosco/storyboard-core', () => ({
21
21
  globalFlows: [],
22
22
  sorted: { title: { prototypes: [], folders: [] } },
23
23
  }),
24
- getStoryData: (storyId) => ({ _route: `/components/${storyId}` }),
24
+ getStoryData: (storyId) => ({ _storyModule: `/src/canvas/${storyId}.story.jsx`, _route: `/components/${storyId}` }),
25
25
  }))
26
26
 
27
27
  vi.mock('./WidgetWrapper.jsx', () => ({
package/src/context.jsx CHANGED
@@ -1,8 +1,8 @@
1
- import { useState, useEffect, useMemo, Suspense, lazy } from 'react'
1
+ import { useState, useEffect, useMemo, useRef, Suspense, lazy } from 'react'
2
2
  import { useParams, useLocation } from 'react-router-dom'
3
3
  // Named import seeds the core data index via init() AND provides canvas/story route data
4
4
  import { canvases, stories } from 'virtual:storyboard-data-index'
5
- import { loadFlow, flowExists, findRecord, deepMerge, setFlowClass, installBodyClassSync, resolveFlowName, resolveRecordName, isModesEnabled } from '@dfosco/storyboard-core'
5
+ import { loadFlow, flowExists, findRecord, deepMerge, setFlowClass, installBodyClassSync, resolveFlowName, resolveRecordName, isModesEnabled, getPrototypeMetadata } from '@dfosco/storyboard-core'
6
6
  import { StoryboardContext } from './StoryboardContext.js'
7
7
  import usePrototypeReloadGuard from './hooks/usePrototypeReloadGuard.js'
8
8
  import styles from './FlowError.module.css'
@@ -270,12 +270,19 @@ function StoryboardProviderInner({ flowName, sceneName, recordName, recordParam,
270
270
  }, [])
271
271
 
272
272
  // Skip flow loading for canvas/story pages and flow-less pages
273
- const { data, error } = useMemo(() => {
274
- if (canvasId || isMissingCanvasRoute || storyName || isMissingStoryRoute) return { data: null, error: null }
275
- if (!activeFlowName) return { data: {}, error: null }
273
+ const { data, error, flowTokens } = useMemo(() => {
274
+ if (canvasId || isMissingCanvasRoute || storyName || isMissingStoryRoute) return { data: null, error: null, flowTokens: null }
275
+ if (!activeFlowName) return { data: {}, error: null, flowTokens: null }
276
276
  try {
277
277
  let flowData = loadFlow(activeFlowName)
278
278
 
279
+ // Extract tokens before passing data to consumers (reserved metadata key)
280
+ const extractedTokens = flowData?.tokens || null
281
+ if (flowData?.tokens) {
282
+ flowData = { ...flowData }
283
+ delete flowData.tokens
284
+ }
285
+
279
286
  // Merge record data if configured (with scoped resolution)
280
287
  if (recordName && recordParam && params[recordParam]) {
281
288
  const resolvedRecord = resolveRecordName(prototypeName, recordName)
@@ -286,12 +293,68 @@ function StoryboardProviderInner({ flowName, sceneName, recordName, recordParam,
286
293
  }
287
294
 
288
295
  setFlowClass(activeFlowName)
289
- return { data: flowData, error: null }
296
+ return { data: flowData, error: null, flowTokens: extractedTokens }
290
297
  } catch (err) {
291
- return { data: null, error: err.message }
298
+ return { data: null, error: err.message, flowTokens: null }
292
299
  }
293
300
  }, [canvasId, isMissingCanvasRoute, storyName, isMissingStoryRoute, activeFlowName, recordName, recordParam, params, prototypeName])
294
301
 
302
+ // Resolve prototype-level tokens from .prototype.json metadata
303
+ const protoTokens = useMemo(() => {
304
+ if (!prototypeName) return null
305
+ const meta = getPrototypeMetadata(prototypeName)
306
+ return meta?.tokens || null
307
+ }, [prototypeName])
308
+
309
+ // Merge prototype + flow tokens (flow wins). Stable reference when tokens don't change.
310
+ const mergedTokens = useMemo(() => {
311
+ if (!protoTokens && !flowTokens) return null
312
+ return { ...(protoTokens || {}), ...(flowTokens || {}) }
313
+ }, [protoTokens, flowTokens])
314
+
315
+ // Track which URL params were set by tokens (vs. user-explicit params)
316
+ const managedParamsRef = useRef({})
317
+
318
+ // Apply merged tokens to URL search params via replaceState.
319
+ // Only sets params not already present (user-explicit wins on first load).
320
+ // Cleans up stale managed params when flow/prototype tokens change.
321
+ useEffect(() => {
322
+ const url = new URL(window.location.href)
323
+ const managed = managedParamsRef.current
324
+ const nextManaged = {}
325
+ let changed = false
326
+
327
+ // Remove stale managed params no longer in merged tokens
328
+ for (const key of Object.keys(managed)) {
329
+ if (!mergedTokens || !(key in mergedTokens)) {
330
+ url.searchParams.delete(key)
331
+ changed = true
332
+ }
333
+ }
334
+
335
+ // Apply current tokens
336
+ if (mergedTokens) {
337
+ const reserved = new Set(['flow', 'scene'])
338
+ for (const [key, value] of Object.entries(mergedTokens)) {
339
+ if (value == null || typeof value === 'object' || reserved.has(key)) continue
340
+ const strValue = String(value)
341
+ if (!url.searchParams.has(key) || (key in managed && managed[key] !== strValue)) {
342
+ url.searchParams.set(key, strValue)
343
+ nextManaged[key] = strValue
344
+ changed = true
345
+ } else if (key in managed) {
346
+ nextManaged[key] = strValue
347
+ }
348
+ }
349
+ }
350
+
351
+ managedParamsRef.current = nextManaged
352
+
353
+ if (changed) {
354
+ window.history.replaceState(window.history.state, '', url.toString())
355
+ }
356
+ }, [mergedTokens])
357
+
295
358
  // Canvas pages get their own rendering path — no flow data needed
296
359
  if (canvasId) {
297
360
  const canvasData = canvases?.[canvasId]
@@ -1038,6 +1038,8 @@ export default function storyboardDataPlugin() {
1038
1038
  // The iframe loads componentIsolate.jsx which reads query params
1039
1039
  // (module, export, theme) and renders a single story export.
1040
1040
  const isolateEntryPath = new URL('../canvas/componentIsolate.jsx', import.meta.url).pathname
1041
+ // Component-set isolate — renders all exports in a grid, bypassing the full SPA.
1042
+ const componentSetIsolateEntryPath = new URL('../canvas/componentSetIsolate.jsx', import.meta.url).pathname
1041
1043
  server.middlewares.use(async (req, res, next) => {
1042
1044
  if (!req.url) return next()
1043
1045
  let url = req.url
@@ -1045,15 +1047,19 @@ export default function storyboardDataPlugin() {
1045
1047
  if (baseNoTrail && url.startsWith(baseNoTrail)) {
1046
1048
  url = url.slice(baseNoTrail.length) || '/'
1047
1049
  }
1048
- if (!url.startsWith('/_storyboard/canvas/isolate')) return next()
1050
+ // Match both single-component and component-set isolate routes
1051
+ const isComponentSet = url.startsWith('/_storyboard/canvas/isolate-set')
1052
+ const isSingle = !isComponentSet && url.startsWith('/_storyboard/canvas/isolate')
1053
+ if (!isSingle && !isComponentSet) return next()
1049
1054
 
1055
+ const entryPath = isComponentSet ? componentSetIsolateEntryPath : isolateEntryPath
1050
1056
  const rawHtml = [
1051
1057
  '<!DOCTYPE html>',
1052
1058
  '<html><head>',
1053
1059
  '<style>html,body{margin:0;padding:0;width:100%;height:100%;background:var(--bgColor-default,transparent)}#root{width:100%;height:100%}</style>',
1054
1060
  '</head><body>',
1055
1061
  '<div id="root"></div>',
1056
- `<script type="module" src="/@fs${isolateEntryPath}"></script>`,
1062
+ `<script type="module" src="/@fs${entryPath}"></script>`,
1057
1063
  '</body></html>',
1058
1064
  ].join('\n')
1059
1065