@dfosco/storyboard-core 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.
@@ -0,0 +1,17 @@
1
+ {
2
+ "modes": [
3
+ { "name": "prototype", "label": "Navigate" },
4
+ { "name": "inspect", "label": "Develop" },
5
+ { "name": "present", "label": "Collaborate" },
6
+ { "name": "plan", "label": "Canvas" }
7
+ ],
8
+ "tools": {
9
+ "*": [
10
+ { "id": "viewfinder", "label": "Viewfinder", "group": "dev" },
11
+ { "id": "scene-info", "label": "Show scene info", "group": "dev" },
12
+ { "id": "reset-params", "label": "Reset all params", "group": "dev" },
13
+ { "id": "hide-mode", "label": "Hide mode", "group": "dev" },
14
+ { "id": "feature-flags", "label": "Feature flags", "group": "dev" }
15
+ ]
16
+ }
17
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-core",
3
- "version": "2.0.0",
3
+ "version": "2.2.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -9,17 +9,22 @@
9
9
  "directory": "packages/core"
10
10
  },
11
11
  "files": [
12
- "src"
12
+ "src",
13
+ "modes.config.json"
13
14
  ],
14
15
  "exports": {
15
16
  ".": "./src/index.js",
17
+ "./modes.config.json": "./modes.config.json",
16
18
  "./vite/server": "./src/vite/server-plugin.js",
17
19
  "./comments": "./src/comments/index.js",
18
20
  "./comments/ui/comments.css": "./src/comments/ui/comments.css",
19
21
  "./workshop/ui/mount.js": "./src/workshop/ui/mount.js",
20
22
  "./modes.css": "./src/modes.css",
21
23
  "./svelte-plugin-ui": "./src/svelte-plugin-ui/index.ts",
22
- "./svelte-plugin-ui/design-modes": "./src/svelte-plugin-ui/plugins/design-modes.ts",
24
+ "./svelte-plugin-ui/design-modes": "./src/ui/design-modes.ts",
25
+ "./svelte-plugin-ui/viewfinder": "./src/ui/viewfinder.ts",
26
+ "./ui/design-modes": "./src/ui/design-modes.ts",
27
+ "./ui/viewfinder": "./src/ui/viewfinder.ts",
23
28
  "./svelte-plugin-ui/styles/base.css": "./src/svelte-plugin-ui/styles/base.css"
24
29
  },
25
30
  "dependencies": {
@@ -14,7 +14,7 @@ import { subscribeToStorage } from './localStorage.js'
14
14
  import { syncFlagBodyClasses } from './featureFlags.js'
15
15
 
16
16
  const PREFIX = 'sb-'
17
- const SCENE_PREFIX = 'sb-scene--'
17
+ const FLOW_PREFIX = 'sb-scene--'
18
18
  const FF_PREFIX = 'sb-ff-'
19
19
 
20
20
  /**
@@ -49,7 +49,7 @@ function overrideClass(key, value) {
49
49
  function getCurrentOverrideClasses() {
50
50
  const classes = new Set()
51
51
  for (const cls of document.body.classList) {
52
- if (cls.startsWith(PREFIX) && !cls.startsWith(SCENE_PREFIX) && !cls.startsWith(FF_PREFIX)) {
52
+ if (cls.startsWith(PREFIX) && !cls.startsWith(FLOW_PREFIX) && !cls.startsWith(FF_PREFIX)) {
53
53
  classes.add(cls)
54
54
  }
55
55
  }
@@ -86,21 +86,24 @@ export function syncOverrideClasses() {
86
86
  }
87
87
 
88
88
  /**
89
- * Set the scene class on <body>. Removes any previous scene class.
90
- * @param {string} name - Scene name (e.g. "Dashboard")
89
+ * Set the flow class on <body>. Removes any previous flow class.
90
+ * @param {string} name - Flow name (e.g. "Dashboard")
91
91
  */
92
- export function setSceneClass(name) {
93
- // Remove any existing scene classes
92
+ export function setFlowClass(name) {
93
+ // Remove any existing flow classes
94
94
  for (const cls of [...document.body.classList]) {
95
- if (cls.startsWith(SCENE_PREFIX)) {
95
+ if (cls.startsWith(FLOW_PREFIX)) {
96
96
  document.body.classList.remove(cls)
97
97
  }
98
98
  }
99
99
  if (name) {
100
- document.body.classList.add(`${SCENE_PREFIX}${sanitize(name)}`)
100
+ document.body.classList.add(`${FLOW_PREFIX}${sanitize(name)}`)
101
101
  }
102
102
  }
103
103
 
104
+ /** @deprecated Use setFlowClass() */
105
+ export const setSceneClass = setFlowClass
106
+
104
107
  /**
105
108
  * Install listeners that keep body classes in sync with overrides.
106
109
  * Subscribes to both hashchange (normal mode) and storage (hide mode).
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  syncOverrideClasses,
3
+ setFlowClass,
3
4
  setSceneClass,
4
5
  installBodyClassSync,
5
6
  } from './bodyClasses.js'
@@ -90,37 +91,50 @@ describe('Override body classes', () => {
90
91
  })
91
92
  })
92
93
 
93
- // ── Scene Classes ──
94
+ // ── Flow Classes ──
94
95
 
95
- describe('Scene body classes', () => {
96
+ describe('Flow body classes', () => {
96
97
  it('sets sb-scene-- class', () => {
97
- setSceneClass('Dashboard')
98
+ setFlowClass('Dashboard')
98
99
  expect(getSbClasses()).toContain('sb-scene--dashboard')
99
100
  })
100
101
 
101
- it('replaces previous scene class', () => {
102
- setSceneClass('Dashboard')
103
- setSceneClass('Settings')
102
+ it('replaces previous flow class', () => {
103
+ setFlowClass('Dashboard')
104
+ setFlowClass('Settings')
104
105
  expect(getSbClasses()).not.toContain('sb-scene--dashboard')
105
106
  expect(getSbClasses()).toContain('sb-scene--settings')
106
107
  })
107
108
 
108
- it('removes scene class when called with empty string', () => {
109
- setSceneClass('Dashboard')
110
- setSceneClass('')
111
- const sceneClasses = getSbClasses().filter((c) => c.startsWith('sb-scene--'))
112
- expect(sceneClasses).toEqual([])
109
+ it('removes flow class when called with empty string', () => {
110
+ setFlowClass('Dashboard')
111
+ setFlowClass('')
112
+ const flowClasses = getSbClasses().filter((c) => c.startsWith('sb-scene--'))
113
+ expect(flowClasses).toEqual([])
113
114
  })
114
115
 
115
116
  it('does not interfere with override classes', () => {
116
117
  window.location.hash = '#theme=dark'
117
118
  syncOverrideClasses()
118
- setSceneClass('Dashboard')
119
+ setFlowClass('Dashboard')
119
120
  expect(getSbClasses()).toContain('sb-theme--dark')
120
121
  expect(getSbClasses()).toContain('sb-scene--dashboard')
121
122
  })
122
123
  })
123
124
 
125
+ // ── setSceneClass (deprecated alias) ──
126
+
127
+ describe('setSceneClass (deprecated alias)', () => {
128
+ it('is the same function as setFlowClass', () => {
129
+ expect(setSceneClass).toBe(setFlowClass)
130
+ })
131
+
132
+ it('sets sb-scene-- class', () => {
133
+ setSceneClass('Dashboard')
134
+ expect(getSbClasses()).toContain('sb-scene--dashboard')
135
+ })
136
+ })
137
+
124
138
  // ── Hide Mode ──
125
139
 
126
140
  describe('Hide mode body classes', () => {
package/src/devtools.js CHANGED
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * Features:
7
7
  * - Floating beaker button (bottom-right) that opens a menu
8
- * - "Show scene info" — overlay panel with resolved scene JSON
8
+ * - "Show flow info" — overlay panel with resolved scene JSON
9
9
  * - "Reset all params" — clears all URL hash session params
10
10
  * - Cmd+. (Mac) / Ctrl+. (other) toggles visibility
11
11
  *
@@ -13,7 +13,7 @@
13
13
  * import { mountDevTools } from '@dfosco/storyboard-core'
14
14
  * mountDevTools() // call once at app startup
15
15
  */
16
- import { loadScene } from './loader.js'
16
+ import { loadFlow } from './loader.js'
17
17
  import { isCommentsEnabled } from './comments/config.js'
18
18
  import { isHideMode, activateHideMode, deactivateHideMode } from './hideMode.js'
19
19
  import { getAllFlags, toggleFlag, getFlagKeys } from './featureFlags.js'
@@ -180,8 +180,9 @@ const EYE_CLOSED_ICON = '<svg viewBox="0 0 16 16"><path d="M.143 2.31a.75.75 0 0
180
180
  const CHECK_ICON = '<svg viewBox="0 0 16 16"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"/></svg>'
181
181
  const ZAP_ICON = '<svg viewBox="0 0 16 16"><path d="M9.504.43a1.516 1.516 0 0 1 2.437 1.713L10.415 5.5h2.123c1.57 0 2.346 1.909 1.22 3.004l-7.34 7.142a1.249 1.249 0 0 1-.871.354h-.302a1.25 1.25 0 0 1-1.157-1.723L5.633 10.5H3.462c-1.57 0-2.346-1.909-1.22-3.004Z"/></svg>'
182
182
 
183
- function getSceneName() {
184
- return new URLSearchParams(window.location.search).get('scene') || 'default'
183
+ function getFlowName() {
184
+ const p = new URLSearchParams(window.location.search)
185
+ return p.get('flow') || p.get('scene') || 'default'
185
186
  }
186
187
 
187
188
  /**
@@ -235,7 +236,7 @@ export function mountDevTools(options = {}) {
235
236
 
236
237
  const showInfoBtn = document.createElement('button')
237
238
  showInfoBtn.className = 'sb-devtools-menu-item'
238
- showInfoBtn.innerHTML = `${INFO_ICON} Show scene info`
239
+ showInfoBtn.innerHTML = `${INFO_ICON} Show flow info`
239
240
 
240
241
  const resetBtn = document.createElement('button')
241
242
  resetBtn.className = 'sb-devtools-menu-item'
@@ -391,11 +392,11 @@ export function mountDevTools(options = {}) {
391
392
 
392
393
  if (overlay) overlay.remove()
393
394
 
394
- const sceneName = getSceneName()
395
+ const sceneName = getFlowName()
395
396
  let sceneJson = ''
396
397
  let error = null
397
398
  try {
398
- sceneJson = JSON.stringify(loadScene(sceneName), null, 2)
399
+ sceneJson = JSON.stringify(loadFlow(sceneName), null, 2)
399
400
  } catch (err) {
400
401
  error = err.message
401
402
  }
@@ -412,7 +413,7 @@ export function mountDevTools(options = {}) {
412
413
 
413
414
  const header = document.createElement('div')
414
415
  header.className = 'sb-devtools-panel-header'
415
- header.innerHTML = `<span class="sb-devtools-panel-title">Scene: ${sceneName}</span>`
416
+ header.innerHTML = `<span class="sb-devtools-panel-title">Flow: ${sceneName}</span>`
416
417
 
417
418
  const closeBtn = document.createElement('button')
418
419
  closeBtn.className = 'sb-devtools-panel-close'
@@ -1,7 +1,7 @@
1
1
  import { vi } from 'vitest'
2
2
 
3
3
  vi.mock('./loader.js', () => ({
4
- loadScene: vi.fn(() => ({ test: true })),
4
+ loadFlow: vi.fn(() => ({ test: true })),
5
5
  }))
6
6
 
7
7
  import { mountDevTools } from './devtools.js'
@@ -68,7 +68,7 @@ describe('mountDevTools', () => {
68
68
  expect(trigger.getAttribute('aria-label')).toBe('Storyboard DevTools')
69
69
  })
70
70
 
71
- it('contains a menu with scene info and reset buttons', () => {
71
+ it('contains a menu with flow info and reset buttons', () => {
72
72
  mountDevTools()
73
73
 
74
74
  const menuItems = document.body.querySelectorAll('.sb-devtools-menu-item')
package/src/index.js CHANGED
@@ -8,8 +8,14 @@
8
8
  // Data index initialization
9
9
  export { init } from './loader.js'
10
10
 
11
- // Scene, object & record loading
12
- export { loadScene, listScenes, sceneExists, loadRecord, findRecord, loadObject, deepMerge } from './loader.js'
11
+ // Flow, object & record loading
12
+ export { loadFlow, listFlows, flowExists, loadRecord, findRecord, loadObject, deepMerge } from './loader.js'
13
+ // Scoped name resolution
14
+ export { resolveFlowName, resolveRecordName } from './loader.js'
15
+ // Prototype metadata
16
+ export { listPrototypes, getPrototypeMetadata } from './loader.js'
17
+ // Deprecated scene aliases
18
+ export { loadScene, listScenes, sceneExists } from './loader.js'
13
19
 
14
20
  // Dot-notation path utilities
15
21
  export { getByPath, setByPath, deepClone } from './dotPath.js'
@@ -27,18 +33,27 @@ export { interceptHideParams, installHideParamListener } from './interceptHidePa
27
33
  // Hash change subscription (for reactive frameworks)
28
34
  export { subscribeToHash, getHashSnapshot } from './hashSubscribe.js'
29
35
 
30
- // Body class sync (overrides + scene → <body> classes)
31
- export { installBodyClassSync, setSceneClass, syncOverrideClasses } from './bodyClasses.js'
36
+ // Body class sync (overrides + flow → <body> classes)
37
+ export { installBodyClassSync, setFlowClass, syncOverrideClasses } from './bodyClasses.js'
38
+ // Deprecated alias
39
+ export { setSceneClass } from './bodyClasses.js'
32
40
 
33
41
  // Design modes (mode registry, switching, event bus)
34
42
  export { registerMode, unregisterMode, getRegisteredModes, getCurrentMode, activateMode, deactivateMode, subscribeToMode, getModeSnapshot, syncModeClasses, on, off, emit, initModesConfig, isModesEnabled } from './modes.js'
35
43
 
44
+ // Tool registry (declared in modes.config.json, state managed at runtime)
45
+ export { initTools, setToolAction, setToolState, getToolState, getToolsForMode, subscribeToTools, getToolsSnapshot } from './modes.js'
46
+
36
47
  // Dev tools (vanilla JS, framework-agnostic)
37
48
  export { mountDevTools } from './devtools.js'
49
+ export { mountFlowDebug } from './sceneDebug.js'
50
+ // Deprecated alias
38
51
  export { mountSceneDebug } from './sceneDebug.js'
39
52
 
40
53
  // Viewfinder utilities
41
- export { hash, resolveSceneRoute, getSceneMeta } from './viewfinder.js'
54
+ export { hash, resolveFlowRoute, getFlowMeta, buildPrototypeIndex } from './viewfinder.js'
55
+ // Deprecated aliases
56
+ export { resolveSceneRoute, getSceneMeta } from './viewfinder.js'
42
57
 
43
58
  // Feature flags
44
59
  export { initFeatureFlags, getFlag, setFlag, toggleFlag, getAllFlags, resetFlags, getFlagKeys, syncFlagBodyClasses } from './featureFlags.js'
package/src/loader.js CHANGED
@@ -28,24 +28,25 @@ function deepMerge(target, source) {
28
28
 
29
29
  /**
30
30
  * Module-level data index, seeded by init().
31
- * Shape: { scenes: {}, objects: {}, records: {} }
31
+ * Shape: { flows: {}, objects: {}, records: {} }
32
32
  */
33
- let dataIndex = { scenes: {}, objects: {}, records: {} }
33
+ let dataIndex = { flows: {}, objects: {}, records: {}, prototypes: {} }
34
34
 
35
35
  /**
36
36
  * Seed the data index. Call once at app startup before any load functions.
37
37
  * The Vite data plugin calls this automatically via the generated virtual module.
38
38
  *
39
- * @param {{ scenes: object, objects: object, records: object }} index
39
+ * @param {{ flows?: object, scenes?: object, objects: object, records: object, prototypes?: object }} index
40
40
  */
41
41
  export function init(index) {
42
42
  if (!index || typeof index !== 'object') {
43
- throw new Error('[storyboard-core] init() requires { scenes, objects, records }')
43
+ throw new Error('[storyboard-core] init() requires { flows, objects, records }')
44
44
  }
45
45
  dataIndex = {
46
- scenes: index.scenes || {},
46
+ flows: index.flows || index.scenes || {},
47
47
  objects: index.objects || {},
48
48
  records: index.records || {},
49
+ prototypes: index.prototypes || {},
49
50
  }
50
51
  }
51
52
 
@@ -63,24 +64,29 @@ function loadDataFile(name, type) {
63
64
 
64
65
  // Search all types if no specific type given
65
66
  if (!type) {
66
- for (const t of ['scenes', 'objects', 'records']) {
67
+ for (const t of ['flows', 'objects', 'records']) {
67
68
  if (dataIndex[t]?.[name] != null) {
68
69
  return dataIndex[t][name]
69
70
  }
70
71
  }
71
72
  }
72
73
 
73
- // Case-insensitive fallback for scenes
74
- if (type === 'scenes' || !type) {
74
+ // Case-insensitive fallback for flows
75
+ if (type === 'flows' || !type) {
75
76
  const lower = name.toLowerCase()
76
- for (const key of Object.keys(dataIndex.scenes)) {
77
+ for (const key of Object.keys(dataIndex.flows)) {
77
78
  if (key.toLowerCase() === lower) {
78
- return dataIndex.scenes[key]
79
+ return dataIndex.flows[key]
79
80
  }
80
81
  }
81
82
  }
82
83
 
83
- throw new Error(`Data file not found: ${name}${type ? ` (type: ${type})` : ''}`)
84
+ const available = Object.keys(dataIndex[type] || {})
85
+ const scopedHints = available.filter(k => k.includes('/')).slice(0, 5)
86
+ const hint = scopedHints.length > 0
87
+ ? `\n Scoped names in index: ${scopedHints.join(', ')}`
88
+ : ''
89
+ throw new Error(`Data file not found: ${name}${type ? ` (type: ${type})` : ''}${hint}`)
84
90
  }
85
91
 
86
92
  /**
@@ -117,49 +123,62 @@ function resolveRefs(node, seen = new Set()) {
117
123
  }
118
124
 
119
125
  /**
120
- * Returns the names of all registered scenes.
126
+ * Returns the names of all registered flows.
121
127
  * @returns {string[]}
122
128
  */
123
- export function listScenes() {
124
- return Object.keys(dataIndex.scenes)
129
+ export function listFlows() {
130
+ return Object.keys(dataIndex.flows)
125
131
  }
126
132
 
133
+ /** @deprecated Use listFlows() */
134
+ export const listScenes = listFlows
135
+
127
136
  /**
128
- * Checks whether a scene file exists for the given name.
129
- * @param {string} sceneName - e.g., "Overview"
137
+ * Checks whether a flow file exists for the given name.
138
+ * @param {string} flowName - e.g., "Overview"
130
139
  * @returns {boolean}
131
140
  */
132
- export function sceneExists(sceneName) {
133
- if (dataIndex.scenes[sceneName] != null) return true
134
- const lower = sceneName.toLowerCase()
135
- for (const key of Object.keys(dataIndex.scenes)) {
141
+ export function flowExists(flowName) {
142
+ if (dataIndex.flows[flowName] != null) return true
143
+ const lower = flowName.toLowerCase()
144
+ for (const key of Object.keys(dataIndex.flows)) {
136
145
  if (key.toLowerCase() === lower) return true
137
146
  }
138
147
  return false
139
148
  }
140
149
 
150
+ /** @deprecated Use flowExists() */
151
+ export const sceneExists = flowExists
152
+
141
153
  /**
142
- * Loads a scene file and resolves $global and $ref references.
154
+ * Loads a flow file and resolves $global and $ref references.
143
155
  *
144
- * - $global: array of data names merged into root (scene wins on conflicts)
156
+ * - $global: array of data names merged into root (flow wins on conflicts)
145
157
  * - $ref: inline object replacement at any nesting level
146
158
  *
147
- * @param {string} sceneName - Name of the scene (e.g., "default")
148
- * @returns {object} Resolved scene data
159
+ * @param {string} flowName - Name of the flow (e.g., "default")
160
+ * @returns {object} Resolved flow data
149
161
  */
150
- export function loadScene(sceneName = 'default') {
151
- let sceneData
162
+ export function loadFlow(flowName = 'default') {
163
+ let flowData
152
164
 
153
165
  try {
154
- sceneData = structuredClone(loadDataFile(sceneName, 'scenes'))
166
+ flowData = structuredClone(loadDataFile(flowName, 'flows'))
155
167
  } catch {
156
- throw new Error(`Failed to load scene: ${sceneName}`)
168
+ const available = listFlows()
169
+ const related = available.filter(k =>
170
+ k.endsWith('/' + flowName) || k.startsWith(flowName + '/')
171
+ )
172
+ const hint = related.length > 0
173
+ ? ` Did you mean: ${related.join(', ')}?`
174
+ : ''
175
+ throw new Error(`Failed to load flow: ${flowName}.${hint}`)
157
176
  }
158
177
 
159
178
  // Handle $global: root-level merge from referenced data files
160
- if (Array.isArray(sceneData.$global)) {
161
- const globalNames = sceneData.$global
162
- delete sceneData.$global
179
+ if (Array.isArray(flowData.$global)) {
180
+ const globalNames = flowData.$global
181
+ delete flowData.$global
163
182
 
164
183
  let mergedGlobals = {}
165
184
  for (const name of globalNames) {
@@ -172,16 +191,19 @@ export function loadScene(sceneName = 'default') {
172
191
  }
173
192
  }
174
193
 
175
- sceneData = deepMerge(mergedGlobals, sceneData)
194
+ flowData = deepMerge(mergedGlobals, flowData)
176
195
  }
177
196
 
178
- sceneData = resolveRefs(sceneData)
197
+ flowData = resolveRefs(flowData)
179
198
 
180
199
  // Single clone at the boundary — resolveRefs builds new objects internally,
181
200
  // so the index data is safe. Clone here to prevent consumer mutation.
182
- return structuredClone(sceneData)
201
+ return structuredClone(flowData)
183
202
  }
184
203
 
204
+ /** @deprecated Use loadFlow() */
205
+ export const loadScene = loadFlow
206
+
185
207
  /**
186
208
  * Loads a record collection by name.
187
209
  * @param {string} recordName - Name of the record file (e.g., "posts")
@@ -190,7 +212,14 @@ export function loadScene(sceneName = 'default') {
190
212
  export function loadRecord(recordName) {
191
213
  const data = dataIndex.records[recordName]
192
214
  if (data == null) {
193
- throw new Error(`Record not found: ${recordName}`)
215
+ const available = Object.keys(dataIndex.records)
216
+ const related = available.filter(k =>
217
+ k.endsWith('/' + recordName) || k.startsWith(recordName + '/')
218
+ )
219
+ const hint = related.length > 0
220
+ ? ` Did you mean: ${related.join(', ')}?`
221
+ : ''
222
+ throw new Error(`Record not found: ${recordName}.${hint}`)
194
223
  }
195
224
  if (!Array.isArray(data)) {
196
225
  throw new Error(`Record "${recordName}" must be an array, got ${typeof data}`)
@@ -222,4 +251,56 @@ export function loadObject(objectName) {
222
251
  return resolved
223
252
  }
224
253
 
254
+ /**
255
+ * Resolve a flow name within a prototype scope.
256
+ * Tries the scoped name first ({scope}/{name}), then falls back to the plain name.
257
+ *
258
+ * @param {string|null} scope - Prototype name (e.g. "Dashboard"), or null for global-only
259
+ * @param {string} name - Flow name (e.g. "default" or "Dashboard/signup")
260
+ * @returns {string} The resolved flow name that exists in the index
261
+ */
262
+ export function resolveFlowName(scope, name) {
263
+ if (scope) {
264
+ const scoped = `${scope}/${name}`
265
+ if (flowExists(scoped)) return scoped
266
+ }
267
+ if (flowExists(name)) return name
268
+ // Return the scoped name for better error messages even if it doesn't exist
269
+ return scope ? `${scope}/${name}` : name
270
+ }
271
+
272
+ /**
273
+ * Resolve a record name within a prototype scope.
274
+ * Tries the scoped name first ({scope}/{name}), then falls back to the plain name.
275
+ *
276
+ * @param {string|null} scope - Prototype name (e.g. "Dashboard"), or null for global-only
277
+ * @param {string} name - Record name (e.g. "posts")
278
+ * @returns {string} The resolved record name that exists in the index
279
+ */
280
+ export function resolveRecordName(scope, name) {
281
+ if (scope) {
282
+ const scoped = `${scope}/${name}`
283
+ if (dataIndex.records[scoped] != null) return scoped
284
+ }
285
+ if (dataIndex.records[name] != null) return name
286
+ return scope ? `${scope}/${name}` : name
287
+ }
288
+
289
+ /**
290
+ * Returns the names of all registered prototypes.
291
+ * @returns {string[]}
292
+ */
293
+ export function listPrototypes() {
294
+ return Object.keys(dataIndex.prototypes)
295
+ }
296
+
297
+ /**
298
+ * Returns prototype metadata by name.
299
+ * @param {string} name - Prototype name (e.g. "Example")
300
+ * @returns {object|null} Metadata from the .prototype.json file, or null
301
+ */
302
+ export function getPrototypeMetadata(name) {
303
+ return dataIndex.prototypes[name] ?? null
304
+ }
305
+
225
306
  export { deepMerge }