@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.
Files changed (42) hide show
  1. package/package.json +18 -0
  2. package/src/comments/api.js +196 -0
  3. package/src/comments/api.test.js +194 -0
  4. package/src/comments/auth.js +79 -0
  5. package/src/comments/auth.test.js +60 -0
  6. package/src/comments/commentMode.js +63 -0
  7. package/src/comments/commentMode.test.js +87 -0
  8. package/src/comments/config.js +43 -0
  9. package/src/comments/config.test.js +76 -0
  10. package/src/comments/graphql.js +65 -0
  11. package/src/comments/graphql.test.js +95 -0
  12. package/src/comments/index.js +40 -0
  13. package/src/comments/metadata.js +52 -0
  14. package/src/comments/metadata.test.js +110 -0
  15. package/src/comments/queries.js +182 -0
  16. package/src/comments/ui/CommentOverlay.js +52 -0
  17. package/src/comments/ui/authModal.js +349 -0
  18. package/src/comments/ui/commentWindow.js +872 -0
  19. package/src/comments/ui/commentsDrawer.js +389 -0
  20. package/src/comments/ui/composer.js +248 -0
  21. package/src/comments/ui/mount.js +364 -0
  22. package/src/devtools.js +365 -0
  23. package/src/devtools.test.js +81 -0
  24. package/src/dotPath.js +53 -0
  25. package/src/dotPath.test.js +114 -0
  26. package/src/hashSubscribe.js +19 -0
  27. package/src/hashSubscribe.test.js +62 -0
  28. package/src/hideMode.js +421 -0
  29. package/src/hideMode.test.js +224 -0
  30. package/src/index.js +38 -0
  31. package/src/interceptHideParams.js +35 -0
  32. package/src/interceptHideParams.test.js +90 -0
  33. package/src/loader.js +212 -0
  34. package/src/loader.test.js +232 -0
  35. package/src/localStorage.js +134 -0
  36. package/src/localStorage.test.js +148 -0
  37. package/src/sceneDebug.js +108 -0
  38. package/src/sceneDebug.test.js +128 -0
  39. package/src/session.js +76 -0
  40. package/src/session.test.js +91 -0
  41. package/src/viewfinder.js +47 -0
  42. 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
+ }