@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.
@@ -2,27 +2,36 @@ import { useContext, useCallback } from 'react'
2
2
  import { StoryboardContext } from '../StoryboardContext.js'
3
3
 
4
4
  /**
5
- * Read the current scene name and programmatically switch scenes.
5
+ * Read the current flow name and programmatically switch flows.
6
6
  *
7
- * @returns {{ sceneName: string, switchScene: (name: string) => void }}
8
- * - sceneName – current active scene (e.g. "default")
9
- * - switchScene – navigate to a different scene by updating ?scene= param
7
+ * @returns {{ flowName: string, switchFlow: (name: string) => void }}
8
+ * - flowName – current active flow (e.g. "default")
9
+ * - switchFlow – navigate to a different flow by updating ?scene= param
10
10
  */
11
- export function useScene() {
11
+ export function useFlow() {
12
12
  const context = useContext(StoryboardContext)
13
13
  if (context === null) {
14
- throw new Error('useScene must be used within a <StoryboardProvider>')
14
+ throw new Error('useFlow must be used within a <StoryboardProvider>')
15
15
  }
16
16
 
17
- const switchScene = useCallback((name) => {
17
+ const switchFlow = useCallback((name) => {
18
18
  const url = new URL(window.location.href)
19
19
  url.searchParams.set('scene', name)
20
- // Preserve hash params across scene switches
20
+ // Preserve hash params across flow switches
21
21
  window.location.href = url.toString()
22
22
  }, [])
23
23
 
24
24
  return {
25
- sceneName: context.sceneName,
26
- switchScene,
25
+ flowName: context.flowName,
26
+ switchFlow,
27
+ }
28
+ }
29
+
30
+ /** @deprecated Use useFlow() */
31
+ export function useScene() {
32
+ const { flowName, switchFlow } = useFlow()
33
+ return {
34
+ sceneName: flowName,
35
+ switchScene: switchFlow,
27
36
  }
28
37
  }
@@ -1,39 +1,66 @@
1
1
  import { renderHook } from '@testing-library/react'
2
+ import { useFlow } from './useScene.js'
2
3
  import { useScene } from './useScene.js'
3
- import { seedTestData, createWrapper, TEST_SCENES } from '../test-utils.js'
4
+ import { seedTestData, createWrapper, TEST_FLOWS } from '../test-utils.js'
4
5
 
5
- const sceneData = TEST_SCENES.default
6
+ const flowData = TEST_FLOWS.default
6
7
 
7
8
  beforeEach(() => {
8
9
  seedTestData()
9
10
  })
10
11
 
11
- describe('useScene', () => {
12
+ describe('useFlow', () => {
13
+ it('returns { flowName, switchFlow }', () => {
14
+ const { result } = renderHook(() => useFlow(), {
15
+ wrapper: createWrapper(flowData),
16
+ })
17
+ expect(result.current).toHaveProperty('flowName')
18
+ expect(result.current).toHaveProperty('switchFlow')
19
+ })
20
+
21
+ it('flowName matches the value from context', () => {
22
+ const { result } = renderHook(() => useFlow(), {
23
+ wrapper: createWrapper(flowData, 'other'),
24
+ })
25
+ expect(result.current.flowName).toBe('other')
26
+ })
27
+
28
+ it('switchFlow is a function', () => {
29
+ const { result } = renderHook(() => useFlow(), {
30
+ wrapper: createWrapper(flowData),
31
+ })
32
+ expect(typeof result.current.switchFlow).toBe('function')
33
+ })
34
+
35
+ it('throws when used outside StoryboardProvider', () => {
36
+ expect(() => {
37
+ renderHook(() => useFlow())
38
+ }).toThrow('useFlow must be used within a <StoryboardProvider>')
39
+ })
40
+ })
41
+
42
+ // ── useScene (deprecated alias) ──
43
+
44
+ describe('useScene (deprecated alias)', () => {
12
45
  it('returns { sceneName, switchScene }', () => {
13
46
  const { result } = renderHook(() => useScene(), {
14
- wrapper: createWrapper(sceneData),
47
+ wrapper: createWrapper(flowData),
15
48
  })
16
49
  expect(result.current).toHaveProperty('sceneName')
17
50
  expect(result.current).toHaveProperty('switchScene')
18
51
  })
19
52
 
20
- it('sceneName matches the value from context', () => {
53
+ it('sceneName matches the flow name from context', () => {
21
54
  const { result } = renderHook(() => useScene(), {
22
- wrapper: createWrapper(sceneData, 'other'),
55
+ wrapper: createWrapper(flowData, 'other'),
23
56
  })
24
57
  expect(result.current.sceneName).toBe('other')
25
58
  })
26
59
 
27
60
  it('switchScene is a function', () => {
28
61
  const { result } = renderHook(() => useScene(), {
29
- wrapper: createWrapper(sceneData),
62
+ wrapper: createWrapper(flowData),
30
63
  })
31
64
  expect(typeof result.current.switchScene).toBe('function')
32
65
  })
33
-
34
- it('throws when used outside StoryboardProvider', () => {
35
- expect(() => {
36
- renderHook(() => useScene())
37
- }).toThrow('useScene must be used within a <StoryboardProvider>')
38
- })
39
66
  })
@@ -7,24 +7,24 @@ import { isHideMode, getShadow, getAllShadows } from '@dfosco/storyboard-core'
7
7
  import { subscribeToStorage, getStorageSnapshot } from '@dfosco/storyboard-core'
8
8
 
9
9
  /**
10
- * Access scene data by dot-notation path.
11
- * Hash params override scene data — both exact matches and nested paths.
10
+ * Access flow data by dot-notation path.
11
+ * Hash params override flow data — both exact matches and nested paths.
12
12
  *
13
13
  * Examples:
14
- * useSceneData('user.name') with #user.name=Alice → "Alice"
15
- * useSceneData('repositories') with #repositories.0.name=Foo
14
+ * useFlowData('user.name') with #user.name=Alice → "Alice"
15
+ * useFlowData('repositories') with #repositories.0.name=Foo
16
16
  * → deep clone of repositories array with [0].name overridden to "Foo"
17
17
  *
18
18
  * @param {string} [path] - Dot-notation path (e.g. 'user.profile.name').
19
- * Omit to get the entire scene object.
19
+ * Omit to get the entire flow object.
20
20
  * @returns {*} The resolved value. Returns {} if path is missing after loading.
21
21
  * @throws If used outside a StoryboardProvider.
22
22
  */
23
- export function useSceneData(path) {
23
+ export function useFlowData(path) {
24
24
  const context = useContext(StoryboardContext)
25
25
 
26
26
  if (context === null) {
27
- throw new Error('useSceneData must be used within a <StoryboardProvider>')
27
+ throw new Error('useFlowData must be used within a <StoryboardProvider>')
28
28
  }
29
29
 
30
30
  const { data, loading, error } = context
@@ -73,7 +73,7 @@ export function useSceneData(path) {
73
73
  }
74
74
 
75
75
  if (sceneValue === undefined) {
76
- console.warn(`[useSceneData] Path "${path}" not found in scene data.`)
76
+ console.warn(`[useFlowData] Path "${path}" not found in flow data.`)
77
77
  return {}
78
78
  }
79
79
 
@@ -83,15 +83,21 @@ export function useSceneData(path) {
83
83
  return result
84
84
  }
85
85
 
86
+ /** @deprecated Use useFlowData() */
87
+ export const useSceneData = useFlowData
88
+
86
89
  /**
87
- * Returns true while scene data is still loading.
90
+ * Returns true while flow data is still loading.
88
91
  */
89
- export function useSceneLoading() {
92
+ export function useFlowLoading() {
90
93
  const context = useContext(StoryboardContext)
91
94
 
92
95
  if (context === null) {
93
- throw new Error('useSceneLoading must be used within a <StoryboardProvider>')
96
+ throw new Error('useFlowLoading must be used within a <StoryboardProvider>')
94
97
  }
95
98
 
96
99
  return context.loading
97
100
  }
101
+
102
+ /** @deprecated Use useFlowLoading() */
103
+ export const useSceneLoading = useFlowLoading
@@ -1,38 +1,38 @@
1
1
  import { renderHook } from '@testing-library/react'
2
- import { useSceneData, useSceneLoading } from './useSceneData.js'
3
- import { seedTestData, createWrapper, TEST_SCENES } from '../test-utils.js'
2
+ import { useFlowData, useFlowLoading, useSceneData, useSceneLoading } from './useSceneData.js'
3
+ import { seedTestData, createWrapper, TEST_FLOWS } from '../test-utils.js'
4
4
 
5
- const sceneData = TEST_SCENES.default
5
+ const flowData = TEST_FLOWS.default
6
6
 
7
7
  beforeEach(() => {
8
8
  seedTestData()
9
9
  })
10
10
 
11
- describe('useSceneData', () => {
12
- it('returns entire scene object when no path given', () => {
13
- const { result } = renderHook(() => useSceneData(), {
14
- wrapper: createWrapper(sceneData),
11
+ describe('useFlowData', () => {
12
+ it('returns entire flow object when no path given', () => {
13
+ const { result } = renderHook(() => useFlowData(), {
14
+ wrapper: createWrapper(flowData),
15
15
  })
16
- expect(result.current).toEqual(sceneData)
16
+ expect(result.current).toEqual(flowData)
17
17
  })
18
18
 
19
19
  it('returns nested value by dot-notation path', () => {
20
- const { result } = renderHook(() => useSceneData('user.name'), {
21
- wrapper: createWrapper(sceneData),
20
+ const { result } = renderHook(() => useFlowData('user.name'), {
21
+ wrapper: createWrapper(flowData),
22
22
  })
23
23
  expect(result.current).toBe('Jane')
24
24
  })
25
25
 
26
26
  it('returns deep nested value', () => {
27
- const { result } = renderHook(() => useSceneData('user.profile.bio'), {
28
- wrapper: createWrapper(sceneData),
27
+ const { result } = renderHook(() => useFlowData('user.profile.bio'), {
28
+ wrapper: createWrapper(flowData),
29
29
  })
30
30
  expect(result.current).toBe('Dev')
31
31
  })
32
32
 
33
33
  it('returns array by path', () => {
34
- const { result } = renderHook(() => useSceneData('projects'), {
35
- wrapper: createWrapper(sceneData),
34
+ const { result } = renderHook(() => useFlowData('projects'), {
35
+ wrapper: createWrapper(flowData),
36
36
  })
37
37
  expect(result.current).toEqual([
38
38
  { id: 1, name: 'alpha' },
@@ -41,16 +41,16 @@ describe('useSceneData', () => {
41
41
  })
42
42
 
43
43
  it('returns array element by index path', () => {
44
- const { result } = renderHook(() => useSceneData('projects.0'), {
45
- wrapper: createWrapper(sceneData),
44
+ const { result } = renderHook(() => useFlowData('projects.0'), {
45
+ wrapper: createWrapper(flowData),
46
46
  })
47
47
  expect(result.current).toEqual({ id: 1, name: 'alpha' })
48
48
  })
49
49
 
50
50
  it('returns empty object and warns for missing path', () => {
51
51
  const spy = vi.spyOn(console, 'warn').mockImplementation(() => {})
52
- const { result } = renderHook(() => useSceneData('nonexistent.path'), {
53
- wrapper: createWrapper(sceneData),
52
+ const { result } = renderHook(() => useFlowData('nonexistent.path'), {
53
+ wrapper: createWrapper(flowData),
54
54
  })
55
55
  expect(result.current).toEqual({})
56
56
  expect(spy).toHaveBeenCalledWith(
@@ -61,48 +61,76 @@ describe('useSceneData', () => {
61
61
 
62
62
  it('throws when used outside StoryboardProvider', () => {
63
63
  expect(() => {
64
- renderHook(() => useSceneData())
65
- }).toThrow('useSceneData must be used within a <StoryboardProvider>')
64
+ renderHook(() => useFlowData())
65
+ }).toThrow('useFlowData must be used within a <StoryboardProvider>')
66
66
  })
67
67
 
68
68
  it('returns hash override value when param matches path', () => {
69
69
  window.location.hash = '#user.name=Alice'
70
- const { result } = renderHook(() => useSceneData('user.name'), {
71
- wrapper: createWrapper(sceneData),
70
+ const { result } = renderHook(() => useFlowData('user.name'), {
71
+ wrapper: createWrapper(flowData),
72
72
  })
73
73
  expect(result.current).toBe('Alice')
74
74
  })
75
75
 
76
76
  it('applies child overrides to arrays', () => {
77
77
  window.location.hash = '#projects.0.name=gamma'
78
- const { result } = renderHook(() => useSceneData('projects'), {
79
- wrapper: createWrapper(sceneData),
78
+ const { result } = renderHook(() => useFlowData('projects'), {
79
+ wrapper: createWrapper(flowData),
80
80
  })
81
81
  expect(result.current[0].name).toBe('gamma')
82
82
  expect(result.current[1].name).toBe('beta')
83
83
  })
84
84
 
85
- it('returns full scene with all overrides applied when no path', () => {
85
+ it('returns full flow with all overrides applied when no path', () => {
86
86
  window.location.hash = '#user.name=Alice'
87
- const { result } = renderHook(() => useSceneData(), {
88
- wrapper: createWrapper(sceneData),
87
+ const { result } = renderHook(() => useFlowData(), {
88
+ wrapper: createWrapper(flowData),
89
89
  })
90
90
  expect(result.current.user.name).toBe('Alice')
91
91
  expect(result.current.settings.theme).toBe('dark')
92
92
  })
93
93
  })
94
94
 
95
- describe('useSceneLoading', () => {
95
+ describe('useFlowLoading', () => {
96
96
  it('returns false when not loading', () => {
97
- const { result } = renderHook(() => useSceneLoading(), {
98
- wrapper: createWrapper(sceneData),
97
+ const { result } = renderHook(() => useFlowLoading(), {
98
+ wrapper: createWrapper(flowData),
99
99
  })
100
100
  expect(result.current).toBe(false)
101
101
  })
102
102
 
103
103
  it('throws when used outside StoryboardProvider', () => {
104
104
  expect(() => {
105
- renderHook(() => useSceneLoading())
106
- }).toThrow('useSceneLoading must be used within a <StoryboardProvider>')
105
+ renderHook(() => useFlowLoading())
106
+ }).toThrow('useFlowLoading must be used within a <StoryboardProvider>')
107
+ })
108
+ })
109
+
110
+ // ── Deprecated aliases ──
111
+
112
+ describe('useSceneData (deprecated alias)', () => {
113
+ it('is the same function as useFlowData', () => {
114
+ expect(useSceneData).toBe(useFlowData)
115
+ })
116
+
117
+ it('returns flow data', () => {
118
+ const { result } = renderHook(() => useSceneData(), {
119
+ wrapper: createWrapper(flowData),
120
+ })
121
+ expect(result.current).toEqual(flowData)
122
+ })
123
+ })
124
+
125
+ describe('useSceneLoading (deprecated alias)', () => {
126
+ it('is the same function as useFlowLoading', () => {
127
+ expect(useSceneLoading).toBe(useFlowLoading)
128
+ })
129
+
130
+ it('returns loading state', () => {
131
+ const { result } = renderHook(() => useSceneLoading(), {
132
+ wrapper: createWrapper(flowData),
133
+ })
134
+ expect(result.current).toBe(false)
107
135
  })
108
136
  })
package/src/index.js CHANGED
@@ -10,10 +10,12 @@ export { default as StoryboardProvider } from './context.jsx'
10
10
  export { StoryboardContext } from './StoryboardContext.js'
11
11
 
12
12
  // Hooks
13
+ export { useFlowData, useFlowLoading } from './hooks/useSceneData.js'
14
+ // Deprecated aliases
13
15
  export { useSceneData, useSceneLoading } from './hooks/useSceneData.js'
14
16
  export { useOverride } from './hooks/useOverride.js'
15
17
  export { useOverride as useSession } from './hooks/useOverride.js' // deprecated alias
16
- export { useScene } from './hooks/useScene.js'
18
+ export { useFlow, useScene } from './hooks/useScene.js'
17
19
  export { useRecord, useRecords } from './hooks/useRecord.js'
18
20
  export { useObject } from './hooks/useObject.js'
19
21
  export { useLocalStorage } from './hooks/useLocalStorage.js'
package/src/test-utils.js CHANGED
@@ -3,7 +3,7 @@ import { StoryboardContext } from './StoryboardContext.js'
3
3
  import { init } from '@dfosco/storyboard-core'
4
4
 
5
5
  // Default test data
6
- export const TEST_SCENES = {
6
+ export const TEST_FLOWS = {
7
7
  default: {
8
8
  user: { name: 'Jane', profile: { bio: 'Dev' } },
9
9
  settings: { theme: 'dark' },
@@ -15,6 +15,9 @@ export const TEST_SCENES = {
15
15
  },
16
16
  }
17
17
 
18
+ /** @deprecated Use TEST_FLOWS */
19
+ export const TEST_SCENES = TEST_FLOWS
20
+
18
21
  export const TEST_OBJECTS = {
19
22
  'jane-doe': { name: 'Jane Doe', role: 'admin' },
20
23
  }
@@ -27,15 +30,15 @@ export const TEST_RECORDS = {
27
30
  }
28
31
 
29
32
  export function seedTestData() {
30
- init({ scenes: TEST_SCENES, objects: TEST_OBJECTS, records: TEST_RECORDS })
33
+ init({ flows: TEST_FLOWS, objects: TEST_OBJECTS, records: TEST_RECORDS })
31
34
  }
32
35
 
33
- // Wrapper that provides StoryboardContext with given scene data
34
- export function createWrapper(sceneData, sceneName = 'default') {
36
+ // Wrapper that provides StoryboardContext with given flow data
37
+ export function createWrapper(flowData, flowName = 'default') {
35
38
  return function Wrapper({ children }) {
36
39
  return createElement(
37
40
  StoryboardContext.Provider,
38
- { value: { data: sceneData, error: null, loading: false, sceneName } },
41
+ { value: { data: flowData, error: null, loading: false, flowName, sceneName: flowName } },
39
42
  children
40
43
  )
41
44
  }
@@ -1,24 +1,68 @@
1
1
  import fs from 'node:fs'
2
2
  import path from 'node:path'
3
+ import { execSync } from 'node:child_process'
3
4
  import { globSync } from 'glob'
4
5
  import { parse as parseJsonc } from 'jsonc-parser'
5
6
 
6
7
  const VIRTUAL_MODULE_ID = 'virtual:storyboard-data-index'
7
8
  const RESOLVED_ID = '\0' + VIRTUAL_MODULE_ID
8
9
 
9
- const SUFFIXES = ['scene', 'object', 'record']
10
- const GLOB_PATTERN = '**/*.{scene,object,record}.{json,jsonc}'
10
+ const GLOB_PATTERN = '**/*.{flow,scene,object,record,prototype}.{json,jsonc}'
11
11
 
12
12
  /**
13
13
  * Extract the data name and type suffix from a file path.
14
- * e.g. "src/data/default.scene.json" → { name: "default", suffix: "scene" }
15
- * "anywhere/posts.record.jsonc" { name: "posts", suffix: "record" }
14
+ * Flows and records inside src/prototypes/{Name}/ get prefixed with the
15
+ * prototype name (e.g. "Dashboard/default"). Objects are never prefixed.
16
+ *
17
+ * e.g. "src/data/default.flow.json" → { name: "default", suffix: "flow" }
18
+ * "src/prototypes/Dashboard/default.flow.json" → { name: "Dashboard/default", suffix: "flow" }
19
+ * "src/prototypes/Dashboard/helpers.object.json"→ { name: "helpers", suffix: "object" }
16
20
  */
17
21
  function parseDataFile(filePath) {
18
22
  const base = path.basename(filePath)
19
- const match = base.match(/^(.+)\.(scene|object|record)\.(jsonc?)$/)
23
+ const match = base.match(/^(.+)\.(flow|scene|object|record|prototype)\.(jsonc?)$/)
20
24
  if (!match) return null
21
- return { name: match[1], suffix: match[2], ext: match[3] }
25
+ // Normalize .scene .flow for backward compatibility
26
+ const suffix = match[2] === 'scene' ? 'flow' : match[2]
27
+ let name = match[1]
28
+
29
+ // Prototype metadata files are keyed by their prototype directory name
30
+ if (suffix === 'prototype') {
31
+ const normalized = filePath.replace(/\\/g, '/')
32
+ const protoMatch = normalized.match(/(?:^|\/)src\/prototypes\/([^/]+)\//)
33
+ if (protoMatch) {
34
+ name = protoMatch[1]
35
+ }
36
+ return { name, suffix, ext: match[3] }
37
+ }
38
+
39
+ // Scope flows and records inside src/prototypes/{Name}/ with a prefix
40
+ if (suffix !== 'object') {
41
+ const normalized = filePath.replace(/\\/g, '/')
42
+ const protoMatch = normalized.match(/(?:^|\/)src\/prototypes\/([^/]+)\//)
43
+ if (protoMatch) {
44
+ name = `${protoMatch[1]}/${name}`
45
+ }
46
+ }
47
+
48
+ return { name, suffix, ext: match[3] }
49
+ }
50
+
51
+ /**
52
+ * Look up the git author who first created a file.
53
+ * Used to auto-fill the author field in .prototype.json when missing.
54
+ */
55
+ function getGitAuthor(root, filePath) {
56
+ try {
57
+ const result = execSync(
58
+ `git log --follow --diff-filter=A --format="%aN" -- "${filePath}"`,
59
+ { cwd: root, encoding: 'utf-8', timeout: 5000 },
60
+ ).trim()
61
+ const lines = result.split('\n').filter(Boolean)
62
+ return lines.length > 0 ? lines[lines.length - 1] : null
63
+ } catch {
64
+ return null
65
+ }
22
66
  }
23
67
 
24
68
  /**
@@ -28,7 +72,7 @@ function buildIndex(root) {
28
72
  const ignore = ['node_modules/**', 'dist/**', '.git/**']
29
73
  const files = globSync(GLOB_PATTERN, { cwd: root, ignore, absolute: false })
30
74
 
31
- const index = { scene: {}, object: {}, record: {} }
75
+ const index = { flow: {}, object: {}, record: {}, prototype: {} }
32
76
  const seen = {} // "name.suffix" → absolute path (for duplicate detection)
33
77
 
34
78
  for (const relPath of files) {
@@ -39,11 +83,17 @@ function buildIndex(root) {
39
83
  const absPath = path.resolve(root, relPath)
40
84
 
41
85
  if (seen[key]) {
86
+ const hint = parsed.suffix === 'object'
87
+ ? ' Objects are globally scoped — even inside src/prototypes/ they share a single namespace.\n' +
88
+ ' Rename one of the files to avoid the collision.'
89
+ : ' Flows and records are scoped to their prototype directory.\n' +
90
+ ' If both files are global (outside src/prototypes/), rename one to avoid the collision.'
91
+
42
92
  throw new Error(
43
- `[storyboard-data] Duplicate data file: "${key}.json"\n` +
93
+ `[storyboard-data] Duplicate ${parsed.suffix} "${parsed.name}"\n` +
44
94
  ` Found at: ${seen[key]}\n` +
45
95
  ` And at: ${absPath}\n` +
46
- ` Every data file name+suffix must be unique across the repo.`
96
+ hint
47
97
  )
48
98
  }
49
99
 
@@ -77,23 +127,70 @@ function readConfig(root) {
77
127
  }
78
128
  }
79
129
 
130
+ /**
131
+ * Read modes.config.json from @dfosco/storyboard-core.
132
+ * Returns the full config object { modes, tools }.
133
+ * Falls back to hardcoded defaults if not found.
134
+ */
135
+ function readModesConfig(root) {
136
+ const fallback = {
137
+ modes: [
138
+ { name: 'prototype', label: 'Navigate' },
139
+ { name: 'inspect', label: 'Develop' },
140
+ { name: 'present', label: 'Collaborate' },
141
+ { name: 'plan', label: 'Canvas' },
142
+ ],
143
+ tools: {},
144
+ }
145
+
146
+ // Try local workspace path first (monorepo), then node_modules
147
+ const candidates = [
148
+ path.resolve(root, 'packages/core/modes.config.json'),
149
+ path.resolve(root, 'node_modules/@dfosco/storyboard-core/modes.config.json'),
150
+ ]
151
+
152
+ for (const filePath of candidates) {
153
+ try {
154
+ const raw = fs.readFileSync(filePath, 'utf-8')
155
+ const parsed = JSON.parse(raw)
156
+ if (Array.isArray(parsed.modes) && parsed.modes.length > 0) {
157
+ return { modes: parsed.modes, tools: parsed.tools ?? {} }
158
+ }
159
+ } catch {
160
+ // try next candidate
161
+ }
162
+ }
163
+
164
+ return fallback
165
+ }
166
+
80
167
  function generateModule(index, root) {
81
168
  const declarations = []
82
- const entries = { scene: [], object: [], record: [] }
169
+ const INDEX_KEYS = ['flow', 'object', 'record', 'prototype']
170
+ const entries = { flow: [], object: [], record: [], prototype: [] }
83
171
  let i = 0
84
172
 
85
- for (const suffix of SUFFIXES) {
173
+ for (const suffix of INDEX_KEYS) {
86
174
  for (const [name, absPath] of Object.entries(index[suffix])) {
87
175
  const varName = `_d${i++}`
88
176
  const raw = fs.readFileSync(absPath, 'utf-8')
89
- const parsed = parseJsonc(raw)
177
+ let parsed = parseJsonc(raw)
178
+
179
+ // Auto-fill gitAuthor for prototype metadata from git history
180
+ if (suffix === 'prototype' && parsed && !parsed.gitAuthor) {
181
+ const gitAuthor = getGitAuthor(root, absPath)
182
+ if (gitAuthor) {
183
+ parsed = { ...parsed, gitAuthor }
184
+ }
185
+ }
186
+
90
187
  declarations.push(`const ${varName} = ${JSON.stringify(parsed)}`)
91
188
  entries[suffix].push(` ${JSON.stringify(name)}: ${varName}`)
92
189
  }
93
190
  }
94
191
 
95
192
  const imports = [`import { init } from '@dfosco/storyboard-core'`]
96
- const initCalls = [`init({ scenes, objects, records })`]
193
+ const initCalls = [`init({ flows, objects, records, prototypes })`]
97
194
 
98
195
  // Feature flags from storyboard.config.json
99
196
  const { config } = readConfig(root)
@@ -110,8 +207,25 @@ function generateModule(index, root) {
110
207
 
111
208
  // Modes configuration from storyboard.config.json
112
209
  if (config?.modes) {
113
- imports.push(`import { initModesConfig } from '@dfosco/storyboard-core'`)
210
+ imports.push(`import { initModesConfig, registerMode, syncModeClasses, initTools } from '@dfosco/storyboard-core'`)
114
211
  initCalls.push(`initModesConfig(${JSON.stringify(config.modes)})`)
212
+
213
+ if (config.modes.enabled) {
214
+ imports.push(`import '@dfosco/storyboard-core/modes.css'`)
215
+
216
+ const modesConfig = readModesConfig(root)
217
+ const modes = config.modes.defaults || modesConfig.modes
218
+ for (const m of modes) {
219
+ initCalls.push(`registerMode(${JSON.stringify(m.name)}, { label: ${JSON.stringify(m.label)} })`)
220
+ }
221
+
222
+ // Seed tool registry from modes.config.json
223
+ if (Object.keys(modesConfig.tools).length > 0) {
224
+ initCalls.push(`initTools(${JSON.stringify(modesConfig.tools)})`)
225
+ }
226
+
227
+ initCalls.push(`syncModeClasses()`)
228
+ }
115
229
  }
116
230
 
117
231
  return [
@@ -119,14 +233,18 @@ function generateModule(index, root) {
119
233
  '',
120
234
  declarations.join('\n'),
121
235
  '',
122
- `const scenes = {\n${entries.scene.join(',\n')}\n}`,
236
+ `const flows = {\n${entries.flow.join(',\n')}\n}`,
123
237
  `const objects = {\n${entries.object.join(',\n')}\n}`,
124
238
  `const records = {\n${entries.record.join(',\n')}\n}`,
239
+ `const prototypes = {\n${entries.prototype.join(',\n')}\n}`,
240
+ '',
241
+ '// Backward-compatible alias',
242
+ 'const scenes = flows',
125
243
  '',
126
244
  initCalls.join('\n'),
127
245
  '',
128
- `export { scenes, objects, records }`,
129
- `export const index = { scenes, objects, records }`,
246
+ `export { flows, scenes, objects, records, prototypes }`,
247
+ `export const index = { flows, scenes, objects, records, prototypes }`,
130
248
  `export default index`,
131
249
  ].join('\n')
132
250
  }
@@ -134,7 +252,7 @@ function generateModule(index, root) {
134
252
  /**
135
253
  * Vite plugin for storyboard data discovery.
136
254
  *
137
- * - Scans the repo for *.scene.json, *.object.json, *.record.json
255
+ * - Scans the repo for *.flow.json, *.scene.json (compat), *.object.json, *.record.json
138
256
  * - Validates no two files share the same name+suffix (hard build error)
139
257
  * - Generates a virtual module `virtual:storyboard-data-index`
140
258
  * - Watches for file additions/removals in dev mode