@hkdigital/lib-core 0.5.57 → 0.5.59

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.
@@ -1,144 +1,108 @@
1
1
  /**
2
- * Base class for page state machines with URL route mapping
2
+ * Route-aware data manager for page groups
3
3
  *
4
- * Simple state tracker that maps states to URL routes.
5
- * Does NOT enforce FSM transitions - allows free navigation
6
- * (because users can navigate to any URL via browser).
4
+ * Tracks navigation within a route group and manages business/domain data.
5
+ * Does NOT enforce transitions - allows free navigation via browser.
7
6
  *
8
7
  * Features:
9
- * - State-to-route mapping and sync
8
+ * - Current route tracking and synchronization
10
9
  * - Start path management
11
- * - Data properties for business/domain state
12
- * - Visited states tracking
13
- * - onEnter hooks with abort/complete handlers for animations
10
+ * - Data properties for business/domain state (using SvelteMap)
11
+ * - Visited routes tracking (using SvelteSet)
12
+ * - Fine-grained reactivity without manual revision tracking
13
+ *
14
+ * Best practices:
15
+ * - Use constants for routes: `const ROUTE_INTRO = '/intro/start'`
16
+ * - Use KEY_ constants for data: `const KEY_SCORE = 'score'`
14
17
  *
15
18
  * Basic usage:
16
19
  * ```javascript
20
+ * // Define constants
21
+ * const ROUTE_INTRO = '/intro/start';
22
+ * const KEY_SCORE = 'score';
23
+ * const KEY_TUTORIAL_SEEN = 'tutorial-seen';
24
+ *
17
25
  * const machine = new PageMachine({
18
- * startPath: '/intro/start',
19
- * routeMap: {
20
- * [STATE_START]: '/intro/start',
21
- * [STATE_PROFILE]: '/intro/profile'
26
+ * startPath: ROUTE_INTRO,
27
+ * routes: [ROUTE_INTRO, '/intro/profile', '/intro/complete'],
28
+ * initialData: {
29
+ * [KEY_SCORE]: 0,
30
+ * [KEY_TUTORIAL_SEEN]: false
22
31
  * }
23
32
  * });
24
33
  *
25
- * // Sync machine state with URL changes
34
+ * // Sync machine with URL changes
26
35
  * $effect(() => {
27
36
  * machine.syncFromPath($page.url.pathname);
28
37
  * });
38
+ *
39
+ * // Check current route (reactive)
40
+ * $effect(() => {
41
+ * if (machine.current === ROUTE_INTRO) {
42
+ * console.log('On intro page');
43
+ * }
44
+ * });
45
+ *
46
+ * // Access data (reactive, fine-grained)
47
+ * $effect(() => {
48
+ * const score = machine.getData(KEY_SCORE);
49
+ * console.log('Score changed:', score);
50
+ * });
29
51
  * ```
30
52
  *
31
- * With onEnter hooks (for animations):
53
+ * Animations and page-specific logic should use $effect in pages:
32
54
  * ```javascript
33
- * const machine = new PageMachine({
34
- * startPath: '/game/animate',
35
- * routeMap: {
36
- * [STATE_ANIMATE]: '/game/animate',
37
- * [STATE_PLAY]: '/game/play'
38
- * },
39
- * onEnterHooks: {
40
- * [STATE_ANIMATE]: (done) => {
41
- * const animation = playAnimation(1000);
42
- * animation.finished.then(() => done(STATE_PLAY));
55
+ * // In +page.svelte
56
+ * const animations = new PageAnimations();
43
57
  *
44
- * return {
45
- * abort: () => animation.cancel(),
46
- * complete: () => animation.finish()
47
- * };
48
- * }
58
+ * $effect(() => {
59
+ * if (someCondition) {
60
+ * animations.start();
49
61
  * }
50
62
  * });
51
- *
52
- * // Fast-forward animation
53
- * machine.completeTransitions();
54
- *
55
- * // Cancel animation
56
- * machine.abortTransitions();
57
63
  * ```
58
64
  */
59
- import { switchToPage } from '../../../util/sveltekit.js';
65
+ import { SvelteMap, SvelteSet } from 'svelte/reactivity';
60
66
 
61
67
  export default class PageMachine {
62
68
  /**
63
- * Logger instance for state machine
69
+ * Logger instance
64
70
  * @type {import('../../../logging/client.js').Logger}
65
71
  */
66
72
  logger;
73
+
67
74
  /**
68
- * Current state
75
+ * Current route path
69
76
  * @type {string}
70
77
  */
71
78
  // @ts-ignore
72
79
  #current = $state();
73
80
 
74
81
  /**
75
- * Start path for this page machine
82
+ * Start path for this route group
76
83
  * @type {string}
77
84
  */
78
85
  #startPath = '';
79
86
 
80
87
  /**
81
- * Initial/start state (derived from startPath)
82
- * @type {string}
83
- */
84
- #startState = '';
85
-
86
- /**
87
- * Map of states to route paths
88
- * @type {Record<string, string>}
89
- */
90
- #routeMap = {};
91
-
92
- /**
93
- * Reverse map of route paths to states
94
- * @type {Record<string, string>}
95
- */
96
- #pathToStateMap = {};
97
-
98
- /**
99
- * Data properties for business/domain state
100
- * Can be initialized from server and synced back
101
- * @type {Record<string, any>}
102
- */
103
- #data = $state({});
104
-
105
- /**
106
- * Track which states have been visited during this session
107
- * Useful for showing first-time hints/tips
108
- * @type {Set<string>}
109
- */
110
- // eslint-disable-next-line svelte/prefer-svelte-reactivity
111
- #visitedStates = new Set();
112
-
113
- /**
114
- * Revision counter for triggering reactivity
115
- * @type {number}
116
- */
117
- #revision = $state(0);
118
-
119
- /**
120
- * Map of state names to onEnter hook configurations
121
- * @type {Record<string, {onEnter: Function}>}
88
+ * Optional list of valid routes for this group
89
+ * @type {string[]}
122
90
  */
123
- #onEnterHooks = {};
91
+ #routes = [];
124
92
 
125
93
  /**
126
- * Current state's onEnter handler (abort/complete functions)
127
- * @type {{abort?: Function, complete?: Function} | null}
94
+ * Reactive map for business/domain data
95
+ * Uses SvelteMap for fine-grained reactivity
96
+ * @type {SvelteMap<string, any>}
128
97
  */
129
- #currentOnEnterHandler = null;
98
+ #data;
130
99
 
131
100
  /**
132
- * Current state's done callback
133
- * @type {Function | null}
101
+ * Reactive set for visited routes
102
+ * Uses SvelteSet for automatic reactivity
103
+ * @type {SvelteSet<string>}
134
104
  */
135
- #currentOnEnterDone = null;
136
-
137
- /**
138
- * Flag to prevent concurrent state transitions
139
- * @type {boolean}
140
- */
141
- #isTransitioning = false;
105
+ #visitedRoutes;
142
106
 
143
107
  /**
144
108
  * Constructor
@@ -146,129 +110,74 @@ export default class PageMachine {
146
110
  * @param {Object} config - Configuration object
147
111
  * @param {string} config.startPath
148
112
  * Start path for this route group (e.g., '/game/play')
149
- * @param {Record<string, string>} [config.routeMap={}]
150
- * Map of states to route paths
113
+ * @param {string[]} [config.routes=[]]
114
+ * Optional list of valid routes (for validation/dev tools)
151
115
  * @param {Record<string, any>} [config.initialData={}]
152
- * Initial data properties (from server)
153
- * @param {Record<string, Function>} [config.onEnterHooks={}]
154
- * Map of states to onEnter hook functions
116
+ * Initial data properties (use KEY_ constants for keys)
155
117
  * @param {import('../../../logging/client.js').Logger} [config.logger]
156
- * Logger instance (optional, if not provided no logging occurs)
118
+ * Logger instance (optional)
157
119
  *
158
120
  * @example
159
121
  * ```javascript
122
+ * const ROUTE_INTRO = '/intro/start';
123
+ * const KEY_INTRO_COMPLETED = 'intro-completed';
124
+ * const KEY_SCORE = 'score';
125
+ *
160
126
  * const machine = new PageMachine({
161
- * startPath: '/intro/start',
162
- * routeMap: {
163
- * [STATE_START]: '/intro/start',
164
- * [STATE_ANIMATE]: '/intro/animate'
165
- * },
127
+ * startPath: ROUTE_INTRO,
128
+ * routes: [ROUTE_INTRO, '/intro/profile'],
166
129
  * initialData: {
167
- * INTRO_COMPLETED: false
168
- * },
169
- * onEnterHooks: {
170
- * [STATE_ANIMATE]: (done) => {
171
- * setTimeout(() => done(STATE_START), 1000);
172
- * return {
173
- * abort: () => clearTimeout(...),
174
- * complete: () => done(STATE_START)
175
- * };
176
- * }
130
+ * [KEY_INTRO_COMPLETED]: false,
131
+ * [KEY_SCORE]: 0
177
132
  * }
178
133
  * });
179
134
  * ```
180
135
  */
181
- constructor({
182
- startPath,
183
- routeMap = {},
184
- initialData = {},
185
- onEnterHooks = {},
186
- logger = null
187
- }) {
136
+ constructor({ startPath, routes = [], initialData = {}, logger = null }) {
188
137
  if (!startPath) {
189
138
  throw new Error('PageMachine requires startPath parameter');
190
139
  }
191
140
 
192
141
  this.logger = logger;
193
142
  this.#startPath = startPath;
194
- this.#routeMap = routeMap;
195
- this.#data = initialData;
196
- this.#onEnterHooks = this.#normalizeOnEnterHooks(onEnterHooks);
197
-
198
- // Build reverse map (path -> state) and validate no duplicates
199
- for (const [state, path] of Object.entries(routeMap)) {
200
- // Check if this path is already mapped to a different state
201
- const existingState = this.#pathToStateMap[path];
202
- if (existingState && existingState !== state) {
203
- throw new Error(
204
- `PageMachine: Duplicate route mapping detected. ` +
205
- `Path "${path}" is mapped to both "${existingState}" and "${state}". ` +
206
- `Each route path must map to exactly one state.`
207
- );
208
- }
209
- this.#pathToStateMap[path] = state;
210
- }
211
-
212
- // Derive initial state from startPath
213
- const initialState = this.#pathToStateMap[startPath];
214
- if (!initialState) {
215
- throw new Error(
216
- `PageMachine: startPath "${startPath}" not found in routeMap`
217
- );
218
- }
143
+ this.#routes = routes;
144
+ this.#current = startPath;
219
145
 
220
- this.#startState = initialState;
221
- this.#current = initialState;
146
+ // Initialize reactive data structures
147
+ this.#data = new SvelteMap();
148
+ this.#visitedRoutes = new SvelteSet();
222
149
 
223
- // Mark initial state as visited
224
- this.#visitedStates.add(initialState);
225
- }
226
-
227
- /**
228
- * Normalize onEnterHooks to ensure consistent format
229
- * Converts function to {onEnter: function} object
230
- *
231
- * @param {Record<string, Function|{onEnter: Function}>} hooks - Raw hooks configuration
232
- * @returns {Record<string, {onEnter: Function}>} Normalized hooks
233
- */
234
- #normalizeOnEnterHooks(hooks) {
235
- /** @type {Record<string, {onEnter: Function} */
236
- const normalized = {};
237
-
238
- for (const [state, hook] of Object.entries(hooks)) {
239
- if (typeof hook === 'function') {
240
- // Simple function -> wrap in object
241
- normalized[state] = { onEnter: hook };
242
- } else if (hook?.onEnter) {
243
- // Already an object with onEnter
244
- normalized[state] = hook;
245
- }
150
+ // Populate initial data
151
+ for (const [key, value] of Object.entries(initialData)) {
152
+ this.#data.set(key, value);
246
153
  }
247
154
 
248
- return normalized;
155
+ // Mark start path as visited
156
+ this.#visitedRoutes.add(startPath);
249
157
  }
250
158
 
251
159
  /**
252
- * Synchronize machine state with URL path
160
+ * Synchronize machine with URL path
253
161
  *
254
- * Call this in a $effect that watches $page.url.pathname
255
- * Automatically tracks visited states and triggers onEnter hooks
162
+ * Call this in a $effect that watches $page.url.pathname.
163
+ * Automatically tracks visited routes.
256
164
  *
257
165
  * @param {string} currentPath - Current URL pathname
258
166
  *
259
- * @returns {boolean} True if state was changed
167
+ * @returns {boolean} True if route was changed
260
168
  */
261
169
  syncFromPath(currentPath) {
262
- const targetState = this.#getStateFromPath(currentPath);
170
+ // Find matching route
171
+ const matchedRoute = this.#findMatchingRoute(currentPath);
172
+
173
+ if (matchedRoute && matchedRoute !== this.#current) {
174
+ this.logger?.debug(`syncFromPath: ${currentPath} → ${matchedRoute}`);
263
175
 
264
- if (targetState && targetState !== this.#current) {
265
- // Log state transition from URL sync
266
- this.logger?.debug(
267
- `syncFromPath: ${currentPath} → targetState: ${targetState}`
268
- );
176
+ const oldRoute = this.#current;
177
+ this.#current = matchedRoute;
178
+ this.#visitedRoutes.add(matchedRoute);
269
179
 
270
- // Use #setState to handle onEnter hooks
271
- this.#setState(targetState);
180
+ this.logger?.debug(`Route changed: ${oldRoute} ${matchedRoute}`);
272
181
 
273
182
  return true;
274
183
  }
@@ -277,154 +186,50 @@ export default class PageMachine {
277
186
  }
278
187
 
279
188
  /**
280
- * Set the current state directly (internal use only)
281
- * Handles onEnter hooks and auto-transitions
282
- *
283
- * Note: This is private to enforce URL-first navigation.
284
- * To change state, navigate to the URL using switchToPage()
285
- * and let syncFromPath() update the state.
286
- *
287
- * @param {string} newState - Target state
288
- */
289
- async #setState(newState) {
290
- if (newState === this.#current || this.#isTransitioning) {
291
- return;
292
- }
293
-
294
- // Abort previous state's onEnter handler
295
- if (this.#currentOnEnterHandler?.abort) {
296
- this.#currentOnEnterHandler.abort();
297
- }
298
- this.#currentOnEnterHandler = null;
299
- this.#currentOnEnterDone = null;
300
-
301
- const oldState = this.#current;
302
- this.#isTransitioning = true;
303
- this.#current = newState;
304
- this.#visitedStates.add(newState);
305
-
306
- // Log state transition
307
- this.logger?.debug(`setState: ${oldState} → ${newState}`);
308
-
309
- // Check if this state has an onEnter hook
310
- const hookConfig = this.#onEnterHooks[newState];
311
- if (hookConfig?.onEnter) {
312
- // Create done callback for auto-transition
313
- let doneCalled = false;
314
-
315
- const done = (/** @type {string} */ nextState) => {
316
- if (!doneCalled && nextState && nextState !== newState) {
317
- doneCalled = true;
318
- this.#isTransitioning = false;
319
- this.#setState(nextState);
320
- }
321
- };
322
-
323
- this.#currentOnEnterDone = done;
324
-
325
- // Call the onEnter hook
326
- try {
327
- const handler = hookConfig.onEnter(done);
328
-
329
- // Store abort/complete handlers if provided
330
- if (handler && typeof handler === 'object') {
331
- if (handler.abort || handler.complete) {
332
- this.#currentOnEnterHandler = {
333
- abort: handler.abort,
334
- complete: handler.complete
335
- };
336
- }
337
- }
338
-
339
- // If hook returned a promise, await it
340
- if (handler?.then) {
341
- await handler;
342
- }
343
- } catch (error) {
344
- const logger = this.logger ?? console;
345
- logger.error(`Error in onEnter hook for state ${newState}:`, error);
346
- }
347
- }
348
-
349
- this.#isTransitioning = false;
350
- this.#revision++;
351
- }
352
-
353
- /**
354
- * Get state name from URL path
189
+ * Find matching route from path
355
190
  *
356
191
  * @param {string} path - URL pathname
357
192
  *
358
- * @returns {string|null} State name or null
193
+ * @returns {string|null} Matched route or null
359
194
  */
360
- #getStateFromPath(path) {
361
- // Try exact match first
362
- if (this.#pathToStateMap[path]) {
363
- return this.#pathToStateMap[path];
364
- }
365
-
366
- // Try partial match (path includes route)
367
- for (const [routePath, state] of Object.entries(this.#pathToStateMap)) {
368
- if (path.includes(routePath)) {
369
- return state;
195
+ #findMatchingRoute(path) {
196
+ // If routes list provided, try to match against it
197
+ if (this.#routes.length > 0) {
198
+ // Try exact match first
199
+ if (this.#routes.includes(path)) {
200
+ return path;
370
201
  }
371
- }
372
-
373
- return null;
374
- }
375
202
 
376
- /**
377
- * Get route path for a given state
378
- *
379
- * @param {string} state - State name
380
- *
381
- * @returns {string} Route path or null if no mapping
382
- */
383
- getPathForState(state) {
384
- const path = this.#routeMap[state];
203
+ // Try partial match (path starts with route)
204
+ for (const route of this.#routes) {
205
+ if (path.startsWith(route)) {
206
+ return route;
207
+ }
208
+ }
385
209
 
386
- if (!path) {
387
- throw new Error(`No path found for state [${state}]`);
210
+ return null;
388
211
  }
389
212
 
213
+ // No routes list - accept any path
390
214
  return path;
391
215
  }
392
216
 
393
217
  /**
394
- * Navigate to the route path for a given state
395
- *
396
- * @param {string} state - State name
397
- */
398
- navigateToState(state) {
399
- const path = this.getPathForState(state);
400
- switchToPage(path);
401
- }
402
-
403
- /**
404
- * Get route path for current state
218
+ * Get current route
405
219
  *
406
- * @returns {string|null} Route path or null if no mapping
407
- */
408
- getCurrentPath() {
409
- return this.getPathForState(this.#current);
410
- }
411
-
412
- /**
413
- * Get current state
414
- *
415
- * @returns {string} Current state name
220
+ * @returns {string} Current route path
416
221
  */
417
222
  get current() {
418
223
  return this.#current;
419
224
  }
420
225
 
421
226
  /**
422
- * Get the route map
227
+ * Get the routes list
423
228
  *
424
- * @returns {Record<string, string>} Copy of route map
229
+ * @returns {string[]} Copy of routes list
425
230
  */
426
- get routeMap() {
427
- return { ...this.#routeMap };
231
+ get routes() {
232
+ return [...this.#routes];
428
233
  }
429
234
 
430
235
  /* ===== Data Properties (Business/Domain State) ===== */
@@ -432,43 +237,59 @@ export default class PageMachine {
432
237
  /**
433
238
  * Set a data property value
434
239
  *
435
- * @param {string} key - Property key
240
+ * Automatically reactive - effects watching this key will re-run.
241
+ * Uses fine-grained reactivity, so only effects watching this specific
242
+ * key will be triggered.
243
+ *
244
+ * @param {string} key - Property key (use KEY_ constant)
436
245
  * @param {any} value - Property value
437
246
  *
438
247
  * @example
439
248
  * ```javascript
440
- * machine.setData('HAS_STRONG_PROFILE', true);
441
- * machine.setData('PROFILE_SCORE', 85);
249
+ * const KEY_HAS_STRONG_PROFILE = 'has-strong-profile';
250
+ * const KEY_PROFILE_SCORE = 'profile-score';
251
+ *
252
+ * machine.setData(KEY_HAS_STRONG_PROFILE, true);
253
+ * machine.setData(KEY_PROFILE_SCORE, 85);
442
254
  * ```
443
255
  */
444
256
  setData(key, value) {
445
- this.#data[key] = value;
446
- this.#revision++;
257
+ this.#data.set(key, value);
447
258
  }
448
259
 
449
260
  /**
450
261
  * Get a data property value
451
262
  *
452
- * @param {string} key - Property key
263
+ * Automatically reactive - creates a dependency on this specific key.
264
+ * The effect will only re-run when THIS key changes, not when other
265
+ * keys change.
266
+ *
267
+ * @param {string} key - Property key (use KEY_ constant)
453
268
  *
454
269
  * @returns {any} Property value or undefined
455
270
  *
456
271
  * @example
457
272
  * ```javascript
458
- * const hasProfile = machine.getData('HAS_STRONG_PROFILE');
459
- * const score = machine.getData('PROFILE_SCORE');
273
+ * const KEY_SCORE = 'score';
274
+ *
275
+ * // Reactive - re-runs only when KEY_SCORE changes
276
+ * $effect(() => {
277
+ * const score = machine.getData(KEY_SCORE);
278
+ * console.log('Score:', score);
279
+ * });
460
280
  * ```
461
281
  */
462
282
  getData(key) {
463
- // Access revision to ensure reactivity
464
- this.#revision;
465
- return this.#data[key];
283
+ return this.#data.get(key);
466
284
  }
467
285
 
468
286
  /**
469
- * Get all data properties
287
+ * Get all data properties as plain object
288
+ *
289
+ * Note: This returns a snapshot (plain object), not a reactive map.
290
+ * Use this for serialization or server sync, not for reactive tracking.
470
291
  *
471
- * @returns {Record<string, any>} Copy of all data
292
+ * @returns {Record<string, any>} Plain object with all data
472
293
  *
473
294
  * @example
474
295
  * ```javascript
@@ -477,58 +298,124 @@ export default class PageMachine {
477
298
  * ```
478
299
  */
479
300
  getAllData() {
480
- // Access revision to ensure reactivity
481
- this.#revision;
482
- return { ...this.#data };
301
+ return Object.fromEntries(this.#data);
483
302
  }
484
303
 
485
304
  /**
486
305
  * Update multiple data properties at once
487
306
  *
488
- * @param {Record<string, any>} dataUpdates - Object with key-value pairs
307
+ * Each property update triggers fine-grained reactivity.
308
+ *
309
+ * @param {Record<string, any>} dataUpdates
310
+ * Object with key-value pairs (use KEY_ constants for keys)
489
311
  *
490
312
  * @example
491
313
  * ```javascript
314
+ * const KEY_HAS_STRONG_PROFILE = 'has-strong-profile';
315
+ * const KEY_PROFILE_SCORE = 'profile-score';
316
+ * const KEY_MATCHED_SECTOR = 'matched-sector';
317
+ *
492
318
  * machine.updateData({
493
- * HAS_STRONG_PROFILE: true,
494
- * PROFILE_SCORE: 85,
495
- * MATCHED_SECTOR: 'technology'
319
+ * [KEY_HAS_STRONG_PROFILE]: true,
320
+ * [KEY_PROFILE_SCORE]: 85,
321
+ * [KEY_MATCHED_SECTOR]: 'technology'
496
322
  * });
497
323
  * ```
498
324
  */
499
325
  updateData(dataUpdates) {
500
326
  for (const [key, value] of Object.entries(dataUpdates)) {
501
- this.#data[key] = value;
327
+ this.#data.set(key, value);
502
328
  }
503
- this.#revision++;
504
329
  }
505
330
 
506
- /* ===== Visited States Tracking ===== */
331
+ /**
332
+ * Delete a data property
333
+ *
334
+ * @param {string} key - Property key to delete (use KEY_ constant)
335
+ *
336
+ * @returns {boolean} True if the key existed and was deleted
337
+ *
338
+ * @example
339
+ * ```javascript
340
+ * const KEY_TEMPORARY_FLAG = 'temporary-flag';
341
+ *
342
+ * machine.deleteData(KEY_TEMPORARY_FLAG);
343
+ * ```
344
+ */
345
+ deleteData(key) {
346
+ return this.#data.delete(key);
347
+ }
507
348
 
508
349
  /**
509
- * Check if a state has been visited
350
+ * Check if data property exists
510
351
  *
511
- * @param {string} state - State name to check
352
+ * @param {string} key - Property key to check (use KEY_ constant)
512
353
  *
513
- * @returns {boolean} True if the state has been visited
354
+ * @returns {boolean} True if the key exists
514
355
  *
515
356
  * @example
516
357
  * ```javascript
517
- * if (machine.hasVisited(STATE_PROFILE)) {
518
- * // User has seen profile page, skip intro
358
+ * const KEY_TUTORIAL_SEEN = 'tutorial-seen';
359
+ *
360
+ * if (machine.hasData(KEY_TUTORIAL_SEEN)) {
361
+ * // Skip tutorial
519
362
  * }
520
363
  * ```
521
364
  */
522
- hasVisited(state) {
523
- // Access revision to ensure reactivity
524
- this.#revision;
525
- return this.#visitedStates.has(state);
365
+ hasData(key) {
366
+ return this.#data.has(key);
367
+ }
368
+
369
+ /**
370
+ * Clear all data properties
371
+ *
372
+ * @example
373
+ * ```javascript
374
+ * machine.clearData(); // Reset all game data
375
+ * ```
376
+ */
377
+ clearData() {
378
+ this.#data.clear();
379
+ }
380
+
381
+ /**
382
+ * Get number of data properties
383
+ *
384
+ * @returns {number} Number of data entries
385
+ */
386
+ get dataSize() {
387
+ return this.#data.size;
388
+ }
389
+
390
+ /* ===== Visited Routes Tracking ===== */
391
+
392
+ /**
393
+ * Check if a route has been visited
394
+ *
395
+ * Automatically reactive - creates a dependency on the visited routes set.
396
+ *
397
+ * @param {string} route - Route path to check
398
+ *
399
+ * @returns {boolean} True if the route has been visited
400
+ *
401
+ * @example
402
+ * ```javascript
403
+ * // Reactive - re-runs when visited routes change
404
+ * $effect(() => {
405
+ * if (machine.hasVisited('/intro/profile')) {
406
+ * console.log('User has seen profile page');
407
+ * }
408
+ * });
409
+ * ```
410
+ */
411
+ hasVisited(route) {
412
+ return this.#visitedRoutes.has(route);
526
413
  }
527
414
 
528
415
  /**
529
- * Check if the start state has been visited
416
+ * Check if the start route has been visited
530
417
  *
531
- * @returns {boolean} True if the start state has been visited
418
+ * @returns {boolean} True if the start route has been visited
532
419
  *
533
420
  * @example
534
421
  * ```javascript
@@ -538,28 +425,42 @@ export default class PageMachine {
538
425
  * ```
539
426
  */
540
427
  get hasVisitedStart() {
541
- return this.hasVisited(this.#startState);
428
+ return this.hasVisited(this.#startPath);
542
429
  }
543
430
 
544
431
  /**
545
- * Get all visited states
432
+ * Get all visited routes as array
433
+ *
434
+ * Note: Returns a snapshot (plain array), not reactive.
546
435
  *
547
- * @returns {string[]} Array of visited state names
436
+ * @returns {string[]} Array of visited route paths
548
437
  */
549
- getVisitedStates() {
550
- // Access revision to ensure reactivity
551
- this.#revision;
552
- return Array.from(this.#visitedStates);
438
+ getVisitedRoutes() {
439
+ return Array.from(this.#visitedRoutes);
553
440
  }
554
441
 
555
442
  /**
556
- * Reset visited states tracking
557
- * Useful for testing or resetting experience
443
+ * Reset visited routes tracking
444
+ *
445
+ * Clears all visited routes and marks only the current route as visited.
446
+ *
447
+ * @example
448
+ * ```javascript
449
+ * machine.resetVisitedRoutes(); // Reset progress tracking
450
+ * ```
558
451
  */
559
- resetVisitedStates() {
560
- this.#visitedStates.clear();
561
- this.#visitedStates.add(this.#current);
562
- this.#revision++;
452
+ resetVisitedRoutes() {
453
+ this.#visitedRoutes.clear();
454
+ this.#visitedRoutes.add(this.#current);
455
+ }
456
+
457
+ /**
458
+ * Get number of visited routes
459
+ *
460
+ * @returns {number} Number of routes visited
461
+ */
462
+ get visitedRoutesCount() {
463
+ return this.#visitedRoutes.size;
563
464
  }
564
465
 
565
466
  /* ===== Start Path Methods ===== */
@@ -573,15 +474,6 @@ export default class PageMachine {
573
474
  return this.#startPath;
574
475
  }
575
476
 
576
- /**
577
- * Get the start state
578
- *
579
- * @returns {string} Start state name
580
- */
581
- get startState() {
582
- return this.#startState;
583
- }
584
-
585
477
  /**
586
478
  * Check if the supplied path matches the start path
587
479
  *
@@ -601,19 +493,19 @@ export default class PageMachine {
601
493
  }
602
494
 
603
495
  /**
604
- * Check if currently on the start state
496
+ * Check if currently on the start path
605
497
  *
606
- * @returns {boolean} True if current state is the start state
498
+ * @returns {boolean} True if current route is the start path
607
499
  *
608
500
  * @example
609
501
  * ```javascript
610
- * if (machine.isOnStartState) {
502
+ * if (machine.isOnStartPath) {
611
503
  * // Show onboarding
612
504
  * }
613
505
  * ```
614
506
  */
615
- get isOnStartState() {
616
- return this.#current === this.#startState;
507
+ get isOnStartPath() {
508
+ return this.#current === this.#startPath;
617
509
  }
618
510
 
619
511
  /**
@@ -631,78 +523,4 @@ export default class PageMachine {
631
523
  switchToPage(this.#startPath);
632
524
  });
633
525
  }
634
-
635
- /* ===== Transition Control Methods ===== */
636
-
637
- /**
638
- * Abort current state's transitions
639
- * Cancels animations/operations immediately (incomplete state)
640
- *
641
- * @example
642
- * ```javascript
643
- * // User clicks "Cancel" button
644
- * machine.abortTransitions();
645
- * ```
646
- */
647
- abortTransitions() {
648
- if (this.#currentOnEnterHandler?.abort) {
649
- this.#currentOnEnterHandler.abort();
650
- this.#currentOnEnterHandler = null;
651
- this.#currentOnEnterDone = null;
652
- this.#revision++;
653
- }
654
- }
655
-
656
- /**
657
- * Complete current state's transitions immediately
658
- * Fast-forwards animations/operations to completion (complete state)
659
- *
660
- * @example
661
- * ```javascript
662
- * // User clicks "Skip" or "Next" button
663
- * machine.completeTransitions();
664
- * ```
665
- */
666
- completeTransitions() {
667
- if (this.#currentOnEnterHandler?.complete) {
668
- this.#currentOnEnterHandler.complete();
669
- this.#currentOnEnterHandler = null;
670
- this.#currentOnEnterDone = null;
671
- this.#revision++;
672
- }
673
- }
674
-
675
- /**
676
- * Check if current state has transitions that can be completed
677
- *
678
- * @returns {boolean} True if completeTransitions() can be called
679
- *
680
- * @example
681
- * ```svelte
682
- * {#if machine.canCompleteTransitions}
683
- * <button onclick={() => machine.completeTransitions()}>Skip</button>
684
- * {/if}
685
- * ```
686
- */
687
- get canCompleteTransitions() {
688
- this.#revision; // Ensure reactivity
689
- return !!this.#currentOnEnterHandler?.complete;
690
- }
691
-
692
- /**
693
- * Check if current state has transitions that can be aborted
694
- *
695
- * @returns {boolean} True if abortTransitions() can be called
696
- *
697
- * @example
698
- * ```svelte
699
- * {#if machine.canAbortTransitions}
700
- * <button onclick={() => machine.abortTransitions()}>Cancel</button>
701
- * {/if}
702
- * ```
703
- */
704
- get canAbortTransitions() {
705
- this.#revision; // Ensure reactivity
706
- return !!this.#currentOnEnterHandler?.abort;
707
- }
708
526
  }