@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.
@@ -1,7 +1,7 @@
1
- import { init, loadScene, listScenes, sceneExists, loadRecord, findRecord, loadObject, deepMerge } from './loader.js'
1
+ import { init, loadFlow, listFlows, flowExists, loadScene, listScenes, sceneExists, loadRecord, findRecord, loadObject, deepMerge, resolveFlowName, resolveRecordName } from './loader.js'
2
2
 
3
3
  const makeIndex = () => ({
4
- scenes: {
4
+ flows: {
5
5
  default: {
6
6
  title: 'Default Scene',
7
7
  user: { $ref: 'jane-doe' },
@@ -72,111 +72,149 @@ describe('init', () => {
72
72
  expect(() => init('string')).toThrow()
73
73
  })
74
74
 
75
- it('stores data so loadScene works', () => {
75
+ it('stores data so loadFlow works', () => {
76
76
  init(makeIndex())
77
- const scene = loadScene('default')
78
- expect(scene.title).toBe('Default Scene')
77
+ const flow = loadFlow('default')
78
+ expect(flow.title).toBe('Default Scene')
79
79
  })
80
80
 
81
81
  it('handles missing properties gracefully', () => {
82
82
  init({})
83
- expect(sceneExists('anything')).toBe(false)
83
+ expect(flowExists('anything')).toBe(false)
84
+ })
85
+
86
+ it('accepts { scenes } for backward compat', () => {
87
+ init({ scenes: { legacy: { title: 'Legacy' } }, objects: {}, records: {} })
88
+ expect(flowExists('legacy')).toBe(true)
84
89
  })
85
90
  })
86
91
 
87
- describe('loadScene', () => {
88
- it('loads scene by name', () => {
89
- const scene = loadScene('empty')
90
- expect(scene).toEqual({})
92
+ describe('loadFlow', () => {
93
+ it('loads flow by name', () => {
94
+ const flow = loadFlow('empty')
95
+ expect(flow).toEqual({})
91
96
  })
92
97
 
93
98
  it('resolves $ref to objects', () => {
94
- const scene = loadScene('default')
95
- expect(scene.user).toEqual({ name: 'Jane Doe', role: 'admin' })
99
+ const flow = loadFlow('default')
100
+ expect(flow.user).toEqual({ name: 'Jane Doe', role: 'admin' })
96
101
  })
97
102
 
98
103
  it('resolves nested $ref', () => {
99
- const scene = loadScene('with-nested-ref')
100
- expect(scene.team.lead).toEqual({ name: 'Jane Doe', role: 'admin' })
104
+ const flow = loadFlow('with-nested-ref')
105
+ expect(flow.team.lead).toEqual({ name: 'Jane Doe', role: 'admin' })
101
106
  })
102
107
 
103
- it('resolves $global and merges into root, scene wins conflicts', () => {
104
- const scene = loadScene('Dashboard')
105
- expect(scene.links).toEqual(['home', 'about'])
106
- expect(scene.heading).toBe('Dashboard')
107
- // scene value should win over global value
108
- expect(scene.nav).toBe('scene-wins')
108
+ it('resolves $global and merges into root, flow wins conflicts', () => {
109
+ const flow = loadFlow('Dashboard')
110
+ expect(flow.links).toEqual(['home', 'about'])
111
+ expect(flow.heading).toBe('Dashboard')
112
+ // flow value should win over global value
113
+ expect(flow.nav).toBe('scene-wins')
109
114
  })
110
115
 
111
- it('throws for missing scene', () => {
112
- expect(() => loadScene('nonexistent')).toThrow()
116
+ it('throws for missing flow', () => {
117
+ expect(() => loadFlow('nonexistent')).toThrow()
113
118
  })
114
119
 
115
120
  it('case-insensitive lookup', () => {
116
- const scene = loadScene('dashboard')
117
- expect(scene.heading).toBe('Dashboard')
121
+ const flow = loadFlow('dashboard')
122
+ expect(flow.heading).toBe('Dashboard')
118
123
  })
119
124
 
120
125
  it('returns deep clone (mutations do not affect index)', () => {
121
- const scene1 = loadScene('empty')
122
- scene1.injected = true
123
- const scene2 = loadScene('empty')
124
- expect(scene2.injected).toBeUndefined()
126
+ const flow1 = loadFlow('empty')
127
+ flow1.injected = true
128
+ const flow2 = loadFlow('empty')
129
+ expect(flow2.injected).toBeUndefined()
125
130
  })
126
131
 
127
132
  it('resolves $global on repeated calls (no index mutation)', () => {
128
- const first = loadScene('Dashboard')
133
+ const first = loadFlow('Dashboard')
129
134
  expect(first.links).toEqual(['home', 'about'])
130
135
  expect(first.heading).toBe('Dashboard')
131
136
 
132
137
  // Second call must return the same resolved data — $global must not
133
138
  // be deleted from the index by the first call
134
- const second = loadScene('Dashboard')
139
+ const second = loadFlow('Dashboard')
135
140
  expect(second.links).toEqual(['home', 'about'])
136
141
  expect(second.heading).toBe('Dashboard')
137
142
  expect(second.nav).toBe('scene-wins')
138
143
  })
139
144
 
140
- it('default param loads "default" scene', () => {
141
- const scene = loadScene()
142
- expect(scene.title).toBe('Default Scene')
145
+ it('default param loads "default" flow', () => {
146
+ const flow = loadFlow()
147
+ expect(flow.title).toBe('Default Scene')
143
148
  })
144
149
 
145
150
  it('detects circular $ref and throws', () => {
146
- expect(() => loadScene('circular-a')).toThrow(/circular/i)
151
+ expect(() => loadFlow('circular-a')).toThrow(/circular/i)
147
152
  })
148
153
  })
149
154
 
150
- describe('sceneExists', () => {
151
- it('returns true for existing scene', () => {
152
- expect(sceneExists('default')).toBe(true)
155
+ describe('flowExists', () => {
156
+ it('returns true for existing flow', () => {
157
+ expect(flowExists('default')).toBe(true)
153
158
  })
154
159
 
155
- it('returns false for missing scene', () => {
156
- expect(sceneExists('nope')).toBe(false)
160
+ it('returns false for missing flow', () => {
161
+ expect(flowExists('nope')).toBe(false)
157
162
  })
158
163
 
159
164
  it('is case-insensitive', () => {
160
- expect(sceneExists('dashboard')).toBe(true)
161
- expect(sceneExists('DASHBOARD')).toBe(true)
165
+ expect(flowExists('dashboard')).toBe(true)
166
+ expect(flowExists('DASHBOARD')).toBe(true)
162
167
  })
163
168
  })
164
169
 
165
- describe('listScenes', () => {
166
- it('returns all scene names', () => {
167
- const names = listScenes()
170
+ describe('listFlows', () => {
171
+ it('returns all flow names', () => {
172
+ const names = listFlows()
168
173
  expect(names).toContain('default')
169
174
  expect(names).toContain('Dashboard')
170
175
  expect(names).toContain('empty')
171
176
  })
172
177
 
173
178
  it('returns an array', () => {
174
- expect(Array.isArray(listScenes())).toBe(true)
179
+ expect(Array.isArray(listFlows())).toBe(true)
175
180
  })
176
181
 
177
- it('returns empty array when no scenes registered', () => {
178
- init({ scenes: {}, objects: {}, records: {} })
179
- expect(listScenes()).toEqual([])
182
+ it('returns empty array when no flows registered', () => {
183
+ init({ flows: {}, objects: {}, records: {} })
184
+ expect(listFlows()).toEqual([])
185
+ })
186
+ })
187
+
188
+ // ── Deprecated aliases ──
189
+
190
+ describe('loadScene (deprecated alias)', () => {
191
+ it('is the same function as loadFlow', () => {
192
+ expect(loadScene).toBe(loadFlow)
193
+ })
194
+
195
+ it('loads flow data', () => {
196
+ const scene = loadScene('default')
197
+ expect(scene.title).toBe('Default Scene')
198
+ })
199
+ })
200
+
201
+ describe('sceneExists (deprecated alias)', () => {
202
+ it('is the same function as flowExists', () => {
203
+ expect(sceneExists).toBe(flowExists)
204
+ })
205
+
206
+ it('returns true for existing flow', () => {
207
+ expect(sceneExists('default')).toBe(true)
208
+ })
209
+ })
210
+
211
+ describe('listScenes (deprecated alias)', () => {
212
+ it('is the same function as listFlows', () => {
213
+ expect(listScenes).toBe(listFlows)
214
+ })
215
+
216
+ it('returns all flow names', () => {
217
+ expect(listScenes()).toContain('default')
180
218
  })
181
219
  })
182
220
 
@@ -275,3 +313,106 @@ describe('loadObject', () => {
275
313
  expect(() => loadObject('circular-obj-a')).toThrow(/circular/i)
276
314
  })
277
315
  })
316
+
317
+ describe('resolveFlowName', () => {
318
+ beforeEach(() => {
319
+ init({
320
+ flows: {
321
+ default: { title: 'Global Default' },
322
+ signup: { title: 'Global Signup' },
323
+ 'Dashboard/default': { title: 'Dashboard Default' },
324
+ 'Dashboard/signup': { title: 'Dashboard Signup' },
325
+ 'Blog/default': { title: 'Blog Default' },
326
+ },
327
+ objects: {},
328
+ records: {},
329
+ })
330
+ })
331
+
332
+ it('returns scoped name when it exists', () => {
333
+ expect(resolveFlowName('Dashboard', 'default')).toBe('Dashboard/default')
334
+ expect(resolveFlowName('Dashboard', 'signup')).toBe('Dashboard/signup')
335
+ })
336
+
337
+ it('falls back to global name when scoped does not exist', () => {
338
+ expect(resolveFlowName('Blog', 'signup')).toBe('signup')
339
+ })
340
+
341
+ it('returns global name when scope is null', () => {
342
+ expect(resolveFlowName(null, 'default')).toBe('default')
343
+ expect(resolveFlowName(null, 'signup')).toBe('signup')
344
+ })
345
+
346
+ it('returns scoped name for error messages when neither exists', () => {
347
+ expect(resolveFlowName('Dashboard', 'nonexistent')).toBe('Dashboard/nonexistent')
348
+ })
349
+
350
+ it('returns plain name for error messages when scope is null and name does not exist', () => {
351
+ expect(resolveFlowName(null, 'nonexistent')).toBe('nonexistent')
352
+ })
353
+
354
+ it('handles already-scoped names (explicit cross-prototype)', () => {
355
+ expect(resolveFlowName('Blog', 'Dashboard/signup')).toBe('Dashboard/signup')
356
+ })
357
+ })
358
+
359
+ describe('resolveRecordName', () => {
360
+ beforeEach(() => {
361
+ init({
362
+ flows: {},
363
+ objects: {},
364
+ records: {
365
+ posts: [{ id: '1' }],
366
+ 'Dashboard/metrics': [{ id: 'm1' }],
367
+ 'Dashboard/posts': [{ id: 'd1' }],
368
+ },
369
+ })
370
+ })
371
+
372
+ it('returns scoped name when it exists', () => {
373
+ expect(resolveRecordName('Dashboard', 'metrics')).toBe('Dashboard/metrics')
374
+ expect(resolveRecordName('Dashboard', 'posts')).toBe('Dashboard/posts')
375
+ })
376
+
377
+ it('falls back to global when scoped does not exist', () => {
378
+ expect(resolveRecordName('Blog', 'posts')).toBe('posts')
379
+ })
380
+
381
+ it('returns global when scope is null', () => {
382
+ expect(resolveRecordName(null, 'posts')).toBe('posts')
383
+ })
384
+ })
385
+
386
+ describe('error hints for scoped data', () => {
387
+ beforeEach(() => {
388
+ init({
389
+ flows: {
390
+ 'Dashboard/signup': { title: 'Dashboard Signup' },
391
+ default: { title: 'Global Default' },
392
+ },
393
+ objects: {},
394
+ records: {
395
+ 'Blog/posts': [{ id: '1' }],
396
+ tags: [{ id: 'js' }],
397
+ },
398
+ })
399
+ })
400
+
401
+ it('loadFlow error suggests scoped alternatives', () => {
402
+ expect(() => loadFlow('signup')).toThrow(/Did you mean: Dashboard\/signup/)
403
+ })
404
+
405
+ it('loadRecord error suggests scoped alternatives', () => {
406
+ expect(() => loadRecord('posts')).toThrow(/Did you mean: Blog\/posts/)
407
+ })
408
+
409
+ it('loadFlow error for truly missing name has no hint', () => {
410
+ expect(() => loadFlow('xyz')).toThrow(/Failed to load flow: xyz/)
411
+ expect(() => loadFlow('xyz')).not.toThrow(/Did you mean/)
412
+ })
413
+
414
+ it('loadRecord error for truly missing name has no hint', () => {
415
+ expect(() => loadRecord('xyz')).toThrow(/Record not found: xyz/)
416
+ expect(() => loadRecord('xyz')).not.toThrow(/Did you mean/)
417
+ })
418
+ })
package/src/modes.js CHANGED
@@ -17,6 +17,12 @@ const DEFAULT_MODE = 'prototype'
17
17
 
18
18
  let _modesEnabled = false
19
19
 
20
+ // Tool registry — seeded from modes.config.json, state managed at runtime
21
+ const _tools = new Map() // id → { id, label, group, modes[] }
22
+ const _toolState = new Map() // id → { enabled, active, busy, hidden, badge }
23
+ const _toolActions = new Map() // id → action function
24
+ const _toolListeners = new Set() // subscribers to tool state/action changes
25
+
20
26
  // ---------------------------------------------------------------------------
21
27
  // Registry
22
28
  // ---------------------------------------------------------------------------
@@ -281,6 +287,166 @@ export function isModesEnabled() {
281
287
  return _modesEnabled
282
288
  }
283
289
 
290
+ // ---------------------------------------------------------------------------
291
+ // Tool registry
292
+ // ---------------------------------------------------------------------------
293
+
294
+ const DEFAULT_TOOL_STATE = Object.freeze({
295
+ enabled: true,
296
+ active: false,
297
+ busy: false,
298
+ hidden: false,
299
+ badge: null,
300
+ })
301
+
302
+ /**
303
+ * Seed the tool registry from modes.config.json.
304
+ * Called by the Vite data plugin's generated virtual module.
305
+ *
306
+ * @param {Record<string, Array<{ id: string, label: string, group: string }>>} config
307
+ * Keys are mode names or '*' (all modes). Values are tool declarations.
308
+ */
309
+ export function initTools(config = {}) {
310
+ for (const [modeKey, tools] of Object.entries(config)) {
311
+ if (!Array.isArray(tools)) continue
312
+ for (const tool of tools) {
313
+ if (!tool.id) continue
314
+ const existing = _tools.get(tool.id)
315
+ if (existing) {
316
+ // Merge mode assignments (tool declared in multiple mode keys)
317
+ if (!existing.modes.includes(modeKey)) {
318
+ existing.modes.push(modeKey)
319
+ }
320
+ } else {
321
+ _tools.set(tool.id, {
322
+ id: tool.id,
323
+ label: tool.label ?? tool.id,
324
+ group: tool.group ?? 'tools',
325
+ icon: tool.icon ?? null,
326
+ order: tool.order ?? 100,
327
+ modes: [modeKey],
328
+ })
329
+ _toolState.set(tool.id, { ...DEFAULT_TOOL_STATE })
330
+ }
331
+ }
332
+ }
333
+ _notifyTools()
334
+ }
335
+
336
+ /**
337
+ * Wire up a click handler for a declared tool.
338
+ * Plugins call this to provide the action callback.
339
+ *
340
+ * @param {string} id Tool id (must exist in registry)
341
+ * @param {Function} action Click handler
342
+ */
343
+ export function setToolAction(id, action) {
344
+ if (!_tools.has(id)) {
345
+ console.warn(`[storyboard] Tool "${id}" is not declared in modes.config.json.`)
346
+ return
347
+ }
348
+ _toolActions.set(id, action)
349
+ _notifyTools()
350
+ }
351
+
352
+ /**
353
+ * Update the runtime state of a tool.
354
+ * Merges the update into existing state — only the provided keys change.
355
+ *
356
+ * @param {string} id Tool id (must exist in registry)
357
+ * @param {object} state Partial state update
358
+ * @param {boolean} [state.enabled] Whether the tool can be interacted with
359
+ * @param {boolean} [state.active] Whether the tool is currently "on" (highlighted)
360
+ * @param {boolean} [state.busy] Whether the tool is in use / unavailable
361
+ * @param {boolean} [state.hidden] Whether the tool should be hidden entirely
362
+ * @param {string|number|null} [state.badge] Notification badge
363
+ */
364
+ export function setToolState(id, state = {}) {
365
+ if (!_tools.has(id)) {
366
+ console.warn(`[storyboard] Tool "${id}" is not declared in modes.config.json.`)
367
+ return
368
+ }
369
+ const current = _toolState.get(id) ?? { ...DEFAULT_TOOL_STATE }
370
+ _toolState.set(id, { ...current, ...state })
371
+ _notifyTools()
372
+ }
373
+
374
+ /**
375
+ * Get the current runtime state of a tool.
376
+ *
377
+ * @param {string} id Tool id
378
+ * @returns {{ enabled: boolean, active: boolean, busy: boolean, hidden: boolean, badge: string|number|null } | null}
379
+ */
380
+ export function getToolState(id) {
381
+ return _toolState.get(id) ?? null
382
+ }
383
+
384
+ /**
385
+ * Get all tools for a given mode, merged with '*' wildcard tools.
386
+ * Returns tool declarations with their current state and action.
387
+ * Sorted by group (tools first, dev second), then by order.
388
+ *
389
+ * @param {string} modeName
390
+ * @returns {Array<{ id, label, group, icon, order, modes, state, action }>}
391
+ */
392
+ export function getToolsForMode(modeName) {
393
+ const result = []
394
+ for (const [id, tool] of _tools) {
395
+ if (!tool.modes.includes(modeName) && !tool.modes.includes('*')) continue
396
+ const state = _toolState.get(id) ?? { ...DEFAULT_TOOL_STATE }
397
+ if (state.hidden) continue
398
+ result.push({
399
+ ...tool,
400
+ state,
401
+ action: _toolActions.get(id) ?? null,
402
+ })
403
+ }
404
+ // Sort: 'tools' group before 'dev', then by order
405
+ const groupOrder = { tools: 0, dev: 1 }
406
+ result.sort((a, b) => {
407
+ const ga = groupOrder[a.group] ?? 0
408
+ const gb = groupOrder[b.group] ?? 0
409
+ if (ga !== gb) return ga - gb
410
+ return (a.order ?? 100) - (b.order ?? 100)
411
+ })
412
+ return result
413
+ }
414
+
415
+ /**
416
+ * Subscribe to tool state/action changes.
417
+ * Compatible with React's useSyncExternalStore.
418
+ *
419
+ * @param {Function} callback Called on any tool change
420
+ * @returns {Function} Unsubscribe function
421
+ */
422
+ export function subscribeToTools(callback) {
423
+ _toolListeners.add(callback)
424
+ return () => _toolListeners.delete(callback)
425
+ }
426
+
427
+ /**
428
+ * Snapshot for useSyncExternalStore.
429
+ * Returns a serialised string that changes when tool state/actions change.
430
+ */
431
+ export function getToolsSnapshot() {
432
+ const entries = []
433
+ for (const [id, state] of _toolState) {
434
+ const hasAction = _toolActions.has(id) ? '1' : '0'
435
+ entries.push(`${id}:${state.enabled}:${state.active}:${state.busy}:${state.hidden}:${state.badge}:${hasAction}`)
436
+ }
437
+ return entries.join('|')
438
+ }
439
+
440
+ function _notifyTools() {
441
+ for (const cb of _toolListeners) {
442
+ try {
443
+ cb()
444
+ } catch (err) {
445
+ console.error('[storyboard] Error in tool subscriber:', err)
446
+ }
447
+ }
448
+ }
449
+
284
450
  // ---------------------------------------------------------------------------
285
451
  // Test helpers
286
452
  // ---------------------------------------------------------------------------
@@ -292,5 +458,9 @@ export function _resetModes() {
292
458
  _modes.clear()
293
459
  _listeners.clear()
294
460
  _eventListeners.clear()
461
+ _tools.clear()
462
+ _toolState.clear()
463
+ _toolActions.clear()
464
+ _toolListeners.clear()
295
465
  _modesEnabled = false
296
466
  }