@dfosco/storyboard-core 1.23.0 → 2.0.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.23.0",
3
+ "version": "2.0.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -16,11 +16,23 @@
16
16
  "./vite/server": "./src/vite/server-plugin.js",
17
17
  "./comments": "./src/comments/index.js",
18
18
  "./comments/ui/comments.css": "./src/comments/ui/comments.css",
19
- "./workshop/ui/mount.js": "./src/workshop/ui/mount.js"
19
+ "./workshop/ui/mount.js": "./src/workshop/ui/mount.js",
20
+ "./modes.css": "./src/modes.css",
21
+ "./svelte-plugin-ui": "./src/svelte-plugin-ui/index.ts",
22
+ "./svelte-plugin-ui/design-modes": "./src/svelte-plugin-ui/plugins/design-modes.ts",
23
+ "./svelte-plugin-ui/styles/base.css": "./src/svelte-plugin-ui/styles/base.css"
20
24
  },
21
25
  "dependencies": {
22
26
  "alpinejs": "^3.15.8",
23
27
  "jsonc-parser": "^3.3.1",
24
28
  "tachyons": "^4.12.0"
29
+ },
30
+ "peerDependencies": {
31
+ "svelte": "^5.0.0"
32
+ },
33
+ "peerDependenciesMeta": {
34
+ "svelte": {
35
+ "optional": true
36
+ }
25
37
  }
26
38
  }
package/src/index.js CHANGED
@@ -30,6 +30,9 @@ export { subscribeToHash, getHashSnapshot } from './hashSubscribe.js'
30
30
  // Body class sync (overrides + scene → <body> classes)
31
31
  export { installBodyClassSync, setSceneClass, syncOverrideClasses } from './bodyClasses.js'
32
32
 
33
+ // Design modes (mode registry, switching, event bus)
34
+ export { registerMode, unregisterMode, getRegisteredModes, getCurrentMode, activateMode, deactivateMode, subscribeToMode, getModeSnapshot, syncModeClasses, on, off, emit, initModesConfig, isModesEnabled } from './modes.js'
35
+
33
36
  // Dev tools (vanilla JS, framework-agnostic)
34
37
  export { mountDevTools } from './devtools.js'
35
38
  export { mountSceneDebug } from './sceneDebug.js'
package/src/modes.css ADDED
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Base styles for the storyboard design-mode system.
3
+ *
4
+ * Each mode applies a `storyboard-mode-{name}` class on <html>.
5
+ * Use these classes to conditionally show/hide or restyle content per mode.
6
+ */
7
+
8
+ :root {
9
+ --mode-color: #fcfcfc;
10
+ --color-orange: #dfb490;
11
+ --color-green: #2a9d8f;
12
+ --color-blue: #4a7fad;
13
+ --color-purple: #7655a4;
14
+ --color-dark: #2a2a2a;
15
+ }
16
+
17
+ html.storyboard-mode-present { --mode-color: var(--color-green); }
18
+ html.storyboard-mode-plan { --mode-color: var(--color-blue); }
19
+ html.storyboard-mode-inspect { --mode-color: var(--color-purple); }
20
+ html.storyboard-mode-prototype { --mode-color: var(--color-dark); }
21
+
22
+ html.storyboard-mode-present,
23
+ html.storyboard-mode-plan,
24
+ html.storyboard-mode-prototype,
25
+ html.storyboard-mode-inspect {
26
+ padding: 12px;
27
+ background-color: var(--mode-color);
28
+ transition: background-color 0.2s ease, padding 0.2s ease;
29
+ }
30
+
31
+ html.storyboard-mode-prototype {
32
+ padding: 0;
33
+ }
34
+
35
+ html.storyboard-mode-present > body > #root,
36
+ html.storyboard-mode-plan > body > #root,
37
+ html.storyboard-mode-inspect > body > #root {
38
+ overflow-x: hidden;
39
+ overflow-y: hidden;
40
+ border-radius: var(--borderRadius-default);
41
+ /* box-shadow: 0 0 80px 40px var(--mode-color); */
42
+ }
43
+
44
+ #root > * {
45
+ overflow-y: auto;
46
+ }
47
+
48
+ html.storyboard-mode-present > body > #root {
49
+ box-shadow: 0 0 7px 2px rgb(42 157 143 / 60%);
50
+ }
51
+
52
+ html.storyboard-mode-plan > body > #root {
53
+ box-shadow: 0 0 7px 2px rgb(74 127 173 / 60%);
54
+ }
55
+
56
+ html.storyboard-mode-inspect > body > #root {
57
+ box-shadow: 0 0 7px 2px rgb(118 85 164 / 60%);
58
+ }
package/src/modes.js ADDED
@@ -0,0 +1,296 @@
1
+ /**
2
+ * Design Modes — mode registry, switching, and cross-plugin event bus.
3
+ *
4
+ * Framework-agnostic (zero npm dependencies).
5
+ * State is stored in the ?mode= URL search param so it's shareable and bookmarkable.
6
+ */
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Internal state
10
+ // ---------------------------------------------------------------------------
11
+
12
+ const _modes = new Map()
13
+ const _listeners = new Set()
14
+ const _eventListeners = new Map()
15
+
16
+ const DEFAULT_MODE = 'prototype'
17
+
18
+ let _modesEnabled = false
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Registry
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /**
25
+ * Register a mode plugin.
26
+ *
27
+ * @param {string} name Unique mode identifier (e.g. 'prototype', 'present')
28
+ * @param {object} config Mode configuration
29
+ * @param {string} config.label Human-readable label for UI
30
+ * @param {string} [config.icon] Octicon name or SVG string
31
+ * @param {string|string[]} [config.className] Extra class(es) applied to <html> when active
32
+ * @param {Function} [config.onActivate] Called when mode becomes active
33
+ * @param {Function} [config.onDeactivate] Called when leaving this mode
34
+ * @param {Array} [config.tools] Tool definitions for the tools toolbar
35
+ * @param {Array} [config.devTools] Tool definitions for the dev toolbar
36
+ */
37
+ export function registerMode(name, config = {}) {
38
+ if (_modes.has(name)) {
39
+ console.warn(`[storyboard] Mode "${name}" is already registered — overwriting.`)
40
+ }
41
+ _modes.set(name, { name, label: config.label ?? name, ...config })
42
+ _notify()
43
+ }
44
+
45
+ /**
46
+ * Remove a previously registered mode.
47
+ */
48
+ export function unregisterMode(name) {
49
+ if (name === DEFAULT_MODE) {
50
+ console.warn(`[storyboard] Cannot unregister the default mode "${DEFAULT_MODE}".`)
51
+ return
52
+ }
53
+ const mode = _modes.get(name)
54
+ if (!mode) return
55
+ // If this mode is currently active, deactivate first
56
+ if (getCurrentMode() === name) {
57
+ deactivateMode()
58
+ }
59
+ _modes.delete(name)
60
+ _notify()
61
+ }
62
+
63
+ /**
64
+ * Get all registered modes in insertion order.
65
+ *
66
+ * @returns {Array<{ name: string, label: string, icon?: string }>}
67
+ */
68
+ export function getRegisteredModes() {
69
+ return Array.from(_modes.values())
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Switching
74
+ // ---------------------------------------------------------------------------
75
+
76
+ /**
77
+ * Read the active mode from the ?mode= URL search param.
78
+ * Falls back to DEFAULT_MODE when the param is absent or unrecognised.
79
+ */
80
+ export function getCurrentMode() {
81
+ if (typeof window === 'undefined') return DEFAULT_MODE
82
+ const url = new URL(window.location.href)
83
+ const param = url.searchParams.get('mode')
84
+ if (param && _modes.has(param)) return param
85
+ return DEFAULT_MODE
86
+ }
87
+
88
+ /**
89
+ * Switch to a registered mode.
90
+ * Calls onDeactivate on the previous mode and onActivate on the new one.
91
+ *
92
+ * @param {string} name Mode to activate
93
+ * @param {object} [options] Passed through to onActivate
94
+ */
95
+ export function activateMode(name, options) {
96
+ if (!_modes.has(name)) {
97
+ console.warn(`[storyboard] Mode "${name}" is not registered.`)
98
+ return
99
+ }
100
+
101
+ const prev = getCurrentMode()
102
+ if (prev === name) return
103
+
104
+ // Deactivate previous
105
+ const prevMode = _modes.get(prev)
106
+ _removeModeClasses(prevMode)
107
+ if (prevMode?.onDeactivate) prevMode.onDeactivate()
108
+ emit('mode:deactivate', prev)
109
+
110
+ // Update URL param
111
+ const url = new URL(window.location.href)
112
+ if (name === DEFAULT_MODE) {
113
+ url.searchParams.delete('mode')
114
+ } else {
115
+ url.searchParams.set('mode', name)
116
+ }
117
+ window.history.replaceState(null, '', url.toString())
118
+
119
+ // Activate new
120
+ const newMode = _modes.get(name)
121
+ _applyModeClasses(newMode)
122
+ if (newMode?.onActivate) newMode.onActivate(options)
123
+ emit('mode:activate', name, options)
124
+ emit('mode:change', prev, name)
125
+
126
+ _notify()
127
+ }
128
+
129
+ /**
130
+ * Return to the default mode.
131
+ */
132
+ export function deactivateMode() {
133
+ activateMode(DEFAULT_MODE)
134
+ }
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // Reactivity (for useSyncExternalStore)
138
+ // ---------------------------------------------------------------------------
139
+
140
+ /**
141
+ * Subscribe to mode changes. Compatible with React's useSyncExternalStore.
142
+ *
143
+ * @param {Function} callback Called whenever the mode or registry changes
144
+ * @returns {Function} Unsubscribe function
145
+ */
146
+ export function subscribeToMode(callback) {
147
+ _listeners.add(callback)
148
+ // Also listen to popstate so browser back/forward syncs mode
149
+ const onPopState = () => {
150
+ _notify()
151
+ }
152
+ window.addEventListener('popstate', onPopState)
153
+ return () => {
154
+ _listeners.delete(callback)
155
+ window.removeEventListener('popstate', onPopState)
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Snapshot for useSyncExternalStore.
161
+ * Returns a serialised string that changes when mode or registry changes.
162
+ */
163
+ export function getModeSnapshot() {
164
+ const mode = getCurrentMode()
165
+ const names = Array.from(_modes.keys()).join(',')
166
+ return `${mode}|${names}`
167
+ }
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // Event bus (cross-plugin communication)
171
+ // ---------------------------------------------------------------------------
172
+
173
+ /**
174
+ * Listen for an event.
175
+ *
176
+ * @param {string} event Event name (e.g. 'mode:change', 'room:create')
177
+ * @param {Function} callback
178
+ */
179
+ export function on(event, callback) {
180
+ if (!_eventListeners.has(event)) {
181
+ _eventListeners.set(event, new Set())
182
+ }
183
+ _eventListeners.get(event).add(callback)
184
+ }
185
+
186
+ /**
187
+ * Remove an event listener.
188
+ */
189
+ export function off(event, callback) {
190
+ const listeners = _eventListeners.get(event)
191
+ if (listeners) listeners.delete(callback)
192
+ }
193
+
194
+ /**
195
+ * Emit an event to all registered listeners.
196
+ *
197
+ * @param {string} event Event name
198
+ * @param {...*} args Arguments forwarded to listeners
199
+ */
200
+ export function emit(event, ...args) {
201
+ const listeners = _eventListeners.get(event)
202
+ if (!listeners) return
203
+ for (const cb of listeners) {
204
+ try {
205
+ cb(...args)
206
+ } catch (err) {
207
+ console.error(`[storyboard] Error in "${event}" listener:`, err)
208
+ }
209
+ }
210
+ }
211
+
212
+ // ---------------------------------------------------------------------------
213
+ // Internal helpers
214
+ // ---------------------------------------------------------------------------
215
+
216
+ /**
217
+ * Collect all classes for a mode: the automatic `storyboard-mode-{name}`
218
+ * plus any custom `className` string(s) from the mode config.
219
+ */
220
+ function _getModeClasses(mode) {
221
+ if (!mode) return []
222
+ const classes = [`storyboard-mode-${mode.name}`]
223
+ if (mode.className) {
224
+ const extra = Array.isArray(mode.className) ? mode.className : mode.className.split(/\s+/)
225
+ classes.push(...extra.filter(Boolean))
226
+ }
227
+ return classes
228
+ }
229
+
230
+ function _applyModeClasses(mode) {
231
+ if (typeof document === 'undefined') return
232
+ const classes = _getModeClasses(mode)
233
+ if (classes.length) document.documentElement.classList.add(...classes)
234
+ }
235
+
236
+ function _removeModeClasses(mode) {
237
+ if (typeof document === 'undefined') return
238
+ const classes = _getModeClasses(mode)
239
+ if (classes.length) document.documentElement.classList.remove(...classes)
240
+ }
241
+
242
+ /**
243
+ * Apply classes for the current mode on first load.
244
+ * Called automatically so the initial mode is reflected in the DOM.
245
+ */
246
+ export function syncModeClasses() {
247
+ const name = getCurrentMode()
248
+ const mode = _modes.get(name)
249
+ if (mode) _applyModeClasses(mode)
250
+ }
251
+
252
+ function _notify() {
253
+ for (const cb of _listeners) {
254
+ try {
255
+ cb()
256
+ } catch (err) {
257
+ console.error('[storyboard] Error in mode subscriber:', err)
258
+ }
259
+ }
260
+ }
261
+
262
+ // ---------------------------------------------------------------------------
263
+ // Configuration
264
+ // ---------------------------------------------------------------------------
265
+
266
+ /**
267
+ * Initialize modes configuration.
268
+ * Called by the Vite data plugin's generated virtual module.
269
+ * @param {{ enabled?: boolean }} [config]
270
+ */
271
+ export function initModesConfig(config = {}) {
272
+ _modesEnabled = config.enabled !== false
273
+ }
274
+
275
+ /**
276
+ * Check whether modes UI is enabled.
277
+ * When false, the app stays in prototype mode with no mode switcher.
278
+ * @returns {boolean}
279
+ */
280
+ export function isModesEnabled() {
281
+ return _modesEnabled
282
+ }
283
+
284
+ // ---------------------------------------------------------------------------
285
+ // Test helpers
286
+ // ---------------------------------------------------------------------------
287
+
288
+ /**
289
+ * Reset all internal state. Only for use in tests.
290
+ */
291
+ export function _resetModes() {
292
+ _modes.clear()
293
+ _listeners.clear()
294
+ _eventListeners.clear()
295
+ _modesEnabled = false
296
+ }
@@ -0,0 +1,291 @@
1
+ import {
2
+ registerMode,
3
+ unregisterMode,
4
+ getRegisteredModes,
5
+ getCurrentMode,
6
+ activateMode,
7
+ deactivateMode,
8
+ subscribeToMode,
9
+ getModeSnapshot,
10
+ on,
11
+ off,
12
+ emit,
13
+ _resetModes,
14
+ } from './modes.js'
15
+
16
+ afterEach(() => {
17
+ _resetModes()
18
+ const url = new URL(window.location.href)
19
+ url.searchParams.delete('mode')
20
+ window.history.replaceState(null, '', url.toString())
21
+ })
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Registry
25
+ // ---------------------------------------------------------------------------
26
+
27
+ describe('registry', () => {
28
+ it('registerMode adds a mode to the registry', () => {
29
+ registerMode('prototype', { label: 'Prototype' })
30
+ expect(getRegisteredModes()).toEqual([{ name: 'prototype', label: 'Prototype' }])
31
+ })
32
+
33
+ it('warns when overwriting an existing mode', () => {
34
+ const spy = vi.spyOn(console, 'warn').mockImplementation(() => {})
35
+ registerMode('prototype', { label: 'V1' })
36
+ registerMode('prototype', { label: 'V2' })
37
+ expect(spy).toHaveBeenCalledWith(expect.stringContaining('overwriting'))
38
+ spy.mockRestore()
39
+ })
40
+
41
+ it('getRegisteredModes returns modes in insertion order', () => {
42
+ registerMode('a', { label: 'A' })
43
+ registerMode('b', { label: 'B' })
44
+ registerMode('c', { label: 'C' })
45
+ const names = getRegisteredModes().map((m) => m.name)
46
+ expect(names).toEqual(['a', 'b', 'c'])
47
+ })
48
+
49
+ it('unregisterMode removes a mode', () => {
50
+ registerMode('present', { label: 'Present' })
51
+ unregisterMode('present')
52
+ expect(getRegisteredModes()).toEqual([])
53
+ })
54
+
55
+ it('cannot unregister the default mode', () => {
56
+ const spy = vi.spyOn(console, 'warn').mockImplementation(() => {})
57
+ registerMode('prototype', { label: 'Prototype' })
58
+ unregisterMode('prototype')
59
+ expect(spy).toHaveBeenCalledWith(expect.stringContaining('Cannot unregister'))
60
+ expect(getRegisteredModes()).toHaveLength(1)
61
+ spy.mockRestore()
62
+ })
63
+ })
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // getCurrentMode
67
+ // ---------------------------------------------------------------------------
68
+
69
+ describe('getCurrentMode', () => {
70
+ it('returns "prototype" by default', () => {
71
+ expect(getCurrentMode()).toBe('prototype')
72
+ })
73
+
74
+ it('reads the ?mode= search param when mode is registered', () => {
75
+ registerMode('present', { label: 'Present' })
76
+ const url = new URL(window.location.href)
77
+ url.searchParams.set('mode', 'present')
78
+ window.history.replaceState(null, '', url.toString())
79
+
80
+ expect(getCurrentMode()).toBe('present')
81
+ })
82
+
83
+ it('ignores unregistered modes in the URL param', () => {
84
+ const url = new URL(window.location.href)
85
+ url.searchParams.set('mode', 'nonexistent')
86
+ window.history.replaceState(null, '', url.toString())
87
+
88
+ expect(getCurrentMode()).toBe('prototype')
89
+ })
90
+ })
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // activateMode
94
+ // ---------------------------------------------------------------------------
95
+
96
+ describe('activateMode', () => {
97
+ it('updates the ?mode= URL param', () => {
98
+ registerMode('prototype', { label: 'Prototype' })
99
+ registerMode('present', { label: 'Present' })
100
+ activateMode('present')
101
+
102
+ const url = new URL(window.location.href)
103
+ expect(url.searchParams.get('mode')).toBe('present')
104
+ })
105
+
106
+ it('calls onDeactivate on the previous mode and onActivate on the new', () => {
107
+ const deactivate = vi.fn()
108
+ const activate = vi.fn()
109
+ registerMode('prototype', { label: 'Prototype', onDeactivate: deactivate })
110
+ registerMode('present', { label: 'Present', onActivate: activate })
111
+
112
+ activateMode('present')
113
+ expect(deactivate).toHaveBeenCalledTimes(1)
114
+ expect(activate).toHaveBeenCalledTimes(1)
115
+ })
116
+
117
+ it('is a no-op when activating the already-active mode', () => {
118
+ const activate = vi.fn()
119
+ registerMode('prototype', { label: 'Prototype', onActivate: activate })
120
+ // prototype is already active by default
121
+ activateMode('prototype')
122
+ expect(activate).not.toHaveBeenCalled()
123
+ })
124
+
125
+ it('warns when activating an unregistered mode', () => {
126
+ const spy = vi.spyOn(console, 'warn').mockImplementation(() => {})
127
+ activateMode('unknown')
128
+ expect(spy).toHaveBeenCalledWith(expect.stringContaining('not registered'))
129
+ spy.mockRestore()
130
+ })
131
+ })
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // deactivateMode
135
+ // ---------------------------------------------------------------------------
136
+
137
+ describe('deactivateMode', () => {
138
+ it('returns to prototype mode', () => {
139
+ registerMode('prototype', { label: 'Prototype' })
140
+ registerMode('present', { label: 'Present' })
141
+ activateMode('present')
142
+ deactivateMode()
143
+ expect(getCurrentMode()).toBe('prototype')
144
+ })
145
+
146
+ it('removes the ?mode= URL param', () => {
147
+ registerMode('prototype', { label: 'Prototype' })
148
+ registerMode('present', { label: 'Present' })
149
+ activateMode('present')
150
+ deactivateMode()
151
+
152
+ const url = new URL(window.location.href)
153
+ expect(url.searchParams.has('mode')).toBe(false)
154
+ })
155
+ })
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // Subscriptions
159
+ // ---------------------------------------------------------------------------
160
+
161
+ describe('subscribeToMode', () => {
162
+ it('callback fires on activateMode', () => {
163
+ const cb = vi.fn()
164
+ registerMode('prototype', { label: 'Prototype' })
165
+ registerMode('present', { label: 'Present' })
166
+ const unsub = subscribeToMode(cb)
167
+
168
+ activateMode('present')
169
+ expect(cb).toHaveBeenCalled()
170
+ unsub()
171
+ })
172
+
173
+ it('callback fires on registerMode', () => {
174
+ const cb = vi.fn()
175
+ const unsub = subscribeToMode(cb)
176
+
177
+ registerMode('new-mode', { label: 'New' })
178
+ expect(cb).toHaveBeenCalled()
179
+ unsub()
180
+ })
181
+
182
+ it('unsubscribe stops further calls', () => {
183
+ const cb = vi.fn()
184
+ registerMode('prototype', { label: 'Prototype' })
185
+ registerMode('present', { label: 'Present' })
186
+ const unsub = subscribeToMode(cb)
187
+ unsub()
188
+
189
+ activateMode('present')
190
+ expect(cb).not.toHaveBeenCalled()
191
+ })
192
+ })
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // getModeSnapshot
196
+ // ---------------------------------------------------------------------------
197
+
198
+ describe('getModeSnapshot', () => {
199
+ it('changes when mode changes', () => {
200
+ registerMode('prototype', { label: 'Prototype' })
201
+ registerMode('present', { label: 'Present' })
202
+ const snap1 = getModeSnapshot()
203
+
204
+ activateMode('present')
205
+ const snap2 = getModeSnapshot()
206
+ expect(snap1).not.toBe(snap2)
207
+ })
208
+
209
+ it('changes when registry changes', () => {
210
+ registerMode('prototype', { label: 'Prototype' })
211
+ const snap1 = getModeSnapshot()
212
+
213
+ registerMode('present', { label: 'Present' })
214
+ const snap2 = getModeSnapshot()
215
+ expect(snap1).not.toBe(snap2)
216
+ })
217
+ })
218
+
219
+ // ---------------------------------------------------------------------------
220
+ // Event bus
221
+ // ---------------------------------------------------------------------------
222
+
223
+ describe('event bus', () => {
224
+ it('on/emit fires the callback with arguments', () => {
225
+ const cb = vi.fn()
226
+ on('test:event', cb)
227
+ emit('test:event', 'a', 'b')
228
+ expect(cb).toHaveBeenCalledWith('a', 'b')
229
+ })
230
+
231
+ it('off removes the listener', () => {
232
+ const cb = vi.fn()
233
+ on('test:event', cb)
234
+ off('test:event', cb)
235
+ emit('test:event')
236
+ expect(cb).not.toHaveBeenCalled()
237
+ })
238
+
239
+ it('emit with no listeners does not throw', () => {
240
+ expect(() => emit('nonexistent')).not.toThrow()
241
+ })
242
+
243
+ it('catches errors thrown by listeners', () => {
244
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
245
+ on('bad', () => { throw new Error('boom') })
246
+ expect(() => emit('bad')).not.toThrow()
247
+ expect(spy).toHaveBeenCalled()
248
+ spy.mockRestore()
249
+ })
250
+ })
251
+
252
+ // ---------------------------------------------------------------------------
253
+ // Modes config
254
+ // ---------------------------------------------------------------------------
255
+
256
+ describe('modes config', () => {
257
+ // Need to import these separately since they were added after the top imports
258
+ let initModesConfig, isModesEnabled
259
+
260
+ beforeEach(async () => {
261
+ const mod = await import('./modes.js')
262
+ initModesConfig = mod.initModesConfig
263
+ isModesEnabled = mod.isModesEnabled
264
+ })
265
+
266
+ it('isModesEnabled returns false by default', () => {
267
+ expect(isModesEnabled()).toBe(false)
268
+ })
269
+
270
+ it('initModesConfig({ enabled: true }) enables modes', () => {
271
+ initModesConfig({ enabled: true })
272
+ expect(isModesEnabled()).toBe(true)
273
+ })
274
+
275
+ it('initModesConfig({ enabled: false }) disables modes', () => {
276
+ initModesConfig({ enabled: true })
277
+ initModesConfig({ enabled: false })
278
+ expect(isModesEnabled()).toBe(false)
279
+ })
280
+
281
+ it('initModesConfig() with no args enables modes (enabled !== false)', () => {
282
+ initModesConfig()
283
+ expect(isModesEnabled()).toBe(true)
284
+ })
285
+
286
+ it('_resetModes resets modesEnabled to false', () => {
287
+ initModesConfig({ enabled: true })
288
+ _resetModes()
289
+ expect(isModesEnabled()).toBe(false)
290
+ })
291
+ })