@dfosco/storyboard-react 2.0.0 → 2.2.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/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-react",
3
- "version": "2.0.0",
3
+ "version": "2.2.0",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@dfosco/storyboard-core": "*",
6
+ "@dfosco/storyboard-core": "2.2.0",
7
7
  "glob": "^11.0.0",
8
8
  "jsonc-parser": "^3.3.1"
9
9
  },
@@ -1,227 +1,60 @@
1
1
 
2
- import { useState, useEffect, useMemo } from 'react'
3
- import { hash, resolveSceneRoute, getSceneMeta } from '@dfosco/storyboard-core'
4
- import styles from './Viewfinder.module.css'
5
-
6
- function formatSceneName(name) {
7
- return name
8
- .split('-')
9
- .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
10
- .join(' ')
11
- }
12
-
13
- function PlaceholderGraphic({ name }) {
14
- const seed = hash(name)
15
- const rects = []
16
-
17
- for (let i = 0; i < 12; i++) {
18
- const s = seed * (i + 1)
19
- const x = (s * 7 + i * 31) % 320
20
- const y = (s * 13 + i * 17) % 200
21
- const w = 20 + (s * (i + 3)) % 80
22
- const h = 8 + (s * (i + 7)) % 40
23
- const opacity = 0.06 + ((s * (i + 2)) % 20) / 100
24
- const fill = i % 3 === 0 ? 'var(--placeholder-accent)' : i % 3 === 1 ? 'var(--placeholder-fg)' : 'var(--placeholder-muted)'
25
-
26
- rects.push(
27
- <rect
28
- key={i}
29
- x={x}
30
- y={y}
31
- width={w}
32
- height={h}
33
- rx={2}
34
- fill={fill}
35
- opacity={opacity}
36
- />
37
- )
38
- }
39
-
40
- const lines = []
41
- for (let i = 0; i < 6; i++) {
42
- const s = seed * (i + 5)
43
- const y = 10 + (s % 180)
44
- lines.push(
45
- <line
46
- key={`h${i}`}
47
- x1={0}
48
- y1={y}
49
- x2={320}
50
- y2={y}
51
- stroke="var(--placeholder-grid)"
52
- strokeWidth={0.5}
53
- opacity={0.4}
54
- />
55
- )
56
- }
57
- for (let i = 0; i < 8; i++) {
58
- const s = seed * (i + 9)
59
- const x = 10 + (s % 300)
60
- lines.push(
61
- <line
62
- key={`v${i}`}
63
- x1={x}
64
- y1={0}
65
- x2={x}
66
- y2={200}
67
- stroke="var(--placeholder-grid)"
68
- strokeWidth={0.5}
69
- opacity={0.3}
70
- />
71
- )
72
- }
73
-
74
- return (
75
- <svg viewBox="0 0 320 200" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
76
- <rect width="320" height="200" fill="var(--placeholder-bg)" />
77
- {lines}
78
- {rects}
79
- </svg>
80
- )
81
- }
2
+ import { useRef, useEffect } from 'react'
82
3
 
83
4
  /**
84
- * Derive the current branch label from the base path.
85
- * Branch deploy folders use the convention `branch--<name>/`.
86
- */
87
- function getCurrentBranch(basePath) {
88
- const match = (basePath || '').match(/\/branch--([^/]+)\/?$/)
89
- return match ? match[1] : 'main'
90
- }
91
-
92
- /**
93
- * Viewfinder — scene index and branch preview dashboard.
5
+ * Viewfinder thin React wrapper around the Svelte Viewfinder component.
6
+ *
7
+ * Mounts the core Svelte Viewfinder into a container div and manages
8
+ * its lifecycle via React's useEffect.
94
9
  *
95
10
  * @param {Object} props
96
- * @param {Record<string, unknown>} props.scenes - Scene index object (keys are scene names)
97
- * @param {Record<string, unknown>} props.pageModules - import.meta.glob result for page files
98
- * @param {string} [props.basePath] - Base URL path (defaults to import.meta.env.BASE_URL)
99
- * @param {string} [props.title] - Header title (defaults to "Viewfinder")
100
- * @param {string} [props.subtitle] - Optional subtitle displayed below the title
101
- * @param {boolean} [props.showThumbnails] - Show thumbnail previews (defaults to false)
102
- * @param {boolean} [props.hideDefaultScene] - Hide the "default" scene from the list (defaults to false)
11
+ * @param {Record<string, unknown>} [props.scenes] - Scene/flow index (deprecated, ignored data comes from core)
12
+ * @param {Record<string, unknown>} [props.flows] - Flow index (deprecated, ignored — data comes from core)
13
+ * @param {Record<string, unknown>} [props.pageModules] - import.meta.glob result for page files
14
+ * @param {string} [props.basePath] - Base URL path
15
+ * @param {string} [props.title] - Header title
16
+ * @param {string} [props.subtitle] - Optional subtitle
17
+ * @param {boolean} [props.showThumbnails] - Show thumbnail previews
18
+ * @param {boolean} [props.hideDefaultFlow] - Hide the "default" flow from the "Other flows" section
103
19
  */
104
- export default function Viewfinder({ scenes = {}, pageModules = {}, basePath, title = 'Viewfinder', subtitle, showThumbnails = false, hideDefaultScene = false }) {
105
- const [branches, setBranches] = useState(null)
106
-
107
- const sceneNames = useMemo(() => {
108
- const names = Object.keys(scenes)
109
- return hideDefaultScene ? names.filter(n => n !== 'default') : names
110
- }, [scenes, hideDefaultScene])
111
-
112
- const knownRoutes = useMemo(() =>
113
- Object.keys(pageModules)
114
- .map(p => p.replace('/src/pages/', '').replace('.jsx', ''))
115
- .filter(n => !n.startsWith('_') && n !== 'index' && n !== 'viewfinder'),
116
- [pageModules]
117
- )
20
+ export default function Viewfinder({ pageModules = {}, basePath, title = 'Storyboard', subtitle, showThumbnails = false, hideDefaultFlow, hideDefaultScene = false }) {
21
+ const containerRef = useRef(null)
22
+ const handleRef = useRef(null)
118
23
 
119
- const branchBasePath = useMemo(() => {
120
- const base = basePath || '/storyboard-source/'
121
- return base.replace(/\/branch--[^/]*\/$/, '/')
122
- }, [basePath])
24
+ const shouldHideDefault = hideDefaultFlow ?? hideDefaultScene
123
25
 
124
- const currentBranch = useMemo(() => getCurrentBranch(basePath), [basePath])
125
-
126
- const MOCK_BRANCHES = useMemo(() => [
127
- { branch: 'main', folder: '' },
128
- { branch: 'feat/comments-v2', folder: 'branch--feat-comments-v2' },
129
- { branch: 'fix/nav-overflow', folder: 'branch--fix-nav-overflow' },
130
- ], [])
26
+ const knownRoutes = Object.keys(pageModules)
27
+ .map(p => p.replace('/src/prototypes/', '').replace('.jsx', ''))
28
+ .filter(n => !n.startsWith('_') && n !== 'index' && n !== 'viewfinder')
131
29
 
132
30
  useEffect(() => {
133
- const url = `${branchBasePath}branches.json`
134
- fetch(url)
135
- .then(r => r.ok ? r.json() : null)
136
- .then(data => setBranches(Array.isArray(data) && data.length > 0 ? data : MOCK_BRANCHES))
137
- .catch(() => setBranches(MOCK_BRANCHES))
138
- }, [branchBasePath, MOCK_BRANCHES])
139
-
140
- const handleBranchChange = (e) => {
141
- const folder = e.target.value
142
- if (folder) {
143
- window.location.href = `${branchBasePath}${folder}/`
31
+ if (!containerRef.current) return
32
+
33
+ let cancelled = false
34
+
35
+ import('@dfosco/storyboard-core/ui/viewfinder').then(({ mountViewfinder, unmountViewfinder }) => {
36
+ if (cancelled) return
37
+ // Ensure clean state for re-mounts
38
+ unmountViewfinder()
39
+ handleRef.current = mountViewfinder(containerRef.current, {
40
+ title,
41
+ subtitle,
42
+ basePath,
43
+ knownRoutes,
44
+ showThumbnails,
45
+ hideDefaultFlow: shouldHideDefault,
46
+ })
47
+ })
48
+
49
+ return () => {
50
+ cancelled = true
51
+ if (handleRef.current) {
52
+ handleRef.current.destroy()
53
+ handleRef.current = null
54
+ }
144
55
  }
145
- }
56
+ }, [title, subtitle, basePath, showThumbnails, shouldHideDefault])
146
57
 
147
- return (
148
- <div className={styles.container}>
149
- <header className={styles.header}>
150
- <div className={styles.headerTop}>
151
- <div>
152
- <h1 className={styles.title}>{title}</h1>
153
- {subtitle && <p className={styles.subtitle}>{subtitle}</p>}
154
- </div>
155
- {branches && branches.length > 0 && (
156
- <div className={styles.branchDropdown}>
157
- <svg className={styles.branchIcon} width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
158
- <path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.492 2.492 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z" />
159
- </svg>
160
- <select
161
- id="branch-select"
162
- className={styles.branchSelect}
163
- defaultValue=""
164
- onChange={handleBranchChange}
165
- aria-label="Switch branch"
166
- >
167
- <option value="" disabled>{currentBranch}</option>
168
- {branches.map((b) => (
169
- <option key={b.folder} value={b.folder}>
170
- {b.branch}
171
- </option>
172
- ))}
173
- </select>
174
- </div>
175
- )}
176
- </div>
177
- <p className={styles.sceneCount}>
178
- {sceneNames.length} scene{sceneNames.length !== 1 ? 's' : ''}
179
- </p>
180
- </header>
181
-
182
- {sceneNames.length === 0 ? (
183
- <p className={styles.empty}>No scenes found. Add a <code>*.scene.json</code> file to get started.</p>
184
- ) : (
185
- <section>
186
- {/* <h2 className={styles.sectionTitle}>Scenes</h2> */}
187
- <div className={showThumbnails ? styles.grid : styles.list}>
188
- {sceneNames.map((name) => {
189
- const meta = getSceneMeta(name)
190
- const displayName = meta?.title || meta?.name || formatSceneName(name)
191
- return (
192
- <a key={name} href={resolveSceneRoute(name, knownRoutes)} className={showThumbnails ? styles.card : styles.listItem}>
193
- {showThumbnails && (
194
- <div className={styles.thumbnail}>
195
- <PlaceholderGraphic name={name} />
196
- </div>
197
- )}
198
- <div className={styles.cardBody}>
199
- <p className={styles.sceneName}>{displayName}</p>
200
- {meta?.author && (() => {
201
- const authors = Array.isArray(meta.author) ? meta.author : [meta.author]
202
- return (
203
- <div className={styles.author}>
204
- <span className={styles.authorAvatars}>
205
- {authors.map((a) => (
206
- <img
207
- key={a}
208
- src={`https://github.com/${a}.png?size=32`}
209
- alt={a}
210
- className={styles.authorAvatar}
211
- />
212
- ))}
213
- </span>
214
- <span className={styles.authorName}>{authors.join(', ')}</span>
215
- </div>
216
- )
217
- })()}
218
- </div>
219
- </a>
220
- )
221
- })}
222
- </div>
223
- </section>
224
- )}
225
- </div>
226
- )
58
+ return <div ref={containerRef} style={{ minHeight: '100vh' }} />
227
59
  }
60
+
package/src/context.jsx CHANGED
@@ -2,16 +2,28 @@ import { useEffect, useMemo } from 'react'
2
2
  import { useParams, useLocation } from 'react-router-dom'
3
3
  // Side-effect import: seeds the core data index via init()
4
4
  import 'virtual:storyboard-data-index'
5
- import { loadScene, sceneExists, findRecord, deepMerge, setSceneClass, installBodyClassSync } from '@dfosco/storyboard-core'
5
+ import { loadFlow, flowExists, findRecord, deepMerge, setFlowClass, installBodyClassSync, resolveFlowName, resolveRecordName, isModesEnabled } from '@dfosco/storyboard-core'
6
6
  import { StoryboardContext } from './StoryboardContext.js'
7
7
 
8
8
  export { StoryboardContext }
9
9
 
10
10
  /**
11
- * Derives a scene name from a pathname.
11
+ * Derives the top-level prototype name from a pathname.
12
+ * "/Dashboard" → "Dashboard", "/Dashboard/sub" → "Dashboard"
13
+ * "/posts/123" → "posts", "/" → null
14
+ */
15
+ function getPrototypeName(pathname) {
16
+ const path = pathname.replace(/\/+$/, '') || '/'
17
+ if (path === '/') return null
18
+ const segments = path.split('/').filter(Boolean)
19
+ return segments[0] || null
20
+ }
21
+
22
+ /**
23
+ * Derives a flow name from a pathname.
12
24
  * "/Overview" → "Overview", "/" → "index", "/nested/Page" → "Page"
13
25
  */
14
- function getPageSceneName(pathname) {
26
+ function getPageFlowName(pathname) {
15
27
  const path = pathname.replace(/\/+$/, '') || '/'
16
28
  if (path === '/') return 'index'
17
29
  const last = path.split('/').pop()
@@ -19,51 +31,92 @@ function getPageSceneName(pathname) {
19
31
  }
20
32
 
21
33
  /**
22
- * Provides loaded scene data to the component tree.
23
- * Reads the scene name from the ?scene= URL param, the sceneName prop,
24
- * a matching scene file for the current page, or defaults to "default".
34
+ * Provides loaded flow data to the component tree.
35
+ * Reads the flow name from the ?flow= URL param (with ?scene= as alias),
36
+ * a matching flow file for the current page, or defaults to "default".
37
+ *
38
+ * Derives the prototype scope from the route and uses it to resolve
39
+ * scoped flow and record names (e.g. "Dashboard/default" for /Dashboard).
25
40
  *
26
41
  * Optionally merges record data when `recordName` and `recordParam` are provided.
27
- * The matched record entry is injected under the "record" key in scene data.
42
+ * The matched record entry is injected under the "record" key in flow data.
28
43
  */
29
- export default function StoryboardProvider({ sceneName, recordName, recordParam, children }) {
44
+ export default function StoryboardProvider({ flowName, sceneName, recordName, recordParam, children }) {
30
45
  const location = useLocation()
31
- const sceneParam = new URLSearchParams(location.search).get('scene')
32
- const pageScene = getPageSceneName(location.pathname)
33
- const activeSceneName = sceneParam || sceneName || (sceneExists(pageScene) ? pageScene : 'default')
46
+ const searchParams = new URLSearchParams(location.search)
47
+ const sceneParam = searchParams.get('flow') || searchParams.get('scene')
48
+ const prototypeName = getPrototypeName(location.pathname)
49
+ const pageFlow = getPageFlowName(location.pathname)
34
50
  const params = useParams()
35
51
 
52
+ // Resolve flow name with prototype scoping
53
+ const activeFlowName = useMemo(() => {
54
+ const requested = sceneParam || flowName || sceneName
55
+ if (requested) {
56
+ return resolveFlowName(prototypeName, requested)
57
+ }
58
+ // 1. Page-specific flow (e.g., Example/Forms)
59
+ const scopedPageFlow = resolveFlowName(prototypeName, pageFlow)
60
+ if (flowExists(scopedPageFlow)) return scopedPageFlow
61
+ // 2. Prototype flow — named after the prototype folder (e.g., Example/example)
62
+ if (prototypeName) {
63
+ const protoFlow = resolveFlowName(prototypeName, prototypeName)
64
+ if (flowExists(protoFlow)) return protoFlow
65
+ }
66
+ // 3. Global default
67
+ return 'default'
68
+ }, [sceneParam, flowName, sceneName, prototypeName, pageFlow])
69
+
36
70
  // Auto-install body class sync (sb-key--value classes on <body>)
37
71
  useEffect(() => installBodyClassSync(), [])
38
72
 
73
+ // Mount design modes UI when enabled in storyboard.config.json
74
+ useEffect(() => {
75
+ if (!isModesEnabled()) return
76
+
77
+ let cleanup
78
+ import('@dfosco/storyboard-core/ui/design-modes')
79
+ .then(({ mountDesignModesUI }) => {
80
+ cleanup = mountDesignModesUI()
81
+ })
82
+ .catch(() => {
83
+ // Svelte UI not available — degrade gracefully
84
+ })
85
+
86
+ return () => cleanup?.()
87
+ }, [])
88
+
39
89
  const { data, error } = useMemo(() => {
40
90
  try {
41
- let sceneData = loadScene(activeSceneName)
91
+ let flowData = loadFlow(activeFlowName)
42
92
 
43
- // Merge record data if configured
93
+ // Merge record data if configured (with scoped resolution)
44
94
  if (recordName && recordParam && params[recordParam]) {
45
- const entry = findRecord(recordName, params[recordParam])
95
+ const resolvedRecord = resolveRecordName(prototypeName, recordName)
96
+ const entry = findRecord(resolvedRecord, params[recordParam])
46
97
  if (entry) {
47
- sceneData = deepMerge(sceneData, { record: entry })
98
+ flowData = deepMerge(flowData, { record: entry })
48
99
  }
49
100
  }
50
101
 
51
- setSceneClass(activeSceneName)
52
- return { data: sceneData, error: null }
102
+ setFlowClass(activeFlowName)
103
+ return { data: flowData, error: null }
53
104
  } catch (err) {
54
105
  return { data: null, error: err.message }
55
106
  }
56
- }, [activeSceneName, recordName, recordParam, params])
107
+ }, [activeFlowName, recordName, recordParam, params, prototypeName])
57
108
 
58
109
  const value = {
59
110
  data,
60
111
  error,
61
112
  loading: false,
62
- sceneName: activeSceneName,
113
+ flowName: activeFlowName,
114
+ sceneName: activeFlowName, // backward compat
115
+ prototypeName,
63
116
  }
64
117
 
65
118
  if (error) {
66
- return <span style={{ color: 'var(--fgColor-danger, #f85149)' }}>Error loading scene: {error}</span>
119
+ return <span style={{ color: 'var(--fgColor-danger, #f85149)' }}>Error loading flow: {error}</span>
67
120
  }
68
121
 
69
122
  return (
@@ -17,7 +17,7 @@ vi.mock('react-router-dom', async () => {
17
17
 
18
18
  beforeEach(() => {
19
19
  init({
20
- scenes: {
20
+ flows: {
21
21
  default: { title: 'Default Scene' },
22
22
  other: { title: 'Other Scene' },
23
23
  },
@@ -36,7 +36,7 @@ function ContextReader({ path }) {
36
36
  }
37
37
 
38
38
  describe('StoryboardProvider', () => {
39
- it('renders children when scene loads successfully', () => {
39
+ it('renders children when flow loads successfully', () => {
40
40
  render(
41
41
  <StoryboardProvider>
42
42
  <span>child content</span>
@@ -45,7 +45,7 @@ describe('StoryboardProvider', () => {
45
45
  expect(screen.getByText('child content')).toBeInTheDocument()
46
46
  })
47
47
 
48
- it('provides scene data via context', () => {
48
+ it('provides flow data via context', () => {
49
49
  render(
50
50
  <StoryboardProvider>
51
51
  <ContextReader path="title" />
@@ -54,7 +54,16 @@ describe('StoryboardProvider', () => {
54
54
  expect(screen.getByTestId('ctx')).toHaveTextContent('Default Scene')
55
55
  })
56
56
 
57
- it('uses sceneName prop when provided', () => {
57
+ it('uses flowName prop when provided', () => {
58
+ render(
59
+ <StoryboardProvider flowName="other">
60
+ <ContextReader path="title" />
61
+ </StoryboardProvider>,
62
+ )
63
+ expect(screen.getByTestId('ctx')).toHaveTextContent('Other Scene')
64
+ })
65
+
66
+ it('uses sceneName prop for backward compat', () => {
58
67
  render(
59
68
  <StoryboardProvider sceneName="other">
60
69
  <ContextReader path="title" />
@@ -63,7 +72,7 @@ describe('StoryboardProvider', () => {
63
72
  expect(screen.getByTestId('ctx')).toHaveTextContent('Other Scene')
64
73
  })
65
74
 
66
- it("falls back to 'default' scene when no ?scene= param", () => {
75
+ it("falls back to 'default' flow when no ?scene= param", () => {
67
76
  render(
68
77
  <StoryboardProvider>
69
78
  <ContextReader path="title" />
@@ -72,22 +81,35 @@ describe('StoryboardProvider', () => {
72
81
  expect(screen.getByTestId('ctx')).toHaveTextContent('Default Scene')
73
82
  })
74
83
 
75
- it('shows error message when scene fails to load', () => {
84
+ it('shows error message when flow fails to load', () => {
76
85
  render(
77
- <StoryboardProvider sceneName="nonexistent">
86
+ <StoryboardProvider flowName="nonexistent">
78
87
  <ContextReader />
79
88
  </StoryboardProvider>,
80
89
  )
81
- expect(screen.getByText(/Error loading scene/)).toBeInTheDocument()
90
+ expect(screen.getByText(/Error loading flow/)).toBeInTheDocument()
82
91
  })
83
92
 
84
- it('provides sceneName in context value', () => {
93
+ it('provides flowName in context value', () => {
94
+ function FlowNameReader() {
95
+ const ctx = useContext(StoryboardContext)
96
+ return <span data-testid="name">{ctx?.flowName}</span>
97
+ }
98
+ render(
99
+ <StoryboardProvider flowName="other">
100
+ <FlowNameReader />
101
+ </StoryboardProvider>,
102
+ )
103
+ expect(screen.getByTestId('name')).toHaveTextContent('other')
104
+ })
105
+
106
+ it('provides sceneName (backward compat) in context value', () => {
85
107
  function SceneNameReader() {
86
108
  const ctx = useContext(StoryboardContext)
87
109
  return <span data-testid="name">{ctx?.sceneName}</span>
88
110
  }
89
111
  render(
90
- <StoryboardProvider sceneName="other">
112
+ <StoryboardProvider flowName="other">
91
113
  <SceneNameReader />
92
114
  </StoryboardProvider>,
93
115
  )
@@ -107,9 +129,9 @@ describe('StoryboardProvider', () => {
107
129
  expect(screen.getByTestId('loading')).toHaveTextContent('false')
108
130
  })
109
131
 
110
- it('auto-matches scene by pathname and resolves $ref data', () => {
132
+ it('auto-matches flow by pathname and resolves $ref data', () => {
111
133
  init({
112
- scenes: {
134
+ flows: {
113
135
  default: { title: 'Default' },
114
136
  Repositories: {
115
137
  '$global': ['navigation'],
@@ -133,9 +155,9 @@ describe('StoryboardProvider', () => {
133
155
  expect(screen.getByTestId('ctx')).toHaveTextContent('All repos')
134
156
  })
135
157
 
136
- it('resolves $ref objects when auto-matching scene by pathname', () => {
158
+ it('resolves $ref objects when auto-matching flow by pathname', () => {
137
159
  init({
138
- scenes: {
160
+ flows: {
139
161
  default: { title: 'Default' },
140
162
  Repositories: {
141
163
  '$global': ['navigation'],
@@ -165,7 +187,18 @@ describe('StoryboardProvider', () => {
165
187
  expect(screen.getByTestId('nav')).toHaveTextContent('Home,Repos')
166
188
  })
167
189
 
168
- it('reads ?scene= param from location.search', () => {
190
+ it('reads ?flow= param from location.search', () => {
191
+ mockUseLocation.mockReturnValue({ pathname: '/whatever', search: '?flow=other', hash: '' })
192
+
193
+ render(
194
+ <StoryboardProvider>
195
+ <ContextReader path="title" />
196
+ </StoryboardProvider>,
197
+ )
198
+ expect(screen.getByTestId('ctx')).toHaveTextContent('Other Scene')
199
+ })
200
+
201
+ it('reads ?scene= as alias for ?flow=', () => {
169
202
  mockUseLocation.mockReturnValue({ pathname: '/whatever', search: '?scene=other', hash: '' })
170
203
 
171
204
  render(
@@ -175,4 +208,76 @@ describe('StoryboardProvider', () => {
175
208
  )
176
209
  expect(screen.getByTestId('ctx')).toHaveTextContent('Other Scene')
177
210
  })
211
+
212
+ it('prefers ?flow= over ?scene= when both present', () => {
213
+ mockUseLocation.mockReturnValue({ pathname: '/whatever', search: '?flow=other&scene=default', hash: '' })
214
+
215
+ render(
216
+ <StoryboardProvider>
217
+ <ContextReader path="title" />
218
+ </StoryboardProvider>,
219
+ )
220
+ expect(screen.getByTestId('ctx')).toHaveTextContent('Other Scene')
221
+ })
222
+
223
+ it('loads prototype flow for sub-pages when no page-specific flow exists', () => {
224
+ init({
225
+ flows: {
226
+ default: { title: 'Global Default' },
227
+ 'Example/example': { title: 'Example Flow' },
228
+ },
229
+ objects: {},
230
+ records: {},
231
+ })
232
+
233
+ // /Example/Forms — no Forms flow exists, should fall back to Example/example
234
+ mockUseLocation.mockReturnValue({ pathname: '/Example/Forms', search: '', hash: '' })
235
+
236
+ render(
237
+ <StoryboardProvider>
238
+ <ContextReader path="title" />
239
+ </StoryboardProvider>,
240
+ )
241
+ expect(screen.getByTestId('ctx')).toHaveTextContent('Example Flow')
242
+ })
243
+
244
+ it('page-specific flow takes priority over prototype flow', () => {
245
+ init({
246
+ flows: {
247
+ default: { title: 'Global Default' },
248
+ 'Example/example': { title: 'Example Flow' },
249
+ 'Example/Forms': { title: 'Forms Flow' },
250
+ },
251
+ objects: {},
252
+ records: {},
253
+ })
254
+
255
+ mockUseLocation.mockReturnValue({ pathname: '/Example/Forms', search: '', hash: '' })
256
+
257
+ render(
258
+ <StoryboardProvider>
259
+ <ContextReader path="title" />
260
+ </StoryboardProvider>,
261
+ )
262
+ expect(screen.getByTestId('ctx')).toHaveTextContent('Forms Flow')
263
+ })
264
+
265
+ it('falls to global default when no prototype flow exists', () => {
266
+ init({
267
+ flows: {
268
+ default: { title: 'Global Default' },
269
+ },
270
+ objects: {},
271
+ records: {},
272
+ })
273
+
274
+ mockUseLocation.mockReturnValue({ pathname: '/NoProto/SomePage', search: '', hash: '' })
275
+
276
+ render(
277
+ <StoryboardProvider>
278
+ <ContextReader path="title" />
279
+ </StoryboardProvider>,
280
+ )
281
+ expect(screen.getByTestId('ctx')).toHaveTextContent('Global Default')
282
+ })
178
283
  })