@dfosco/storyboard-core 1.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 +18 -0
- package/src/comments/api.js +196 -0
- package/src/comments/api.test.js +194 -0
- package/src/comments/auth.js +79 -0
- package/src/comments/auth.test.js +60 -0
- package/src/comments/commentMode.js +63 -0
- package/src/comments/commentMode.test.js +87 -0
- package/src/comments/config.js +43 -0
- package/src/comments/config.test.js +76 -0
- package/src/comments/graphql.js +65 -0
- package/src/comments/graphql.test.js +95 -0
- package/src/comments/index.js +40 -0
- package/src/comments/metadata.js +52 -0
- package/src/comments/metadata.test.js +110 -0
- package/src/comments/queries.js +182 -0
- package/src/comments/ui/CommentOverlay.js +52 -0
- package/src/comments/ui/authModal.js +349 -0
- package/src/comments/ui/commentWindow.js +872 -0
- package/src/comments/ui/commentsDrawer.js +389 -0
- package/src/comments/ui/composer.js +248 -0
- package/src/comments/ui/mount.js +364 -0
- package/src/devtools.js +365 -0
- package/src/devtools.test.js +81 -0
- package/src/dotPath.js +53 -0
- package/src/dotPath.test.js +114 -0
- package/src/hashSubscribe.js +19 -0
- package/src/hashSubscribe.test.js +62 -0
- package/src/hideMode.js +421 -0
- package/src/hideMode.test.js +224 -0
- package/src/index.js +38 -0
- package/src/interceptHideParams.js +35 -0
- package/src/interceptHideParams.test.js +90 -0
- package/src/loader.js +212 -0
- package/src/loader.test.js +232 -0
- package/src/localStorage.js +134 -0
- package/src/localStorage.test.js +148 -0
- package/src/sceneDebug.js +108 -0
- package/src/sceneDebug.test.js +128 -0
- package/src/session.js +76 -0
- package/src/session.test.js +91 -0
- package/src/viewfinder.js +47 -0
- package/src/viewfinder.test.js +87 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Intercept ?hide and ?show URL params.
|
|
3
|
+
*
|
|
4
|
+
* Called at app startup AND on every client-side navigation.
|
|
5
|
+
* Checks the URL for the special params, triggers the corresponding
|
|
6
|
+
* hide-mode transition, and strips the param from the URL.
|
|
7
|
+
*/
|
|
8
|
+
import { activateHideMode, deactivateHideMode } from './hideMode.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Check for ?hide or ?show in the current URL and act on them.
|
|
12
|
+
* Safe to call multiple times (idempotent — only acts if the param exists).
|
|
13
|
+
*/
|
|
14
|
+
export function interceptHideParams() {
|
|
15
|
+
const url = new URL(window.location.href)
|
|
16
|
+
|
|
17
|
+
if (url.searchParams.has('hide')) {
|
|
18
|
+
activateHideMode()
|
|
19
|
+
return
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (url.searchParams.has('show')) {
|
|
23
|
+
deactivateHideMode()
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Install a popstate listener so ?hide/?show are detected on
|
|
30
|
+
* browser back/forward navigation too.
|
|
31
|
+
*/
|
|
32
|
+
export function installHideParamListener() {
|
|
33
|
+
interceptHideParams()
|
|
34
|
+
window.addEventListener('popstate', () => interceptHideParams())
|
|
35
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { vi } from 'vitest'
|
|
2
|
+
import { interceptHideParams, installHideParamListener } from './interceptHideParams.js'
|
|
3
|
+
|
|
4
|
+
vi.mock('./hideMode.js', () => ({
|
|
5
|
+
activateHideMode: vi.fn(),
|
|
6
|
+
deactivateHideMode: vi.fn(),
|
|
7
|
+
}))
|
|
8
|
+
|
|
9
|
+
import { activateHideMode, deactivateHideMode } from './hideMode.js'
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
vi.clearAllMocks()
|
|
13
|
+
window.history.pushState(null, '', '/')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
describe('interceptHideParams', () => {
|
|
17
|
+
it('no-ops when no hide/show params are present', () => {
|
|
18
|
+
interceptHideParams()
|
|
19
|
+
|
|
20
|
+
expect(activateHideMode).not.toHaveBeenCalled()
|
|
21
|
+
expect(deactivateHideMode).not.toHaveBeenCalled()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('calls activateHideMode when ?hide is present', () => {
|
|
25
|
+
window.history.pushState(null, '', '?hide')
|
|
26
|
+
|
|
27
|
+
interceptHideParams()
|
|
28
|
+
|
|
29
|
+
expect(activateHideMode).toHaveBeenCalledTimes(1)
|
|
30
|
+
expect(deactivateHideMode).not.toHaveBeenCalled()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('calls deactivateHideMode when ?show is present', () => {
|
|
34
|
+
window.history.pushState(null, '', '?show')
|
|
35
|
+
|
|
36
|
+
interceptHideParams()
|
|
37
|
+
|
|
38
|
+
expect(deactivateHideMode).toHaveBeenCalledTimes(1)
|
|
39
|
+
expect(activateHideMode).not.toHaveBeenCalled()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('prefers ?hide over ?show when both are present', () => {
|
|
43
|
+
window.history.pushState(null, '', '?hide&show')
|
|
44
|
+
|
|
45
|
+
interceptHideParams()
|
|
46
|
+
|
|
47
|
+
expect(activateHideMode).toHaveBeenCalledTimes(1)
|
|
48
|
+
expect(deactivateHideMode).not.toHaveBeenCalled()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('is idempotent — safe to call multiple times', () => {
|
|
52
|
+
window.history.pushState(null, '', '?hide')
|
|
53
|
+
|
|
54
|
+
interceptHideParams()
|
|
55
|
+
interceptHideParams()
|
|
56
|
+
interceptHideParams()
|
|
57
|
+
|
|
58
|
+
expect(activateHideMode).toHaveBeenCalledTimes(3)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('no-ops when URL has unrelated query params', () => {
|
|
62
|
+
window.history.pushState(null, '', '?scene=overview&foo=bar')
|
|
63
|
+
|
|
64
|
+
interceptHideParams()
|
|
65
|
+
|
|
66
|
+
expect(activateHideMode).not.toHaveBeenCalled()
|
|
67
|
+
expect(deactivateHideMode).not.toHaveBeenCalled()
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe('installHideParamListener', () => {
|
|
72
|
+
it('calls interceptHideParams immediately', () => {
|
|
73
|
+
window.history.pushState(null, '', '?hide')
|
|
74
|
+
const spy = vi.spyOn(window, 'addEventListener')
|
|
75
|
+
|
|
76
|
+
installHideParamListener()
|
|
77
|
+
|
|
78
|
+
expect(activateHideMode).toHaveBeenCalled()
|
|
79
|
+
spy.mockRestore()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('adds a popstate listener', () => {
|
|
83
|
+
const spy = vi.spyOn(window, 'addEventListener')
|
|
84
|
+
|
|
85
|
+
installHideParamListener()
|
|
86
|
+
|
|
87
|
+
expect(spy).toHaveBeenCalledWith('popstate', expect.any(Function))
|
|
88
|
+
spy.mockRestore()
|
|
89
|
+
})
|
|
90
|
+
})
|
package/src/loader.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deep merges two objects. Source values take priority over target.
|
|
3
|
+
* Arrays are replaced, not concatenated.
|
|
4
|
+
*/
|
|
5
|
+
function deepMerge(target, source) {
|
|
6
|
+
const result = { ...target }
|
|
7
|
+
|
|
8
|
+
for (const key of Object.keys(source)) {
|
|
9
|
+
const sourceValue = source[key]
|
|
10
|
+
const targetValue = target[key]
|
|
11
|
+
|
|
12
|
+
if (
|
|
13
|
+
sourceValue !== null &&
|
|
14
|
+
typeof sourceValue === 'object' &&
|
|
15
|
+
!Array.isArray(sourceValue) &&
|
|
16
|
+
targetValue !== null &&
|
|
17
|
+
typeof targetValue === 'object' &&
|
|
18
|
+
!Array.isArray(targetValue)
|
|
19
|
+
) {
|
|
20
|
+
result[key] = deepMerge(targetValue, sourceValue)
|
|
21
|
+
} else {
|
|
22
|
+
result[key] = sourceValue
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return result
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Module-level data index, seeded by init().
|
|
31
|
+
* Shape: { scenes: {}, objects: {}, records: {} }
|
|
32
|
+
*/
|
|
33
|
+
let dataIndex = { scenes: {}, objects: {}, records: {} }
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Seed the data index. Call once at app startup before any load functions.
|
|
37
|
+
* The Vite data plugin calls this automatically via the generated virtual module.
|
|
38
|
+
*
|
|
39
|
+
* @param {{ scenes: object, objects: object, records: object }} index
|
|
40
|
+
*/
|
|
41
|
+
export function init(index) {
|
|
42
|
+
if (!index || typeof index !== 'object') {
|
|
43
|
+
throw new Error('[storyboard-core] init() requires { scenes, objects, records }')
|
|
44
|
+
}
|
|
45
|
+
dataIndex = {
|
|
46
|
+
scenes: index.scenes || {},
|
|
47
|
+
objects: index.objects || {},
|
|
48
|
+
records: index.records || {},
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Loads a data file by name and type from the data index.
|
|
54
|
+
* Data is pre-parsed at build time — returns a deep clone to prevent mutation.
|
|
55
|
+
* @param {string} name - Data file name (e.g., "jane-doe", "default")
|
|
56
|
+
* @param {string} [type] - Data type: "scenes", "objects", or "records". If omitted, searches all types.
|
|
57
|
+
* @returns {object} Parsed file contents
|
|
58
|
+
*/
|
|
59
|
+
function loadDataFile(name, type) {
|
|
60
|
+
if (type && dataIndex[type]?.[name] != null) {
|
|
61
|
+
return dataIndex[type][name]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Search all types if no specific type given
|
|
65
|
+
if (!type) {
|
|
66
|
+
for (const t of ['scenes', 'objects', 'records']) {
|
|
67
|
+
if (dataIndex[t]?.[name] != null) {
|
|
68
|
+
return dataIndex[t][name]
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Case-insensitive fallback for scenes
|
|
74
|
+
if (type === 'scenes' || !type) {
|
|
75
|
+
const lower = name.toLowerCase()
|
|
76
|
+
for (const key of Object.keys(dataIndex.scenes)) {
|
|
77
|
+
if (key.toLowerCase() === lower) {
|
|
78
|
+
return dataIndex.scenes[key]
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
throw new Error(`Data file not found: ${name}${type ? ` (type: ${type})` : ''}`)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Recursively resolves $ref objects within data.
|
|
88
|
+
* A $ref is a name resolved from the data index (objects first, then any type).
|
|
89
|
+
*
|
|
90
|
+
* @param {*} node - Current data node
|
|
91
|
+
* @param {Set} seen - Tracks visited names to prevent circular refs
|
|
92
|
+
* @returns {*} Resolved data
|
|
93
|
+
*/
|
|
94
|
+
function resolveRefs(node, seen = new Set()) {
|
|
95
|
+
if (node === null || typeof node !== 'object') return node
|
|
96
|
+
if (Array.isArray(node)) {
|
|
97
|
+
return node.map((item) => resolveRefs(item, seen))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Handle $ref replacement
|
|
101
|
+
if (node.$ref && typeof node.$ref === 'string') {
|
|
102
|
+
const refName = node.$ref
|
|
103
|
+
if (seen.has(refName)) {
|
|
104
|
+
throw new Error(`Circular $ref detected: ${refName}`)
|
|
105
|
+
}
|
|
106
|
+
seen.add(refName)
|
|
107
|
+
const refData = loadDataFile(refName, 'objects')
|
|
108
|
+
return resolveRefs(refData, seen)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Recurse into object values
|
|
112
|
+
const result = {}
|
|
113
|
+
for (const [key, value] of Object.entries(node)) {
|
|
114
|
+
result[key] = resolveRefs(value, seen)
|
|
115
|
+
}
|
|
116
|
+
return result
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Returns the names of all registered scenes.
|
|
121
|
+
* @returns {string[]}
|
|
122
|
+
*/
|
|
123
|
+
export function listScenes() {
|
|
124
|
+
return Object.keys(dataIndex.scenes)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Checks whether a scene file exists for the given name.
|
|
129
|
+
* @param {string} sceneName - e.g., "Overview"
|
|
130
|
+
* @returns {boolean}
|
|
131
|
+
*/
|
|
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)) {
|
|
136
|
+
if (key.toLowerCase() === lower) return true
|
|
137
|
+
}
|
|
138
|
+
return false
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Loads a scene file and resolves $global and $ref references.
|
|
143
|
+
*
|
|
144
|
+
* - $global: array of data names merged into root (scene wins on conflicts)
|
|
145
|
+
* - $ref: inline object replacement at any nesting level
|
|
146
|
+
*
|
|
147
|
+
* @param {string} sceneName - Name of the scene (e.g., "default")
|
|
148
|
+
* @returns {object} Resolved scene data
|
|
149
|
+
*/
|
|
150
|
+
export function loadScene(sceneName = 'default') {
|
|
151
|
+
let sceneData
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
sceneData = loadDataFile(sceneName, 'scenes')
|
|
155
|
+
} catch {
|
|
156
|
+
throw new Error(`Failed to load scene: ${sceneName}`)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Handle $global: root-level merge from referenced data files
|
|
160
|
+
if (Array.isArray(sceneData.$global)) {
|
|
161
|
+
const globalNames = sceneData.$global
|
|
162
|
+
delete sceneData.$global
|
|
163
|
+
|
|
164
|
+
let mergedGlobals = {}
|
|
165
|
+
for (const name of globalNames) {
|
|
166
|
+
try {
|
|
167
|
+
let globalData = loadDataFile(name)
|
|
168
|
+
globalData = resolveRefs(globalData)
|
|
169
|
+
mergedGlobals = deepMerge(mergedGlobals, globalData)
|
|
170
|
+
} catch (err) {
|
|
171
|
+
console.warn(`Failed to load $global: ${name}`, err)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
sceneData = deepMerge(mergedGlobals, sceneData)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
sceneData = resolveRefs(sceneData)
|
|
179
|
+
|
|
180
|
+
// Single clone at the boundary — resolveRefs builds new objects internally,
|
|
181
|
+
// so the index data is safe. Clone here to prevent consumer mutation.
|
|
182
|
+
return structuredClone(sceneData)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Loads a record collection by name.
|
|
187
|
+
* @param {string} recordName - Name of the record file (e.g., "posts")
|
|
188
|
+
* @returns {Array} Parsed record collection
|
|
189
|
+
*/
|
|
190
|
+
export function loadRecord(recordName) {
|
|
191
|
+
const data = dataIndex.records[recordName]
|
|
192
|
+
if (data == null) {
|
|
193
|
+
throw new Error(`Record not found: ${recordName}`)
|
|
194
|
+
}
|
|
195
|
+
if (!Array.isArray(data)) {
|
|
196
|
+
throw new Error(`Record "${recordName}" must be an array, got ${typeof data}`)
|
|
197
|
+
}
|
|
198
|
+
return structuredClone(data)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Finds a single record entry by id within a collection.
|
|
203
|
+
* @param {string} recordName - Record collection name (e.g., "posts")
|
|
204
|
+
* @param {string} id - The id to match
|
|
205
|
+
* @returns {object|null} The matched entry, or null
|
|
206
|
+
*/
|
|
207
|
+
export function findRecord(recordName, id) {
|
|
208
|
+
const records = loadRecord(recordName)
|
|
209
|
+
return records.find((entry) => entry.id === id) ?? null
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export { deepMerge }
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { init, loadScene, listScenes, sceneExists, loadRecord, findRecord, deepMerge } from './loader.js'
|
|
2
|
+
|
|
3
|
+
const makeIndex = () => ({
|
|
4
|
+
scenes: {
|
|
5
|
+
default: {
|
|
6
|
+
title: 'Default Scene',
|
|
7
|
+
user: { $ref: 'jane-doe' },
|
|
8
|
+
},
|
|
9
|
+
Dashboard: {
|
|
10
|
+
$global: ['navigation'],
|
|
11
|
+
heading: 'Dashboard',
|
|
12
|
+
nav: 'scene-wins',
|
|
13
|
+
},
|
|
14
|
+
empty: {},
|
|
15
|
+
'with-nested-ref': {
|
|
16
|
+
team: {
|
|
17
|
+
lead: { $ref: 'jane-doe' },
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
'circular-a': {
|
|
21
|
+
thing: { $ref: 'circular-obj-a' },
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
objects: {
|
|
25
|
+
'jane-doe': {
|
|
26
|
+
name: 'Jane Doe',
|
|
27
|
+
role: 'admin',
|
|
28
|
+
},
|
|
29
|
+
navigation: {
|
|
30
|
+
nav: 'global-nav',
|
|
31
|
+
links: ['home', 'about'],
|
|
32
|
+
},
|
|
33
|
+
'circular-obj-a': {
|
|
34
|
+
nested: { $ref: 'circular-obj-b' },
|
|
35
|
+
},
|
|
36
|
+
'circular-obj-b': {
|
|
37
|
+
nested: { $ref: 'circular-obj-a' },
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
records: {
|
|
41
|
+
posts: [
|
|
42
|
+
{ id: 'post-1', title: 'First Post' },
|
|
43
|
+
{ id: 'post-2', title: 'Second Post' },
|
|
44
|
+
],
|
|
45
|
+
'bad-record': { notAnArray: true },
|
|
46
|
+
},
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
51
|
+
init(makeIndex())
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
vi.restoreAllMocks()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('init', () => {
|
|
59
|
+
it('throws on null', () => {
|
|
60
|
+
expect(() => init(null)).toThrow()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('throws on undefined', () => {
|
|
64
|
+
expect(() => init(undefined)).toThrow()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('throws on non-object', () => {
|
|
68
|
+
expect(() => init('string')).toThrow()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('stores data so loadScene works', () => {
|
|
72
|
+
init(makeIndex())
|
|
73
|
+
const scene = loadScene('default')
|
|
74
|
+
expect(scene.title).toBe('Default Scene')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('handles missing properties gracefully', () => {
|
|
78
|
+
init({})
|
|
79
|
+
expect(sceneExists('anything')).toBe(false)
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('loadScene', () => {
|
|
84
|
+
it('loads scene by name', () => {
|
|
85
|
+
const scene = loadScene('empty')
|
|
86
|
+
expect(scene).toEqual({})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('resolves $ref to objects', () => {
|
|
90
|
+
const scene = loadScene('default')
|
|
91
|
+
expect(scene.user).toEqual({ name: 'Jane Doe', role: 'admin' })
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('resolves nested $ref', () => {
|
|
95
|
+
const scene = loadScene('with-nested-ref')
|
|
96
|
+
expect(scene.team.lead).toEqual({ name: 'Jane Doe', role: 'admin' })
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('resolves $global and merges into root, scene wins conflicts', () => {
|
|
100
|
+
const scene = loadScene('Dashboard')
|
|
101
|
+
expect(scene.links).toEqual(['home', 'about'])
|
|
102
|
+
expect(scene.heading).toBe('Dashboard')
|
|
103
|
+
// scene value should win over global value
|
|
104
|
+
expect(scene.nav).toBe('scene-wins')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('throws for missing scene', () => {
|
|
108
|
+
expect(() => loadScene('nonexistent')).toThrow()
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('case-insensitive lookup', () => {
|
|
112
|
+
const scene = loadScene('dashboard')
|
|
113
|
+
expect(scene.heading).toBe('Dashboard')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('returns deep clone (mutations do not affect index)', () => {
|
|
117
|
+
const scene1 = loadScene('empty')
|
|
118
|
+
scene1.injected = true
|
|
119
|
+
const scene2 = loadScene('empty')
|
|
120
|
+
expect(scene2.injected).toBeUndefined()
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('default param loads "default" scene', () => {
|
|
124
|
+
const scene = loadScene()
|
|
125
|
+
expect(scene.title).toBe('Default Scene')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('detects circular $ref and throws', () => {
|
|
129
|
+
expect(() => loadScene('circular-a')).toThrow(/circular/i)
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
describe('sceneExists', () => {
|
|
134
|
+
it('returns true for existing scene', () => {
|
|
135
|
+
expect(sceneExists('default')).toBe(true)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('returns false for missing scene', () => {
|
|
139
|
+
expect(sceneExists('nope')).toBe(false)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('is case-insensitive', () => {
|
|
143
|
+
expect(sceneExists('dashboard')).toBe(true)
|
|
144
|
+
expect(sceneExists('DASHBOARD')).toBe(true)
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
describe('listScenes', () => {
|
|
149
|
+
it('returns all scene names', () => {
|
|
150
|
+
const names = listScenes()
|
|
151
|
+
expect(names).toContain('default')
|
|
152
|
+
expect(names).toContain('Dashboard')
|
|
153
|
+
expect(names).toContain('empty')
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('returns an array', () => {
|
|
157
|
+
expect(Array.isArray(listScenes())).toBe(true)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('returns empty array when no scenes registered', () => {
|
|
161
|
+
init({ scenes: {}, objects: {}, records: {} })
|
|
162
|
+
expect(listScenes()).toEqual([])
|
|
163
|
+
})
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
describe('loadRecord', () => {
|
|
167
|
+
it('loads record array by name', () => {
|
|
168
|
+
const records = loadRecord('posts')
|
|
169
|
+
expect(records).toHaveLength(2)
|
|
170
|
+
expect(records[0].id).toBe('post-1')
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('throws for missing record', () => {
|
|
174
|
+
expect(() => loadRecord('nonexistent')).toThrow()
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('throws for non-array record', () => {
|
|
178
|
+
expect(() => loadRecord('bad-record')).toThrow(/array/i)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('returns deep clone', () => {
|
|
182
|
+
const records1 = loadRecord('posts')
|
|
183
|
+
records1[0].title = 'Modified'
|
|
184
|
+
const records2 = loadRecord('posts')
|
|
185
|
+
expect(records2[0].title).toBe('First Post')
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
describe('findRecord', () => {
|
|
190
|
+
it('finds entry by id', () => {
|
|
191
|
+
const entry = findRecord('posts', 'post-2')
|
|
192
|
+
expect(entry).toEqual({ id: 'post-2', title: 'Second Post' })
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('returns null for missing id', () => {
|
|
196
|
+
const entry = findRecord('posts', 'nonexistent')
|
|
197
|
+
expect(entry).toBeNull()
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('throws for missing record', () => {
|
|
201
|
+
expect(() => findRecord('nonexistent', 'any')).toThrow()
|
|
202
|
+
})
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
describe('deepMerge', () => {
|
|
206
|
+
it('merges nested objects', () => {
|
|
207
|
+
const target = { a: { b: 1, c: 2 } }
|
|
208
|
+
const source = { a: { d: 3 } }
|
|
209
|
+
const result = deepMerge(target, source)
|
|
210
|
+
expect(result).toEqual({ a: { b: 1, c: 2, d: 3 } })
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('source wins conflicts', () => {
|
|
214
|
+
const target = { a: 1 }
|
|
215
|
+
const source = { a: 2 }
|
|
216
|
+
expect(deepMerge(target, source)).toEqual({ a: 2 })
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('arrays are replaced not concatenated', () => {
|
|
220
|
+
const target = { items: [1, 2, 3] }
|
|
221
|
+
const source = { items: [4, 5] }
|
|
222
|
+
expect(deepMerge(target, source)).toEqual({ items: [4, 5] })
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('handles null/undefined values', () => {
|
|
226
|
+
const target = { a: 1, b: 2 }
|
|
227
|
+
const source = { a: null, c: undefined }
|
|
228
|
+
const result = deepMerge(target, source)
|
|
229
|
+
expect(result.a).toBeNull()
|
|
230
|
+
expect(result.c).toBeUndefined()
|
|
231
|
+
})
|
|
232
|
+
})
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* localStorage utilities for persistent storyboard overrides.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the session.js (URL hash) API but persists values in localStorage.
|
|
5
|
+
* All keys are prefixed with "storyboard:" to avoid collisions.
|
|
6
|
+
*
|
|
7
|
+
* Reactivity:
|
|
8
|
+
* - Cross-tab: native "storage" event (fires in other tabs automatically)
|
|
9
|
+
* - Intra-tab: custom "storyboard-storage" event on window (the native
|
|
10
|
+
* "storage" event does NOT fire in the tab that made the change)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const PREFIX = 'storyboard:'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Read a single value from localStorage.
|
|
17
|
+
* @param {string} key - Unprefixed key (e.g. "settings.theme")
|
|
18
|
+
* @returns {string|null}
|
|
19
|
+
*/
|
|
20
|
+
export function getLocal(key) {
|
|
21
|
+
try {
|
|
22
|
+
return localStorage.getItem(PREFIX + key)
|
|
23
|
+
} catch {
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Write a single value to localStorage and notify listeners.
|
|
30
|
+
* @param {string} key - Unprefixed key
|
|
31
|
+
* @param {string} value
|
|
32
|
+
*/
|
|
33
|
+
export function setLocal(key, value) {
|
|
34
|
+
try {
|
|
35
|
+
localStorage.setItem(PREFIX + key, String(value))
|
|
36
|
+
notifyChange()
|
|
37
|
+
} catch {
|
|
38
|
+
// localStorage full or unavailable — silently degrade
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Remove a single key from localStorage and notify listeners.
|
|
44
|
+
* @param {string} key - Unprefixed key
|
|
45
|
+
*/
|
|
46
|
+
export function removeLocal(key) {
|
|
47
|
+
try {
|
|
48
|
+
localStorage.removeItem(PREFIX + key)
|
|
49
|
+
notifyChange()
|
|
50
|
+
} catch {
|
|
51
|
+
// silently degrade
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Return all storyboard-prefixed localStorage entries as a plain object.
|
|
57
|
+
* Keys are returned WITHOUT the prefix.
|
|
58
|
+
* @returns {Record<string, string>}
|
|
59
|
+
*/
|
|
60
|
+
export function getAllLocal() {
|
|
61
|
+
const result = {}
|
|
62
|
+
try {
|
|
63
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
64
|
+
const raw = localStorage.key(i)
|
|
65
|
+
if (raw && raw.startsWith(PREFIX)) {
|
|
66
|
+
result[raw.slice(PREFIX.length)] = localStorage.getItem(raw)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
// silently degrade
|
|
71
|
+
}
|
|
72
|
+
return result
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Subscribe to localStorage changes (both cross-tab and intra-tab).
|
|
77
|
+
* Compatible with useSyncExternalStore.
|
|
78
|
+
* @param {function} callback
|
|
79
|
+
* @returns {function} unsubscribe
|
|
80
|
+
*/
|
|
81
|
+
export function subscribeToStorage(callback) {
|
|
82
|
+
const wrappedCallback = () => {
|
|
83
|
+
invalidateSnapshotCache()
|
|
84
|
+
callback()
|
|
85
|
+
}
|
|
86
|
+
// Cross-tab: native storage event
|
|
87
|
+
window.addEventListener('storage', wrappedCallback)
|
|
88
|
+
// Intra-tab: custom event
|
|
89
|
+
window.addEventListener('storyboard-storage', wrappedCallback)
|
|
90
|
+
return () => {
|
|
91
|
+
window.removeEventListener('storage', wrappedCallback)
|
|
92
|
+
window.removeEventListener('storyboard-storage', wrappedCallback)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── Snapshot cache ──
|
|
97
|
+
|
|
98
|
+
let _snapshotCache = null
|
|
99
|
+
|
|
100
|
+
/** Invalidate the snapshot cache so the next getStorageSnapshot() recomputes. */
|
|
101
|
+
function invalidateSnapshotCache() {
|
|
102
|
+
_snapshotCache = null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Snapshot of all storyboard localStorage entries as a serialized string.
|
|
107
|
+
* Used by useSyncExternalStore to detect changes.
|
|
108
|
+
* Cached — invalidated on writes and storage events.
|
|
109
|
+
* @returns {string}
|
|
110
|
+
*/
|
|
111
|
+
export function getStorageSnapshot() {
|
|
112
|
+
if (_snapshotCache !== null) return _snapshotCache
|
|
113
|
+
try {
|
|
114
|
+
const entries = []
|
|
115
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
116
|
+
const raw = localStorage.key(i)
|
|
117
|
+
if (raw && raw.startsWith(PREFIX)) {
|
|
118
|
+
entries.push(raw + '=' + localStorage.getItem(raw))
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
_snapshotCache = entries.sort().join('&')
|
|
122
|
+
return _snapshotCache
|
|
123
|
+
} catch {
|
|
124
|
+
return ''
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── Internal ──
|
|
129
|
+
|
|
130
|
+
/** Fire a custom event so intra-tab listeners re-render. */
|
|
131
|
+
export function notifyChange() {
|
|
132
|
+
invalidateSnapshotCache()
|
|
133
|
+
window.dispatchEvent(new Event('storyboard-storage'))
|
|
134
|
+
}
|