@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 +14 -2
- package/src/index.js +3 -0
- package/src/modes.css +58 -0
- package/src/modes.js +296 -0
- package/src/modes.test.js +291 -0
- package/src/svelte-plugin-ui/__tests__/ModeSwitch.test.ts +62 -0
- package/src/svelte-plugin-ui/__tests__/ToolbarShell.test.ts +67 -0
- package/src/svelte-plugin-ui/__tests__/designModes.test.ts +58 -0
- package/src/svelte-plugin-ui/__tests__/modeStore.test.ts +53 -0
- package/src/svelte-plugin-ui/__tests__/mount.test.ts +29 -0
- package/src/svelte-plugin-ui/components/ModeSwitch.svelte +116 -0
- package/src/svelte-plugin-ui/components/ToolbarShell.svelte +110 -0
- package/src/svelte-plugin-ui/index.ts +17 -0
- package/src/svelte-plugin-ui/mount.ts +90 -0
- package/src/svelte-plugin-ui/plugins/design-modes.ts +52 -0
- package/src/svelte-plugin-ui/stores/modeStore.ts +87 -0
- package/src/svelte-plugin-ui/stores/types.ts +39 -0
- package/src/svelte-plugin-ui/styles/base.css +71 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dfosco/storyboard-core",
|
|
3
|
-
"version": "
|
|
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
|
+
})
|