@dfosco/storyboard-react 4.0.0-beta.9 → 4.0.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.
Files changed (75) hide show
  1. package/package.json +6 -3
  2. package/src/AuthModal/AuthModal.jsx +134 -0
  3. package/src/AuthModal/AuthModal.module.css +221 -0
  4. package/src/BranchBar/BranchBar.jsx +56 -0
  5. package/src/BranchBar/BranchBar.module.css +230 -0
  6. package/src/BranchBar/useBranches.js +79 -0
  7. package/src/CommandPalette/CommandPalette.jsx +936 -0
  8. package/src/CommandPalette/CreateDialog.jsx +219 -0
  9. package/src/CommandPalette/command-palette.css +111 -0
  10. package/src/Icon.jsx +180 -0
  11. package/src/Viewfinder.jsx +1104 -57
  12. package/src/Viewfinder.module.css +1107 -149
  13. package/src/canvas/CanvasControls.jsx +51 -2
  14. package/src/canvas/CanvasControls.module.css +31 -0
  15. package/src/canvas/CanvasPage.bridge.test.jsx +142 -19
  16. package/src/canvas/CanvasPage.dragdrop.test.jsx +346 -0
  17. package/src/canvas/CanvasPage.jsx +807 -251
  18. package/src/canvas/CanvasPage.module.css +98 -50
  19. package/src/canvas/CanvasPage.multiselect.test.jsx +13 -11
  20. package/src/canvas/CanvasToolbar.jsx +2 -2
  21. package/src/canvas/MarqueeOverlay.jsx +20 -0
  22. package/src/canvas/PageSelector.jsx +239 -0
  23. package/src/canvas/PageSelector.module.css +165 -0
  24. package/src/canvas/PageSelector.test.jsx +104 -0
  25. package/src/canvas/canvasApi.js +22 -8
  26. package/src/canvas/canvasTheme.js +96 -52
  27. package/src/canvas/componentIsolate.jsx +33 -7
  28. package/src/canvas/useCanvas.js +9 -8
  29. package/src/canvas/useCanvas.test.js +4 -4
  30. package/src/canvas/useMarqueeSelect.js +187 -0
  31. package/src/canvas/useMarqueeSelect.test.js +78 -0
  32. package/src/canvas/widgets/CodePenEmbed.jsx +292 -0
  33. package/src/canvas/widgets/CodePenEmbed.module.css +161 -0
  34. package/src/canvas/widgets/ComponentWidget.jsx +42 -10
  35. package/src/canvas/widgets/ComponentWidget.module.css +6 -5
  36. package/src/canvas/widgets/FigmaEmbed.jsx +110 -24
  37. package/src/canvas/widgets/FigmaEmbed.module.css +21 -7
  38. package/src/canvas/widgets/LinkPreview.jsx +297 -11
  39. package/src/canvas/widgets/LinkPreview.module.css +386 -18
  40. package/src/canvas/widgets/LinkPreview.test.jsx +193 -0
  41. package/src/canvas/widgets/MarkdownBlock.jsx +86 -5
  42. package/src/canvas/widgets/MarkdownBlock.module.css +64 -15
  43. package/src/canvas/widgets/PrototypeEmbed.jsx +96 -145
  44. package/src/canvas/widgets/PrototypeEmbed.module.css +74 -4
  45. package/src/canvas/widgets/StickyNote.module.css +5 -0
  46. package/src/canvas/widgets/StickyNote.test.jsx +9 -9
  47. package/src/canvas/widgets/StoryWidget.jsx +277 -0
  48. package/src/canvas/widgets/StoryWidget.module.css +211 -0
  49. package/src/canvas/widgets/WidgetChrome.jsx +76 -20
  50. package/src/canvas/widgets/WidgetChrome.module.css +2 -6
  51. package/src/canvas/widgets/WidgetWrapper.module.css +2 -0
  52. package/src/canvas/widgets/codepenUrl.js +75 -0
  53. package/src/canvas/widgets/codepenUrl.test.js +76 -0
  54. package/src/canvas/widgets/embedInteraction.test.jsx +235 -0
  55. package/src/canvas/widgets/embedOverlay.module.css +35 -0
  56. package/src/canvas/widgets/embedTheme.js +138 -39
  57. package/src/canvas/widgets/githubUrl.js +82 -0
  58. package/src/canvas/widgets/githubUrl.test.js +74 -0
  59. package/src/canvas/widgets/iframeDevLogs.js +49 -0
  60. package/src/canvas/widgets/iframeDevLogs.test.jsx +81 -0
  61. package/src/canvas/widgets/index.js +4 -0
  62. package/src/canvas/widgets/pasteRules.js +295 -0
  63. package/src/canvas/widgets/pasteRules.test.js +474 -0
  64. package/src/canvas/widgets/snapshotDisplay.test.jsx +259 -0
  65. package/src/canvas/widgets/widgetConfig.js +16 -5
  66. package/src/canvas/widgets/widgetConfig.test.js +34 -12
  67. package/src/context.jsx +145 -16
  68. package/src/hooks/useSceneData.js +4 -2
  69. package/src/hooks/useThemeState.js +61 -0
  70. package/src/hooks/useThemeState.test.js +66 -0
  71. package/src/index.js +10 -0
  72. package/src/story/StoryPage.jsx +117 -0
  73. package/src/story/StoryPage.module.css +18 -0
  74. package/src/vite/data-plugin.js +348 -66
  75. package/src/vite/data-plugin.test.js +405 -5
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Tests for iframe snapshot display — single snapshot prop.
3
+ */
4
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
5
+ import { render, fireEvent, waitFor, act } from '@testing-library/react'
6
+ import PrototypeEmbed from './PrototypeEmbed.jsx'
7
+ import StoryWidget from './StoryWidget.jsx'
8
+
9
+ vi.mock('@dfosco/storyboard-core', () => ({
10
+ buildPrototypeIndex: () => ({
11
+ folders: [],
12
+ prototypes: [
13
+ {
14
+ name: 'Design Overview',
15
+ dirName: 'examples',
16
+ isExternal: false,
17
+ hideFlows: true,
18
+ flows: [{ route: '/test', name: 'default', meta: { title: 'Design Overview' } }],
19
+ },
20
+ ],
21
+ globalFlows: [],
22
+ sorted: { title: { prototypes: [], folders: [] } },
23
+ }),
24
+ getStoryData: (storyId) => ({ _route: `/components/${storyId}` }),
25
+ }))
26
+
27
+ vi.mock('./WidgetWrapper.jsx', () => ({
28
+ default: ({ children }) => <div data-testid="widget-wrapper">{children}</div>,
29
+ }))
30
+
31
+ vi.mock('@dfosco/storyboard-core/inspector/highlighter', () => ({
32
+ createInspectorHighlighter: async () => ({
33
+ codeToHtml: () => '<pre><code></code></pre>',
34
+ }),
35
+ }), { virtual: true })
36
+
37
+ vi.mock('./ResizeHandle.jsx', () => ({
38
+ default: () => <div data-testid="resize-handle" />,
39
+ }))
40
+
41
+ /**
42
+ * Render inside a wrapper with data-sb-canvas-theme, matching the real
43
+ * CanvasPage DOM structure that subscribeCanvasTheme reads from.
44
+ */
45
+ function renderInCanvas(ui, theme = 'light') {
46
+ const wrapper = document.createElement('div')
47
+ wrapper.setAttribute('data-sb-canvas-theme', theme)
48
+ document.body.appendChild(wrapper)
49
+ const result = render(ui, { container: wrapper })
50
+ return { ...result, wrapper }
51
+ }
52
+
53
+ afterEach(() => {
54
+ document.querySelectorAll('[data-sb-canvas-theme]').forEach(el => el.remove())
55
+ })
56
+
57
+ describe('Snapshot display', () => {
58
+ describe('PrototypeEmbed', () => {
59
+ it('shows snapshot image when valid snapshot prop exists', () => {
60
+ const { wrapper } = renderInCanvas(
61
+ <PrototypeEmbed
62
+ id="proto-abc123"
63
+ props={{
64
+ src: '/test',
65
+ width: 400,
66
+ height: 300,
67
+ zoom: 100,
68
+ snapshot: '/_storyboard/canvas/images/snapshot-proto-abc123.webp?v=123',
69
+ }}
70
+ onUpdate={vi.fn()}
71
+ resizable={false}
72
+ />
73
+ )
74
+
75
+ const img = wrapper.querySelector('img')
76
+ expect(img).toBeInTheDocument()
77
+ expect(img.src).toContain('snapshot-proto-abc123.webp')
78
+ expect(wrapper.querySelector('iframe')).not.toBeInTheDocument()
79
+ })
80
+
81
+ it('falls back to snapshotLight for backward compat', () => {
82
+ const { wrapper } = renderInCanvas(
83
+ <PrototypeEmbed
84
+ id="proto-abc123"
85
+ props={{
86
+ src: '/test',
87
+ width: 400,
88
+ height: 300,
89
+ zoom: 100,
90
+ snapshotLight: '/_storyboard/canvas/images/snapshot-proto-abc123--light.webp?v=1',
91
+ }}
92
+ onUpdate={vi.fn()}
93
+ resizable={false}
94
+ />
95
+ )
96
+
97
+ const img = wrapper.querySelector('img')
98
+ expect(img).toBeInTheDocument()
99
+ expect(img.src).toContain('snapshot-proto-abc123--light.webp')
100
+ })
101
+
102
+ it('shows placeholder when no snapshot exists', () => {
103
+ const { wrapper } = renderInCanvas(
104
+ <PrototypeEmbed
105
+ id="proto-xyz"
106
+ props={{ src: '/test', width: 400, height: 300, zoom: 100 }}
107
+ onUpdate={vi.fn()}
108
+ resizable={false}
109
+ />
110
+ )
111
+
112
+ expect(wrapper.querySelector('img')).not.toBeInTheDocument()
113
+ expect(wrapper.querySelector('iframe')).not.toBeInTheDocument()
114
+ })
115
+
116
+ it('falls back to placeholder when snapshot image fails to load', () => {
117
+ const { wrapper } = renderInCanvas(
118
+ <PrototypeEmbed
119
+ id="proto-abc123"
120
+ props={{
121
+ src: '/test',
122
+ width: 400,
123
+ height: 300,
124
+ zoom: 100,
125
+ snapshot: '/_storyboard/canvas/images/snapshot-proto-abc123.webp?v=123',
126
+ }}
127
+ onUpdate={vi.fn()}
128
+ resizable={false}
129
+ />
130
+ )
131
+
132
+ const img = wrapper.querySelector('img')
133
+ expect(img).toBeInTheDocument()
134
+ fireEvent.error(img)
135
+ expect(wrapper.querySelector('img')).not.toBeInTheDocument()
136
+ })
137
+
138
+ it('ignores snapshot that does not match widget ID', () => {
139
+ const { wrapper } = renderInCanvas(
140
+ <PrototypeEmbed
141
+ id="proto-abc123"
142
+ props={{
143
+ src: '/test',
144
+ width: 400,
145
+ height: 300,
146
+ zoom: 100,
147
+ snapshot: '/_storyboard/canvas/images/snapshot-other-widget.webp?v=123',
148
+ }}
149
+ onUpdate={vi.fn()}
150
+ resizable={false}
151
+ />
152
+ )
153
+
154
+ expect(wrapper.querySelector('img')).not.toBeInTheDocument()
155
+ })
156
+
157
+ it('does not show snapshot for external URLs', () => {
158
+ const { wrapper } = renderInCanvas(
159
+ <PrototypeEmbed
160
+ id="proto-ext"
161
+ props={{
162
+ src: 'https://example.com',
163
+ width: 400,
164
+ height: 300,
165
+ zoom: 100,
166
+ snapshot: '/_storyboard/canvas/images/snapshot-proto-ext.webp?v=123',
167
+ }}
168
+ onUpdate={vi.fn()}
169
+ resizable={false}
170
+ />
171
+ )
172
+
173
+ expect(wrapper.querySelector('img')).not.toBeInTheDocument()
174
+ })
175
+ })
176
+
177
+ describe('StoryWidget', () => {
178
+ it('shows snapshot image when valid snapshot prop exists', () => {
179
+ const { wrapper } = renderInCanvas(
180
+ <StoryWidget
181
+ id="story-abc123"
182
+ props={{
183
+ storyId: 'button-patterns',
184
+ exportName: 'Primary',
185
+ width: 400,
186
+ height: 300,
187
+ snapshot: '/_storyboard/canvas/images/snapshot-story-abc123.webp?v=456',
188
+ }}
189
+ onUpdate={vi.fn()}
190
+ resizable={false}
191
+ />
192
+ )
193
+
194
+ const img = wrapper.querySelector('img')
195
+ expect(img).toBeInTheDocument()
196
+ expect(img.src).toContain('snapshot-story-abc123.webp')
197
+ expect(wrapper.querySelector('iframe')).not.toBeInTheDocument()
198
+ })
199
+
200
+ it('falls back to snapshotDark for backward compat', () => {
201
+ const { wrapper } = renderInCanvas(
202
+ <StoryWidget
203
+ id="story-abc123"
204
+ props={{
205
+ storyId: 'button-patterns',
206
+ snapshotDark: '/_storyboard/canvas/images/snapshot-story-abc123--dark.webp?v=1',
207
+ }}
208
+ onUpdate={vi.fn()}
209
+ resizable={false}
210
+ />
211
+ )
212
+
213
+ const img = wrapper.querySelector('img')
214
+ expect(img).toBeInTheDocument()
215
+ expect(img.src).toContain('snapshot-story-abc123--dark.webp')
216
+ })
217
+
218
+ it('shows placeholder when no snapshot exists', () => {
219
+ const { wrapper } = renderInCanvas(
220
+ <StoryWidget
221
+ id="story-xyz"
222
+ props={{
223
+ storyId: 'button-patterns',
224
+ exportName: 'Primary',
225
+ width: 400,
226
+ height: 300,
227
+ }}
228
+ onUpdate={vi.fn()}
229
+ resizable={false}
230
+ />
231
+ )
232
+
233
+ expect(wrapper.querySelector('img')).not.toBeInTheDocument()
234
+ expect(wrapper.querySelector('iframe')).not.toBeInTheDocument()
235
+ })
236
+
237
+ it('falls back to placeholder when snapshot image fails to load', () => {
238
+ const { wrapper } = renderInCanvas(
239
+ <StoryWidget
240
+ id="story-abc123"
241
+ props={{
242
+ storyId: 'button-patterns',
243
+ exportName: 'Primary',
244
+ width: 400,
245
+ height: 300,
246
+ snapshot: '/_storyboard/canvas/images/snapshot-story-abc123.webp?v=456',
247
+ }}
248
+ onUpdate={vi.fn()}
249
+ resizable={false}
250
+ />
251
+ )
252
+
253
+ const img = wrapper.querySelector('img')
254
+ expect(img).toBeInTheDocument()
255
+ fireEvent.error(img)
256
+ expect(wrapper.querySelector('img')).not.toBeInTheDocument()
257
+ })
258
+ })
259
+ })
@@ -34,7 +34,16 @@ function resolveFeature(feature) {
34
34
  if (key === 'items' && Array.isArray(val)) {
35
35
  resolved[key] = val.map((item) => {
36
36
  const r = {}
37
- for (const [k, v] of Object.entries(item)) r[k] = resolveVar(v)
37
+ for (const [k, v] of Object.entries(item)) {
38
+ // Resolve nested alt object inside items
39
+ if (k === 'alt' && v && typeof v === 'object') {
40
+ const altResolved = {}
41
+ for (const [ak, av] of Object.entries(v)) altResolved[ak] = resolveVar(av)
42
+ r[k] = altResolved
43
+ } else {
44
+ r[k] = resolveVar(v)
45
+ }
46
+ }
38
47
  return r
39
48
  })
40
49
  } else if (key === 'alt' && val && typeof val === 'object') {
@@ -103,14 +112,16 @@ export const widgetTypes = buildWidgetTypes()
103
112
 
104
113
  /**
105
114
  * Get the feature list for a widget type.
106
- * In production, only features with `prod: true` are returned.
115
+ * In production (or when isLocalDev is false, e.g. ?prodMode simulation),
116
+ * only features with `prod: true` are returned.
107
117
  * In dev, all features are returned.
108
118
  * @param {string} type — widget type string
119
+ * @param {{ isLocalDev?: boolean }} [options]
109
120
  * @returns {Array} features array from config (variables resolved), or empty array
110
121
  */
111
- export function getFeatures(type) {
122
+ export function getFeatures(type, { isLocalDev = true } = {}) {
112
123
  const features = widgetTypes[type]?.features ?? []
113
- if (import.meta.env?.PROD) {
124
+ if (import.meta.env?.PROD || !isLocalDev) {
114
125
  return features.filter(f => f.prod)
115
126
  }
116
127
  return features
@@ -146,6 +157,6 @@ export function getWidgetMeta(type) {
146
157
  */
147
158
  export function getMenuWidgetTypes() {
148
159
  return Object.entries(widgetTypes)
149
- .filter(([type]) => type !== 'link-preview' && type !== 'image' && type !== 'figma-embed')
160
+ .filter(([type]) => type !== 'link-preview' && type !== 'image' && type !== 'figma-embed' && type !== 'codepen-embed' && type !== 'story')
150
161
  .map(([type, def]) => ({ type, label: def.label, icon: def.icon }))
151
162
  }
@@ -2,21 +2,24 @@ import { describe, expect, it } from 'vitest'
2
2
  import { isResizable, getFeatures, getWidgetMeta } from './widgetConfig.js'
3
3
 
4
4
  describe('isResizable', () => {
5
- // Vitest runs with import.meta.env.PROD = true, so prod: false widgets
6
- // correctly return false. This tests the production behavior.
7
- it('returns false for resize-enabled widgets when prod is false (production env)', () => {
8
- expect(isResizable('sticky-note')).toBe(false)
9
- expect(isResizable('prototype')).toBe(false)
10
- expect(isResizable('figma-embed')).toBe(false)
11
- expect(isResizable('image')).toBe(false)
12
- expect(isResizable('component')).toBe(false)
13
- })
14
-
15
- it('returns false for widget types with resize disabled', () => {
16
- expect(isResizable('markdown')).toBe(false)
5
+ // Vitest runs in dev mode by default (import.meta.env.PROD = false)
6
+ // In dev mode, all resize-enabled widgets are resizable
7
+ it('returns true for resize-enabled widgets in dev mode', () => {
8
+ expect(isResizable('sticky-note')).toBe(true)
9
+ expect(isResizable('prototype')).toBe(true)
10
+ expect(isResizable('figma-embed')).toBe(true)
11
+ expect(isResizable('image')).toBe(true)
12
+ expect(isResizable('component')).toBe(true)
13
+ })
14
+
15
+ it('returns false for link-preview (resize disabled)', () => {
17
16
  expect(isResizable('link-preview')).toBe(false)
18
17
  })
19
18
 
19
+ it('returns true for markdown (resize enabled, dev only)', () => {
20
+ expect(isResizable('markdown')).toBe(true)
21
+ })
22
+
20
23
  it('returns false for unknown widget types', () => {
21
24
  expect(isResizable('nonexistent')).toBe(false)
22
25
  })
@@ -32,6 +35,25 @@ describe('getFeatures', () => {
32
35
  it('returns empty array for unknown widget types', () => {
33
36
  expect(getFeatures('nonexistent')).toEqual([])
34
37
  })
38
+
39
+ it('returns only prod features when isLocalDev is false', () => {
40
+ const features = getFeatures('figma-embed', { isLocalDev: false })
41
+ expect(features.length).toBeGreaterThan(0)
42
+ expect(features.every(f => f.prod === true)).toBe(true)
43
+ })
44
+
45
+ it('returns all features when isLocalDev is true (default)', () => {
46
+ const allFeatures = getFeatures('figma-embed')
47
+ const prodFeatures = getFeatures('figma-embed', { isLocalDev: false })
48
+ expect(allFeatures.length).toBeGreaterThan(prodFeatures.length)
49
+ })
50
+
51
+ it('includes menu-only prod features when isLocalDev is false', () => {
52
+ const features = getFeatures('figma-embed', { isLocalDev: false })
53
+ const menuFeature = features.find(f => f.menu)
54
+ expect(menuFeature).toBeDefined()
55
+ expect(menuFeature.prod).toBe(true)
56
+ })
35
57
  })
36
58
 
37
59
  describe('getWidgetMeta', () => {
package/src/context.jsx CHANGED
@@ -1,7 +1,7 @@
1
1
  import { useEffect, useMemo, Suspense, lazy } from 'react'
2
2
  import { useParams, useLocation } from 'react-router-dom'
3
- // Named import seeds the core data index via init() AND provides canvas route data
4
- import { canvases } from 'virtual:storyboard-data-index'
3
+ // Named import seeds the core data index via init() AND provides canvas/story route data
4
+ import { canvases, canvasAliases, stories } from 'virtual:storyboard-data-index'
5
5
  import { loadFlow, flowExists, findRecord, deepMerge, setFlowClass, installBodyClassSync, resolveFlowName, resolveRecordName, isModesEnabled } from '@dfosco/storyboard-core'
6
6
  import { StoryboardContext } from './StoryboardContext.js'
7
7
  import styles from './FlowError.module.css'
@@ -9,24 +9,77 @@ import styles from './FlowError.module.css'
9
9
  export { StoryboardContext }
10
10
 
11
11
  const CanvasPageLazy = lazy(() => import('./canvas/CanvasPage.jsx'))
12
+ const StoryPageLazy = lazy(() => import('./story/StoryPage.jsx'))
12
13
 
13
14
  // Build a map from canvas route paths → canvas names at module load time
14
15
  const canvasRouteMap = new Map()
16
+ // Build a map from group name → array of { name, route, title } for page selector
17
+ const canvasGroupMap = new Map()
15
18
  for (const [name, data] of Object.entries(canvases || {})) {
16
- const route = (data?._route || `/${name}`).replace(/\/+$/, '')
19
+ const route = (data?._route || `/canvas/${name}`).replace(/\/+$/, '')
17
20
  canvasRouteMap.set(route, name)
21
+ const group = data?._group
22
+ if (group) {
23
+ if (!canvasGroupMap.has(group)) canvasGroupMap.set(group, [])
24
+ canvasGroupMap.get(group).push({
25
+ name,
26
+ route,
27
+ title: data?.title || name.split('/').pop(),
28
+ _canvasMeta: data?._canvasMeta || null,
29
+ })
30
+ }
31
+ }
32
+
33
+ // Build a map from story route paths → story names at module load time
34
+ const storyRouteMap = new Map()
35
+ for (const [name, data] of Object.entries(stories || {})) {
36
+ if (data?._route) {
37
+ const route = data._route.replace(/\/+$/, '')
38
+ storyRouteMap.set(route, name)
39
+ }
18
40
  }
19
41
 
20
42
  function matchCanvasRoute(pathname) {
21
- const normalized = pathname.replace(/\/+$/, '') || '/'
43
+ const normalized = stripBasePath(pathname)
22
44
  return canvasRouteMap.get(normalized) || null
23
45
  }
24
46
 
47
+ function matchStoryRoute(pathname) {
48
+ const normalized = stripBasePath(pathname)
49
+ return storyRouteMap.get(normalized) || null
50
+ }
51
+
52
+ /**
53
+ * Strip the app's sub-path prefix (e.g. /storyboard) from the pathname.
54
+ * React Router's basename strips the branch prefix but not the app name prefix
55
+ * when the app runs under a nested base path.
56
+ */
57
+ function stripBasePath(pathname) {
58
+ let p = pathname.replace(/\/+$/, '') || '/'
59
+ // BASE_URL includes branch prefix + app path (e.g. /branch--name/storyboard/)
60
+ // React Router strips the branch prefix but may leave the app sub-path
61
+ const base = (import.meta.env?.BASE_URL || '/').replace(/\/+$/, '')
62
+ if (base && base !== '/') {
63
+ // Extract just the last segment(s) after the branch prefix
64
+ const withoutBranch = base.replace(/^\/branch--[^/]+/, '')
65
+ const subPath = withoutBranch.replace(/\/+$/, '')
66
+ if (subPath && p.startsWith(subPath)) {
67
+ p = p.slice(subPath.length) || '/'
68
+ }
69
+ }
70
+ return p
71
+ }
72
+
25
73
  function isCanvasPath(pathname) {
26
- const normalized = pathname.replace(/\/+$/, '') || '/'
74
+ const normalized = stripBasePath(pathname)
27
75
  return normalized === '/canvas' || normalized.startsWith('/canvas/')
28
76
  }
29
77
 
78
+ function isStoryPath(pathname) {
79
+ const normalized = stripBasePath(pathname)
80
+ return normalized === '/components' || normalized.startsWith('/components/')
81
+ }
82
+
30
83
  /**
31
84
  * Derives the top-level prototype name from a pathname.
32
85
  * "/Dashboard" → "Dashboard", "/Dashboard/sub" → "Dashboard"
@@ -66,10 +119,17 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
66
119
  const params = useParams()
67
120
 
68
121
  // Canvas route detection — matches current URL against registered canvas routes
69
- const canvasName = useMemo(() => matchCanvasRoute(location.pathname), [location.pathname])
122
+ const canvasId = useMemo(() => matchCanvasRoute(location.pathname), [location.pathname])
70
123
  const isMissingCanvasRoute = useMemo(
71
- () => isCanvasPath(location.pathname) && !canvasName,
72
- [location.pathname, canvasName],
124
+ () => isCanvasPath(location.pathname) && !canvasId && !matchStoryRoute(location.pathname),
125
+ [location.pathname, canvasId],
126
+ )
127
+
128
+ // Story route detection — matches current URL against registered story routes
129
+ const storyName = useMemo(() => matchStoryRoute(location.pathname), [location.pathname])
130
+ const isMissingStoryRoute = useMemo(
131
+ () => isStoryPath(location.pathname) && !storyName,
132
+ [location.pathname, storyName],
73
133
  )
74
134
 
75
135
  const searchParams = new URLSearchParams(location.search)
@@ -77,9 +137,9 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
77
137
  const prototypeName = getPrototypeName(location.pathname)
78
138
  const pageFlow = getPageFlowName(location.pathname)
79
139
 
80
- // Resolve flow name with prototype scoping (skip for canvas pages)
140
+ // Resolve flow name with prototype scoping (skip for canvas/story pages)
81
141
  const activeFlowName = useMemo(() => {
82
- if (canvasName || isMissingCanvasRoute) return null
142
+ if (canvasId || isMissingCanvasRoute || storyName || isMissingStoryRoute) return null
83
143
  const requested = sceneParam || flowName || sceneName
84
144
  if (requested) {
85
145
  // Allow fully-scoped flow names from URLs/widgets without re-prefixing
@@ -103,11 +163,32 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
103
163
  // 4. Global default — or null if no flow exists at all
104
164
  if (flowExists('default')) return 'default'
105
165
  return null
106
- }, [canvasName, isMissingCanvasRoute, sceneParam, flowName, sceneName, prototypeName, pageFlow])
166
+ }, [canvasId, isMissingCanvasRoute, storyName, isMissingStoryRoute, sceneParam, flowName, sceneName, prototypeName, pageFlow])
107
167
 
108
168
  // Auto-install body class sync (sb-key--value classes on <body>)
109
169
  useEffect(() => installBodyClassSync(), [])
110
170
 
171
+ // Update document.title to reflect the current artifact
172
+ useEffect(() => {
173
+ const base = import.meta.env?.BASE_URL || '/'
174
+ const branchMatch = base.match(/\/branch--([^/]+)/)
175
+ const branchSuffix = branchMatch ? ` (${branchMatch[1]})` : ''
176
+
177
+ let title
178
+ if (canvasId) {
179
+ const canvasData = canvases?.[canvasId]
180
+ const meta = canvasData?._canvasMeta
181
+ const pageTitle = canvasData?.title || canvasId.split('/').pop()
182
+ title = (meta?.title || pageTitle) + ' · Storyboard'
183
+ } else if (prototypeName) {
184
+ title = prototypeName + ' · Storyboard'
185
+ } else {
186
+ title = 'Storyboard'
187
+ }
188
+
189
+ document.title = title + branchSuffix
190
+ }, [canvasId, prototypeName])
191
+
111
192
  // Mount design modes UI when enabled in storyboard.config.json
112
193
  useEffect(() => {
113
194
  if (!isModesEnabled()) return
@@ -124,9 +205,9 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
124
205
  return () => cleanup?.()
125
206
  }, [])
126
207
 
127
- // Skip flow loading for canvas pages and flow-less pages
208
+ // Skip flow loading for canvas/story pages and flow-less pages
128
209
  const { data, error } = useMemo(() => {
129
- if (canvasName || isMissingCanvasRoute) return { data: null, error: null }
210
+ if (canvasId || isMissingCanvasRoute || storyName || isMissingStoryRoute) return { data: null, error: null }
130
211
  if (!activeFlowName) return { data: {}, error: null }
131
212
  try {
132
213
  let flowData = loadFlow(activeFlowName)
@@ -145,10 +226,18 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
145
226
  } catch (err) {
146
227
  return { data: null, error: err.message }
147
228
  }
148
- }, [canvasName, isMissingCanvasRoute, activeFlowName, recordName, recordParam, params, prototypeName])
229
+ }, [canvasId, isMissingCanvasRoute, storyName, isMissingStoryRoute, activeFlowName, recordName, recordParam, params, prototypeName])
149
230
 
150
231
  // Canvas pages get their own rendering path — no flow data needed
151
- if (canvasName) {
232
+ if (canvasId) {
233
+ const canvasData = canvases?.[canvasId]
234
+ const group = canvasData?._group
235
+ // Include the current canvas as a sibling even if it's the only page in its group,
236
+ // so the PageSelector can render and allow adding new pages.
237
+ const siblingPages = group
238
+ ? canvasGroupMap.get(group) || []
239
+ : [{ name: canvasId, route: canvasData?._route || `/canvas/${canvasId}`, title: canvasData?.title || canvasId.split('/').pop() }]
240
+ const canvasMeta = canvasData?._canvasMeta || null
152
241
  const canvasValue = {
153
242
  data: null,
154
243
  error: null,
@@ -160,7 +249,26 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
160
249
  return (
161
250
  <StoryboardContext.Provider value={canvasValue}>
162
251
  <Suspense fallback={null}>
163
- <CanvasPageLazy name={canvasName} />
252
+ <CanvasPageLazy canvasId={canvasId} siblingPages={siblingPages} canvasMeta={canvasMeta} />
253
+ </Suspense>
254
+ </StoryboardContext.Provider>
255
+ )
256
+ }
257
+
258
+ // Story pages get their own rendering path — no flow data needed
259
+ if (storyName) {
260
+ const storyValue = {
261
+ data: null,
262
+ error: null,
263
+ loading: false,
264
+ flowName: null,
265
+ sceneName: null,
266
+ prototypeName: null,
267
+ }
268
+ return (
269
+ <StoryboardContext.Provider value={storyValue}>
270
+ <Suspense fallback={null}>
271
+ <StoryPageLazy name={storyName} />
164
272
  </Suspense>
165
273
  </StoryboardContext.Provider>
166
274
  )
@@ -187,6 +295,27 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
187
295
  )
188
296
  }
189
297
 
298
+ if (isMissingStoryRoute) {
299
+ const currentUrl = `${location.pathname}${location.search}`
300
+ const truncatedUrl = currentUrl.length > 60
301
+ ? currentUrl.slice(0, 60) + '…'
302
+ : currentUrl
303
+
304
+ return (
305
+ <main className={styles.container}>
306
+ <div className={styles.banner}>
307
+ <strong>Story not found</strong>
308
+ No story matches this route.
309
+ </div>
310
+ <p className={styles.meta}>
311
+ Tried to open{' '}
312
+ <a href={currentUrl} title={currentUrl}>{truncatedUrl}</a>
313
+ </p>
314
+ <a className={styles.homeLink} href="/">← Go to index page</a>
315
+ </main>
316
+ )
317
+ }
318
+
190
319
  const value = {
191
320
  data,
192
321
  error,
@@ -17,10 +17,12 @@ import { subscribeToStorage, getStorageSnapshot } from '@dfosco/storyboard-core'
17
17
  *
18
18
  * @param {string} [path] - Dot-notation path (e.g. 'user.profile.name').
19
19
  * Omit to get the entire flow object.
20
+ * @param {{ optional?: boolean }} [opts] - Pass { optional: true } to suppress
21
+ * the "path not found" warning for optional data.
20
22
  * @returns {*} The resolved value. Returns {} if path is missing after loading.
21
23
  * @throws If used outside a StoryboardProvider.
22
24
  */
23
- export function useFlowData(path) {
25
+ export function useFlowData(path, opts) {
24
26
  const context = useContext(StoryboardContext)
25
27
 
26
28
  if (context === null) {
@@ -73,7 +75,7 @@ export function useFlowData(path) {
73
75
  }
74
76
 
75
77
  if (sceneValue === undefined) {
76
- if (data != null && Object.keys(data).length > 0) {
78
+ if (!opts?.optional && data != null && Object.keys(data).length > 0) {
77
79
  console.warn(`[useFlowData] Path "${path}" not found in flow data.`)
78
80
  }
79
81
  return {}