@dfosco/storyboard-core 1.20.0 → 1.22.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,6 +1,6 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-core",
3
- "version": "1.20.0",
3
+ "version": "1.22.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -11,9 +11,11 @@ import { getAllParams } from './session.js'
11
11
  import { isHideMode, getAllShadows } from './hideMode.js'
12
12
  import { subscribeToHash } from './hashSubscribe.js'
13
13
  import { subscribeToStorage } from './localStorage.js'
14
+ import { syncFlagBodyClasses } from './featureFlags.js'
14
15
 
15
16
  const PREFIX = 'sb-'
16
17
  const SCENE_PREFIX = 'sb-scene--'
18
+ const FF_PREFIX = 'sb-ff-'
17
19
 
18
20
  /**
19
21
  * Sanitize a string for use in a CSS class name.
@@ -41,13 +43,13 @@ function overrideClass(key, value) {
41
43
  }
42
44
 
43
45
  /**
44
- * Get all current sb- classes on body (excluding scene classes).
46
+ * Get all current sb- classes on body (excluding scene and feature-flag classes).
45
47
  * @returns {Set<string>}
46
48
  */
47
49
  function getCurrentOverrideClasses() {
48
50
  const classes = new Set()
49
51
  for (const cls of document.body.classList) {
50
- if (cls.startsWith(PREFIX) && !cls.startsWith(SCENE_PREFIX)) {
52
+ if (cls.startsWith(PREFIX) && !cls.startsWith(SCENE_PREFIX) && !cls.startsWith(FF_PREFIX)) {
51
53
  classes.add(cls)
52
54
  }
53
55
  }
@@ -108,8 +110,10 @@ export function setSceneClass(name) {
108
110
  */
109
111
  export function installBodyClassSync() {
110
112
  syncOverrideClasses()
111
- const unsubHash = subscribeToHash(syncOverrideClasses)
112
- const unsubStorage = subscribeToStorage(syncOverrideClasses)
113
+ syncFlagBodyClasses()
114
+ const sync = () => { syncOverrideClasses(); syncFlagBodyClasses() }
115
+ const unsubHash = subscribeToHash(sync)
116
+ const unsubStorage = subscribeToStorage(sync)
113
117
  return () => {
114
118
  unsubHash()
115
119
  unsubStorage()
@@ -15,23 +15,42 @@ import { getParam, setParam, removeParam, getAllParams } from './session.js'
15
15
  import { getLocal, setLocal, removeLocal, getAllLocal } from './localStorage.js'
16
16
 
17
17
  const FLAG_PREFIX = 'flag.'
18
+ const BODY_CLASS_PREFIX = 'sb-ff-'
18
19
 
19
20
  /** Module-level storage for config defaults */
20
21
  let _defaults = {}
21
22
 
23
+ /**
24
+ * Sync body classes for active feature flags.
25
+ * Adds `sb-ff-{name}` for every flag that resolves to true,
26
+ * removes it for every flag that resolves to false.
27
+ */
28
+ export function syncFlagBodyClasses() {
29
+ if (typeof document === 'undefined') return
30
+ for (const key of Object.keys(_defaults)) {
31
+ const cls = BODY_CLASS_PREFIX + key
32
+ if (getFlag(key)) {
33
+ document.body.classList.add(cls)
34
+ } else {
35
+ document.body.classList.remove(cls)
36
+ }
37
+ }
38
+ }
39
+
22
40
  /**
23
41
  * Initialize the feature flag system with config defaults.
24
- * Seeds localStorage with defaults (doesn't overwrite existing values).
42
+ * Syncs localStorage with config defaults on every call.
25
43
  * @param {Record<string, boolean>} defaults - Flag key → default value
26
44
  */
27
45
  export function initFeatureFlags(defaults = {}) {
28
46
  _defaults = { ...defaults }
29
- // Seed localStorage with defaults (don't overwrite existing)
47
+ // Sync localStorage with config defaults always overwrite so config
48
+ // changes take effect. User overrides live in the URL hash, which is
49
+ // checked first by getFlag(), so this is safe.
30
50
  for (const [key, value] of Object.entries(_defaults)) {
31
- if (getLocal(FLAG_PREFIX + key) === null) {
32
- setLocal(FLAG_PREFIX + key, String(value))
33
- }
51
+ setLocal(FLAG_PREFIX + key, String(value))
34
52
  }
53
+ syncFlagBodyClasses()
35
54
  }
36
55
 
37
56
  /**
@@ -59,6 +78,7 @@ export function getFlag(key) {
59
78
  */
60
79
  export function setFlag(key, value) {
61
80
  setParam(FLAG_PREFIX + key, String(value))
81
+ syncFlagBodyClasses()
62
82
  }
63
83
 
64
84
  /**
@@ -101,6 +121,7 @@ export function resetFlags() {
101
121
  removeLocal(localKey)
102
122
  }
103
123
  }
124
+ syncFlagBodyClasses()
104
125
  }
105
126
 
106
127
  /**
package/src/index.js CHANGED
@@ -8,8 +8,8 @@
8
8
  // Data index initialization
9
9
  export { init } from './loader.js'
10
10
 
11
- // Scene & record loading
12
- export { loadScene, listScenes, sceneExists, loadRecord, findRecord, deepMerge } from './loader.js'
11
+ // Scene, object & record loading
12
+ export { loadScene, listScenes, sceneExists, loadRecord, findRecord, loadObject, deepMerge } from './loader.js'
13
13
 
14
14
  // Dot-notation path utilities
15
15
  export { getByPath, setByPath, deepClone } from './dotPath.js'
@@ -38,7 +38,7 @@ export { mountSceneDebug } from './sceneDebug.js'
38
38
  export { hash, resolveSceneRoute, getSceneMeta } from './viewfinder.js'
39
39
 
40
40
  // Feature flags
41
- export { initFeatureFlags, getFlag, setFlag, toggleFlag, getAllFlags, resetFlags, getFlagKeys } from './featureFlags.js'
41
+ export { initFeatureFlags, getFlag, setFlag, toggleFlag, getAllFlags, resetFlags, getFlagKeys, syncFlagBodyClasses } from './featureFlags.js'
42
42
 
43
43
  // Plugin configuration
44
44
  export { initPlugins, isPluginEnabled, getPluginsConfig } from './plugins.js'
package/src/loader.js CHANGED
@@ -209,4 +209,17 @@ export function findRecord(recordName, id) {
209
209
  return records.find((entry) => entry.id === id) ?? null
210
210
  }
211
211
 
212
+ /**
213
+ * Loads an object data file by name, resolves any nested $ref references,
214
+ * and returns a deep clone.
215
+ *
216
+ * @param {string} objectName - Name of the object file (e.g., "jane-doe")
217
+ * @returns {object|Array} Resolved object data
218
+ */
219
+ export function loadObject(objectName) {
220
+ const data = loadDataFile(objectName, 'objects')
221
+ const resolved = resolveRefs(structuredClone(data))
222
+ return resolved
223
+ }
224
+
212
225
  export { deepMerge }
@@ -1,4 +1,4 @@
1
- import { init, loadScene, listScenes, sceneExists, loadRecord, findRecord, deepMerge } from './loader.js'
1
+ import { init, loadScene, listScenes, sceneExists, loadRecord, findRecord, loadObject, deepMerge } from './loader.js'
2
2
 
3
3
  const makeIndex = () => ({
4
4
  scenes: {
@@ -36,6 +36,10 @@ const makeIndex = () => ({
36
36
  'circular-obj-b': {
37
37
  nested: { $ref: 'circular-obj-a' },
38
38
  },
39
+ 'team-info': {
40
+ team: 'Engineering',
41
+ lead: { $ref: 'jane-doe' },
42
+ },
39
43
  },
40
44
  records: {
41
45
  posts: [
@@ -243,3 +247,31 @@ describe('deepMerge', () => {
243
247
  expect(result.c).toBeUndefined()
244
248
  })
245
249
  })
250
+
251
+ describe('loadObject', () => {
252
+ it('loads object by name', () => {
253
+ const obj = loadObject('jane-doe')
254
+ expect(obj).toEqual({ name: 'Jane Doe', role: 'admin' })
255
+ })
256
+
257
+ it('resolves $ref within object', () => {
258
+ const obj = loadObject('team-info')
259
+ expect(obj.team).toBe('Engineering')
260
+ expect(obj.lead).toEqual({ name: 'Jane Doe', role: 'admin' })
261
+ })
262
+
263
+ it('throws for missing object', () => {
264
+ expect(() => loadObject('nonexistent')).toThrow()
265
+ })
266
+
267
+ it('returns deep clone (mutations do not affect index)', () => {
268
+ const obj1 = loadObject('jane-doe')
269
+ obj1.name = 'Modified'
270
+ const obj2 = loadObject('jane-doe')
271
+ expect(obj2.name).toBe('Jane Doe')
272
+ })
273
+
274
+ it('detects circular $ref and throws', () => {
275
+ expect(() => loadObject('circular-obj-a')).toThrow(/circular/i)
276
+ })
277
+ })