@dfosco/storyboard-react 2.0.0 → 2.1.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.1.0",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@dfosco/storyboard-core": "*",
6
+ "@dfosco/storyboard-core": "2.1.0",
7
7
  "glob": "^11.0.0",
8
8
  "jsonc-parser": "^3.3.1"
9
9
  },
@@ -1,227 +1,58 @@
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
- }
82
-
83
- /**
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
- }
2
+ import { useRef, useEffect } from 'react'
91
3
 
92
4
  /**
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.hideDefaultScene] - Hide the "default" flow
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, 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])
123
-
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
- ], [])
24
+ const knownRoutes = Object.keys(pageModules)
25
+ .map(p => p.replace('/src/prototypes/', '').replace('.jsx', ''))
26
+ .filter(n => !n.startsWith('_') && n !== 'index' && n !== 'viewfinder')
131
27
 
132
28
  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}/`
29
+ if (!containerRef.current) return
30
+
31
+ let cancelled = false
32
+
33
+ import('@dfosco/storyboard-core/ui/viewfinder').then(({ mountViewfinder, unmountViewfinder }) => {
34
+ if (cancelled) return
35
+ // Ensure clean state for re-mounts
36
+ unmountViewfinder()
37
+ handleRef.current = mountViewfinder(containerRef.current, {
38
+ title,
39
+ subtitle,
40
+ basePath,
41
+ knownRoutes,
42
+ showThumbnails,
43
+ hideDefaultFlow: hideDefaultScene,
44
+ })
45
+ })
46
+
47
+ return () => {
48
+ cancelled = true
49
+ if (handleRef.current) {
50
+ handleRef.current.destroy()
51
+ handleRef.current = null
52
+ }
144
53
  }
145
- }
54
+ }, [title, subtitle, basePath, showThumbnails, hideDefaultScene])
146
55
 
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
- )
56
+ return <div ref={containerRef} style={{ minHeight: '100vh' }} />
227
57
  }
58
+
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,91 @@ 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 ?scene= URL param, the flowName prop,
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
46
  const sceneParam = new URLSearchParams(location.search).get('scene')
32
- const pageScene = getPageSceneName(location.pathname)
33
- const activeSceneName = sceneParam || sceneName || (sceneExists(pageScene) ? pageScene : 'default')
47
+ const prototypeName = getPrototypeName(location.pathname)
48
+ const pageFlow = getPageFlowName(location.pathname)
34
49
  const params = useParams()
35
50
 
51
+ // Resolve flow name with prototype scoping
52
+ const activeFlowName = useMemo(() => {
53
+ const requested = sceneParam || flowName || sceneName
54
+ if (requested) {
55
+ return resolveFlowName(prototypeName, requested)
56
+ }
57
+ // 1. Page-specific flow (e.g., Example/Forms)
58
+ const scopedPageFlow = resolveFlowName(prototypeName, pageFlow)
59
+ if (flowExists(scopedPageFlow)) return scopedPageFlow
60
+ // 2. Prototype flow — named after the prototype folder (e.g., Example/example)
61
+ if (prototypeName) {
62
+ const protoFlow = resolveFlowName(prototypeName, prototypeName)
63
+ if (flowExists(protoFlow)) return protoFlow
64
+ }
65
+ // 3. Global default
66
+ return 'default'
67
+ }, [sceneParam, flowName, sceneName, prototypeName, pageFlow])
68
+
36
69
  // Auto-install body class sync (sb-key--value classes on <body>)
37
70
  useEffect(() => installBodyClassSync(), [])
38
71
 
72
+ // Mount design modes UI when enabled in storyboard.config.json
73
+ useEffect(() => {
74
+ if (!isModesEnabled()) return
75
+
76
+ let cleanup
77
+ import('@dfosco/storyboard-core/ui/design-modes')
78
+ .then(({ mountDesignModesUI }) => {
79
+ cleanup = mountDesignModesUI()
80
+ })
81
+ .catch(() => {
82
+ // Svelte UI not available — degrade gracefully
83
+ })
84
+
85
+ return () => cleanup?.()
86
+ }, [])
87
+
39
88
  const { data, error } = useMemo(() => {
40
89
  try {
41
- let sceneData = loadScene(activeSceneName)
90
+ let flowData = loadFlow(activeFlowName)
42
91
 
43
- // Merge record data if configured
92
+ // Merge record data if configured (with scoped resolution)
44
93
  if (recordName && recordParam && params[recordParam]) {
45
- const entry = findRecord(recordName, params[recordParam])
94
+ const resolvedRecord = resolveRecordName(prototypeName, recordName)
95
+ const entry = findRecord(resolvedRecord, params[recordParam])
46
96
  if (entry) {
47
- sceneData = deepMerge(sceneData, { record: entry })
97
+ flowData = deepMerge(flowData, { record: entry })
48
98
  }
49
99
  }
50
100
 
51
- setSceneClass(activeSceneName)
52
- return { data: sceneData, error: null }
101
+ setFlowClass(activeFlowName)
102
+ return { data: flowData, error: null }
53
103
  } catch (err) {
54
104
  return { data: null, error: err.message }
55
105
  }
56
- }, [activeSceneName, recordName, recordParam, params])
106
+ }, [activeFlowName, recordName, recordParam, params, prototypeName])
57
107
 
58
108
  const value = {
59
109
  data,
60
110
  error,
61
111
  loading: false,
62
- sceneName: activeSceneName,
112
+ flowName: activeFlowName,
113
+ sceneName: activeFlowName, // backward compat
114
+ prototypeName,
63
115
  }
64
116
 
65
117
  if (error) {
66
- return <span style={{ color: 'var(--fgColor-danger, #f85149)' }}>Error loading scene: {error}</span>
118
+ return <span style={{ color: 'var(--fgColor-danger, #f85149)' }}>Error loading flow: {error}</span>
67
119
  }
68
120
 
69
121
  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()
91
+ })
92
+
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')
82
104
  })
83
105
 
84
- it('provides sceneName in context value', () => {
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'],
@@ -175,4 +197,65 @@ describe('StoryboardProvider', () => {
175
197
  )
176
198
  expect(screen.getByTestId('ctx')).toHaveTextContent('Other Scene')
177
199
  })
200
+
201
+ it('loads prototype flow for sub-pages when no page-specific flow exists', () => {
202
+ init({
203
+ flows: {
204
+ default: { title: 'Global Default' },
205
+ 'Example/example': { title: 'Example Flow' },
206
+ },
207
+ objects: {},
208
+ records: {},
209
+ })
210
+
211
+ // /Example/Forms — no Forms flow exists, should fall back to Example/example
212
+ mockUseLocation.mockReturnValue({ pathname: '/Example/Forms', search: '', hash: '' })
213
+
214
+ render(
215
+ <StoryboardProvider>
216
+ <ContextReader path="title" />
217
+ </StoryboardProvider>,
218
+ )
219
+ expect(screen.getByTestId('ctx')).toHaveTextContent('Example Flow')
220
+ })
221
+
222
+ it('page-specific flow takes priority over prototype flow', () => {
223
+ init({
224
+ flows: {
225
+ default: { title: 'Global Default' },
226
+ 'Example/example': { title: 'Example Flow' },
227
+ 'Example/Forms': { title: 'Forms Flow' },
228
+ },
229
+ objects: {},
230
+ records: {},
231
+ })
232
+
233
+ mockUseLocation.mockReturnValue({ pathname: '/Example/Forms', search: '', hash: '' })
234
+
235
+ render(
236
+ <StoryboardProvider>
237
+ <ContextReader path="title" />
238
+ </StoryboardProvider>,
239
+ )
240
+ expect(screen.getByTestId('ctx')).toHaveTextContent('Forms Flow')
241
+ })
242
+
243
+ it('falls to global default when no prototype flow exists', () => {
244
+ init({
245
+ flows: {
246
+ default: { title: 'Global Default' },
247
+ },
248
+ objects: {},
249
+ records: {},
250
+ })
251
+
252
+ mockUseLocation.mockReturnValue({ pathname: '/NoProto/SomePage', search: '', hash: '' })
253
+
254
+ render(
255
+ <StoryboardProvider>
256
+ <ContextReader path="title" />
257
+ </StoryboardProvider>,
258
+ )
259
+ expect(screen.getByTestId('ctx')).toHaveTextContent('Global Default')
260
+ })
178
261
  })
@@ -1,11 +1,12 @@
1
- import { useMemo, useSyncExternalStore } from 'react'
1
+ import { useContext, useMemo, useSyncExternalStore } from 'react'
2
2
  import { useParams } from 'react-router-dom'
3
- import { loadRecord } from '@dfosco/storyboard-core'
3
+ import { loadRecord, resolveRecordName } from '@dfosco/storyboard-core'
4
4
  import { deepClone, setByPath } from '@dfosco/storyboard-core'
5
5
  import { getAllParams } from '@dfosco/storyboard-core'
6
6
  import { isHideMode, getAllShadows } from '@dfosco/storyboard-core'
7
7
  import { subscribeToHash, getHashSnapshot } from '@dfosco/storyboard-core'
8
8
  import { subscribeToStorage, getStorageSnapshot } from '@dfosco/storyboard-core'
9
+ import { StoryboardContext } from '../StoryboardContext.js'
9
10
 
10
11
  /**
11
12
  * Collect overrides for a record and merge them into the base array.
@@ -91,6 +92,8 @@ function applyRecordOverrides(baseRecords, recordName) {
91
92
  export function useRecord(recordName, paramName = 'id') {
92
93
  const params = useParams()
93
94
  const paramValue = params[paramName]
95
+ const context = useContext(StoryboardContext)
96
+ const prototypeName = context?.prototypeName ?? null
94
97
 
95
98
  // Re-render on hash or localStorage changes so overrides are reactive
96
99
  const hashString = useSyncExternalStore(subscribeToHash, getHashSnapshot)
@@ -99,14 +102,15 @@ export function useRecord(recordName, paramName = 'id') {
99
102
  return useMemo(() => {
100
103
  if (!paramValue) return null
101
104
  try {
102
- const base = loadRecord(recordName)
103
- const merged = applyRecordOverrides(base, recordName)
105
+ const resolvedName = resolveRecordName(prototypeName, recordName)
106
+ const base = loadRecord(resolvedName)
107
+ const merged = applyRecordOverrides(base, resolvedName)
104
108
  return merged.find(e => e[paramName] === paramValue) ?? null
105
109
  } catch (err) {
106
110
  console.error(`[useRecord] ${err.message}`)
107
111
  return null
108
112
  }
109
- }, [recordName, paramName, paramValue, hashString, storageString]) // eslint-disable-line react-hooks/exhaustive-deps
113
+ }, [recordName, paramName, paramValue, prototypeName, hashString, storageString]) // eslint-disable-line react-hooks/exhaustive-deps
110
114
  }
111
115
 
112
116
  /**
@@ -121,17 +125,21 @@ export function useRecord(recordName, paramName = 'id') {
121
125
  * const allPosts = useRecords('posts')
122
126
  */
123
127
  export function useRecords(recordName) {
128
+ const context = useContext(StoryboardContext)
129
+ const prototypeName = context?.prototypeName ?? null
130
+
124
131
  // Re-render on hash or localStorage changes so overrides are reactive
125
132
  const hashString = useSyncExternalStore(subscribeToHash, getHashSnapshot)
126
133
  const storageString = useSyncExternalStore(subscribeToStorage, getStorageSnapshot)
127
134
 
128
135
  return useMemo(() => {
129
136
  try {
130
- const base = loadRecord(recordName)
131
- return applyRecordOverrides(base, recordName)
137
+ const resolvedName = resolveRecordName(prototypeName, recordName)
138
+ const base = loadRecord(resolvedName)
139
+ return applyRecordOverrides(base, resolvedName)
132
140
  } catch (err) {
133
141
  console.error(`[useRecords] ${err.message}`)
134
142
  return []
135
143
  }
136
- }, [recordName, hashString, storageString]) // eslint-disable-line react-hooks/exhaustive-deps
144
+ }, [recordName, prototypeName, hashString, storageString]) // eslint-disable-line react-hooks/exhaustive-deps
137
145
  }