@hkdigital/lib-core 0.5.44 → 0.5.46

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.
@@ -0,0 +1,537 @@
1
+ /**
2
+ * Base class for page state machines with URL route mapping
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).
7
+ *
8
+ * Features:
9
+ * - State-to-route mapping and sync
10
+ * - Data properties for business/domain state
11
+ * - Visited states tracking
12
+ * - onEnter hooks with abort/complete handlers for animations
13
+ *
14
+ * Basic usage:
15
+ * ```javascript
16
+ * const machine = new PageMachine({
17
+ * initialState: STATE_START,
18
+ * routeMap: {
19
+ * [STATE_START]: '/intro/start',
20
+ * [STATE_PROFILE]: '/intro/profile'
21
+ * }
22
+ * });
23
+ *
24
+ * // Sync machine state with URL changes
25
+ * $effect(() => {
26
+ * machine.syncFromPath($page.url.pathname);
27
+ * });
28
+ * ```
29
+ *
30
+ * With onEnter hooks (for animations):
31
+ * ```javascript
32
+ * const machine = new PageMachine({
33
+ * initialState: STATE_ANIMATE,
34
+ * routeMap: {
35
+ * [STATE_ANIMATE]: '/game/animate',
36
+ * [STATE_PLAY]: '/game/play'
37
+ * },
38
+ * onEnterHooks: {
39
+ * [STATE_ANIMATE]: (done) => {
40
+ * const animation = playAnimation(1000);
41
+ * animation.finished.then(() => done(STATE_PLAY));
42
+ *
43
+ * return {
44
+ * abort: () => animation.cancel(),
45
+ * complete: () => animation.finish()
46
+ * };
47
+ * }
48
+ * }
49
+ * });
50
+ *
51
+ * // Fast-forward animation
52
+ * machine.completeTransitions();
53
+ *
54
+ * // Cancel animation
55
+ * machine.abortTransitions();
56
+ * ```
57
+ */
58
+ export default class PageMachine {
59
+ /**
60
+ * Current state
61
+ * @type {string}
62
+ */
63
+ // @ts-ignore
64
+ #current = $state();
65
+
66
+ /**
67
+ * Map of states to route paths
68
+ * @type {Record<string, string>}
69
+ */
70
+ #routeMap = {};
71
+
72
+ /**
73
+ * Reverse map of route paths to states
74
+ * @type {Record<string, string>}
75
+ */
76
+ #pathToStateMap = {};
77
+
78
+ /**
79
+ * Data properties for business/domain state
80
+ * Can be initialized from server and synced back
81
+ * @type {Record<string, any>}
82
+ */
83
+ #data = $state({});
84
+
85
+ /**
86
+ * Track which states have been visited during this session
87
+ * Useful for showing first-time hints/tips
88
+ * @type {Set<string>}
89
+ */
90
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
91
+ #visitedStates = new Set();
92
+
93
+ /**
94
+ * Revision counter for triggering reactivity
95
+ * @type {number}
96
+ */
97
+ #revision = $state(0);
98
+
99
+ /**
100
+ * Map of state names to onEnter hook configurations
101
+ * @type {Record<string, {onEnter: Function}>}
102
+ */
103
+ #onEnterHooks = {};
104
+
105
+ /**
106
+ * Current state's onEnter handler (abort/complete functions)
107
+ * @type {{abort?: Function, complete?: Function} | null}
108
+ */
109
+ #currentOnEnterHandler = null;
110
+
111
+ /**
112
+ * Current state's done callback
113
+ * @type {Function | null}
114
+ */
115
+ #currentOnEnterDone = null;
116
+
117
+ /**
118
+ * Flag to prevent concurrent state transitions
119
+ * @type {boolean}
120
+ */
121
+ #isTransitioning = false;
122
+
123
+ /**
124
+ * Constructor
125
+ *
126
+ * @param {Object} config - Configuration object
127
+ * @param {string} config.initialState - Initial state name
128
+ * @param {Record<string, string>} [config.routeMap={}] - Map of states to route paths
129
+ * @param {Record<string, any>} [config.initialData={}] - Initial data properties (from server)
130
+ * @param {Record<string, Function>} [config.onEnterHooks={}] - Map of states to onEnter hook functions
131
+ *
132
+ * @example
133
+ * ```javascript
134
+ * const machine = new PageMachine({
135
+ * initialState: STATE_START,
136
+ * routeMap: {
137
+ * [STATE_START]: '/intro/start',
138
+ * [STATE_ANIMATE]: '/intro/animate'
139
+ * },
140
+ * initialData: {
141
+ * INTRO_COMPLETED: false
142
+ * },
143
+ * onEnterHooks: {
144
+ * [STATE_ANIMATE]: (done) => {
145
+ * setTimeout(() => done(STATE_START), 1000);
146
+ * return {
147
+ * abort: () => clearTimeout(...),
148
+ * complete: () => done(STATE_START)
149
+ * };
150
+ * }
151
+ * }
152
+ * });
153
+ * ```
154
+ */
155
+ constructor({ initialState, routeMap = {}, initialData = {}, onEnterHooks = {} }) {
156
+ if (!initialState) {
157
+ throw new Error('PageMachine requires initialState parameter');
158
+ }
159
+
160
+ this.#current = initialState;
161
+ this.#routeMap = routeMap;
162
+ this.#data = initialData;
163
+ this.#onEnterHooks = this.#normalizeOnEnterHooks(onEnterHooks);
164
+
165
+ // Build reverse map (path -> state)
166
+ for (const [state, path] of Object.entries(routeMap)) {
167
+ this.#pathToStateMap[path] = state;
168
+ }
169
+
170
+ // Mark initial state as visited
171
+ this.#visitedStates.add(initialState);
172
+ }
173
+
174
+ /**
175
+ * Normalize onEnterHooks to ensure consistent format
176
+ * Converts function to {onEnter: function} object
177
+ *
178
+ * @param {Record<string, Function|Object>} hooks - Raw hooks configuration
179
+ * @returns {Record<string, {onEnter: Function}>} Normalized hooks
180
+ */
181
+ #normalizeOnEnterHooks(hooks) {
182
+ const normalized = {};
183
+
184
+ for (const [state, hook] of Object.entries(hooks)) {
185
+ if (typeof hook === 'function') {
186
+ // Simple function -> wrap in object
187
+ normalized[state] = { onEnter: hook };
188
+ } else if (hook && typeof hook === 'object' && hook.onEnter) {
189
+ // Already an object with onEnter
190
+ normalized[state] = hook;
191
+ }
192
+ }
193
+
194
+ return normalized;
195
+ }
196
+
197
+ /**
198
+ * Synchronize machine state with URL path
199
+ *
200
+ * Call this in a $effect that watches $page.url.pathname
201
+ * Automatically tracks visited states
202
+ *
203
+ * @param {string} currentPath - Current URL pathname
204
+ *
205
+ * @returns {boolean} True if state was changed
206
+ */
207
+ syncFromPath(currentPath) {
208
+ const targetState = this.#getStateFromPath(currentPath);
209
+
210
+ if (targetState && targetState !== this.#current) {
211
+ this.#current = targetState;
212
+ this.#visitedStates.add(targetState);
213
+ this.#revision++;
214
+ return true;
215
+ }
216
+
217
+ return false;
218
+ }
219
+
220
+ /**
221
+ * Set the current state directly
222
+ * Handles onEnter hooks and auto-transitions
223
+ *
224
+ * @param {string} newState - Target state
225
+ */
226
+ async setState(newState) {
227
+ if (newState === this.#current || this.#isTransitioning) {
228
+ return;
229
+ }
230
+
231
+ // Abort previous state's onEnter handler
232
+ if (this.#currentOnEnterHandler?.abort) {
233
+ this.#currentOnEnterHandler.abort();
234
+ }
235
+ this.#currentOnEnterHandler = null;
236
+ this.#currentOnEnterDone = null;
237
+
238
+ this.#isTransitioning = true;
239
+ this.#current = newState;
240
+ this.#visitedStates.add(newState);
241
+
242
+ // Check if this state has an onEnter hook
243
+ const hookConfig = this.#onEnterHooks[newState];
244
+ if (hookConfig?.onEnter) {
245
+ // Create done callback for auto-transition
246
+ let doneCalled = false;
247
+ const done = (nextState) => {
248
+ if (!doneCalled && nextState && nextState !== newState) {
249
+ doneCalled = true;
250
+ this.#isTransitioning = false;
251
+ this.setState(nextState);
252
+ }
253
+ };
254
+
255
+ this.#currentOnEnterDone = done;
256
+
257
+ // Call the onEnter hook
258
+ try {
259
+ const handler = hookConfig.onEnter(done);
260
+
261
+ // Store abort/complete handlers if provided
262
+ if (handler && typeof handler === 'object') {
263
+ if (handler.abort || handler.complete) {
264
+ this.#currentOnEnterHandler = {
265
+ abort: handler.abort,
266
+ complete: handler.complete
267
+ };
268
+ }
269
+ }
270
+
271
+ // If hook returned a promise, await it
272
+ if (handler?.then) {
273
+ await handler;
274
+ }
275
+ } catch (error) {
276
+ console.error(`Error in onEnter hook for state ${newState}:`, error);
277
+ }
278
+ }
279
+
280
+ this.#isTransitioning = false;
281
+ this.#revision++;
282
+ }
283
+
284
+ /**
285
+ * Get state name from URL path
286
+ *
287
+ * @param {string} path - URL pathname
288
+ *
289
+ * @returns {string|null} State name or null
290
+ */
291
+ #getStateFromPath(path) {
292
+ // Try exact match first
293
+ if (this.#pathToStateMap[path]) {
294
+ return this.#pathToStateMap[path];
295
+ }
296
+
297
+ // Try partial match (path includes route)
298
+ for (const [routePath, state] of Object.entries(this.#pathToStateMap)) {
299
+ if (path.includes(routePath)) {
300
+ return state;
301
+ }
302
+ }
303
+
304
+ return null;
305
+ }
306
+
307
+ /**
308
+ * Get route path for a given state
309
+ *
310
+ * @param {string} state - State name
311
+ *
312
+ * @returns {string|null} Route path or null if no mapping
313
+ */
314
+ getPathForState(state) {
315
+ return this.#routeMap[state] || null;
316
+ }
317
+
318
+ /**
319
+ * Get route path for current state
320
+ *
321
+ * @returns {string|null} Route path or null if no mapping
322
+ */
323
+ getCurrentPath() {
324
+ return this.getPathForState(this.#current);
325
+ }
326
+
327
+ /**
328
+ * Get current state
329
+ *
330
+ * @returns {string} Current state name
331
+ */
332
+ get current() {
333
+ return this.#current;
334
+ }
335
+
336
+ /**
337
+ * Get the route map
338
+ *
339
+ * @returns {Record<string, string>} Copy of route map
340
+ */
341
+ get routeMap() {
342
+ return { ...this.#routeMap };
343
+ }
344
+
345
+ /* ===== Data Properties (Business/Domain State) ===== */
346
+
347
+ /**
348
+ * Set a data property value
349
+ *
350
+ * @param {string} key - Property key
351
+ * @param {any} value - Property value
352
+ *
353
+ * @example
354
+ * ```javascript
355
+ * machine.setData('HAS_STRONG_PROFILE', true);
356
+ * machine.setData('PROFILE_SCORE', 85);
357
+ * ```
358
+ */
359
+ setData(key, value) {
360
+ this.#data[key] = value;
361
+ this.#revision++;
362
+ }
363
+
364
+ /**
365
+ * Get a data property value
366
+ *
367
+ * @param {string} key - Property key
368
+ *
369
+ * @returns {any} Property value or undefined
370
+ *
371
+ * @example
372
+ * ```javascript
373
+ * const hasProfile = machine.getData('HAS_STRONG_PROFILE');
374
+ * const score = machine.getData('PROFILE_SCORE');
375
+ * ```
376
+ */
377
+ getData(key) {
378
+ // Access revision to ensure reactivity
379
+ this.#revision;
380
+ return this.#data[key];
381
+ }
382
+
383
+ /**
384
+ * Get all data properties
385
+ *
386
+ * @returns {Record<string, any>} Copy of all data
387
+ *
388
+ * @example
389
+ * ```javascript
390
+ * const allData = machine.getAllData();
391
+ * await playerService.saveData(allData);
392
+ * ```
393
+ */
394
+ getAllData() {
395
+ // Access revision to ensure reactivity
396
+ this.#revision;
397
+ return { ...this.#data };
398
+ }
399
+
400
+ /**
401
+ * Update multiple data properties at once
402
+ *
403
+ * @param {Record<string, any>} dataUpdates - Object with key-value pairs
404
+ *
405
+ * @example
406
+ * ```javascript
407
+ * machine.updateData({
408
+ * HAS_STRONG_PROFILE: true,
409
+ * PROFILE_SCORE: 85,
410
+ * MATCHED_SECTOR: 'technology'
411
+ * });
412
+ * ```
413
+ */
414
+ updateData(dataUpdates) {
415
+ for (const [key, value] of Object.entries(dataUpdates)) {
416
+ this.#data[key] = value;
417
+ }
418
+ this.#revision++;
419
+ }
420
+
421
+ /* ===== Visited States Tracking ===== */
422
+
423
+ /**
424
+ * Check if a state has been visited
425
+ *
426
+ * @param {string} state - State name to check
427
+ *
428
+ * @returns {boolean} True if the state has been visited
429
+ *
430
+ * @example
431
+ * ```javascript
432
+ * if (machine.hasVisited(STATE_PROFILE)) {
433
+ * // User has seen profile page, skip intro
434
+ * }
435
+ * ```
436
+ */
437
+ hasVisited(state) {
438
+ // Access revision to ensure reactivity
439
+ this.#revision;
440
+ return this.#visitedStates.has(state);
441
+ }
442
+
443
+ /**
444
+ * Get all visited states
445
+ *
446
+ * @returns {string[]} Array of visited state names
447
+ */
448
+ getVisitedStates() {
449
+ // Access revision to ensure reactivity
450
+ this.#revision;
451
+ return Array.from(this.#visitedStates);
452
+ }
453
+
454
+ /**
455
+ * Reset visited states tracking
456
+ * Useful for testing or resetting experience
457
+ */
458
+ resetVisitedStates() {
459
+ this.#visitedStates.clear();
460
+ this.#visitedStates.add(this.#current);
461
+ this.#revision++;
462
+ }
463
+
464
+ /* ===== Transition Control Methods ===== */
465
+
466
+ /**
467
+ * Abort current state's transitions
468
+ * Cancels animations/operations immediately (incomplete state)
469
+ *
470
+ * @example
471
+ * ```javascript
472
+ * // User clicks "Cancel" button
473
+ * machine.abortTransitions();
474
+ * ```
475
+ */
476
+ abortTransitions() {
477
+ if (this.#currentOnEnterHandler?.abort) {
478
+ this.#currentOnEnterHandler.abort();
479
+ this.#currentOnEnterHandler = null;
480
+ this.#currentOnEnterDone = null;
481
+ this.#revision++;
482
+ }
483
+ }
484
+
485
+ /**
486
+ * Complete current state's transitions immediately
487
+ * Fast-forwards animations/operations to completion (complete state)
488
+ *
489
+ * @example
490
+ * ```javascript
491
+ * // User clicks "Skip" or "Next" button
492
+ * machine.completeTransitions();
493
+ * ```
494
+ */
495
+ completeTransitions() {
496
+ if (this.#currentOnEnterHandler?.complete) {
497
+ this.#currentOnEnterHandler.complete();
498
+ this.#currentOnEnterHandler = null;
499
+ this.#currentOnEnterDone = null;
500
+ this.#revision++;
501
+ }
502
+ }
503
+
504
+ /**
505
+ * Check if current state has transitions that can be completed
506
+ *
507
+ * @returns {boolean} True if completeTransitions() can be called
508
+ *
509
+ * @example
510
+ * ```svelte
511
+ * {#if machine.canCompleteTransitions}
512
+ * <button onclick={() => machine.completeTransitions()}>Skip</button>
513
+ * {/if}
514
+ * ```
515
+ */
516
+ get canCompleteTransitions() {
517
+ this.#revision; // Ensure reactivity
518
+ return !!this.#currentOnEnterHandler?.complete;
519
+ }
520
+
521
+ /**
522
+ * Check if current state has transitions that can be aborted
523
+ *
524
+ * @returns {boolean} True if abortTransitions() can be called
525
+ *
526
+ * @example
527
+ * ```svelte
528
+ * {#if machine.canAbortTransitions}
529
+ * <button onclick={() => machine.abortTransitions()}>Cancel</button>
530
+ * {/if}
531
+ * ```
532
+ */
533
+ get canAbortTransitions() {
534
+ this.#revision; // Ensure reactivity
535
+ return !!this.#currentOnEnterHandler?.abort;
536
+ }
537
+ }
@@ -0,0 +1,157 @@
1
+ # PageMachine
2
+
3
+ State machine for managing page view states with URL route mapping.
4
+
5
+ ## How it connects
6
+
7
+ ```
8
+ ┌─────────────────────────────────────────────────────────┐
9
+ │ MyFlowPageMachine (extends PageMachine) │
10
+ │ - Maps states to URL routes │
11
+ │ - Tracks current state and visited states │
12
+ │ - Provides computed properties (inIntro, inStep1, etc.) │
13
+ └────────────────┬────────────────────────────────────────┘
14
+
15
+ │ Contained in state
16
+
17
+ ┌────────────────▼────────────────────────────────────────┐
18
+ │ MyFlowState (extends RouteStateContext) │
19
+ │ get pageMachine() { return this.#pageMachine; } │
20
+ └────────────────┬────────────────────────────────────────┘
21
+
22
+ │ Context provided to layout
23
+
24
+ ┌────────────────▼────────────────────────────────────────┐
25
+ │ +layout.svelte │
26
+ │ IMPORTANT: Must sync URL with state: │
27
+ │ $effect(() => { │
28
+ │ pageMachine.syncFromPath($page.url.pathname); │
29
+ │ }); │
30
+ └────────────────┬────────────────────────────────────────┘
31
+
32
+ │ Pages use machine state
33
+
34
+ ┌────────┴─────────┬────────────────┐
35
+ ▼ ▼ ▼
36
+ ┌──────────┐ ┌──────────┐ ┌──────────┐
37
+ │ +page │ │ +page │ │ Component│
38
+ │ │ │ │ │ │
39
+ └──────────┘ └──────────┘ └──────────┘
40
+ Access via: pageMachine.current, pageMachine.inIntro, etc.
41
+ ```
42
+
43
+ ## Main Purposes
44
+
45
+ 1. **Track current view/step** - Which page is active
46
+ 2. **Map states to URL paths** - Connect state names to routes
47
+ 3. **Sync with browser navigation** - Keep state in sync with URL
48
+ 4. **Track visited states** - Know which pages user has seen
49
+
50
+ ## Basic Usage
51
+
52
+ ### 1. Create a page machine class
53
+
54
+ ```javascript
55
+ // my-flow.machine.svelte.js
56
+ import PageMachine from '$lib/state/machines/PageMachine.svelte.js';
57
+
58
+ export const STATE_INTRO = 'intro';
59
+ export const STATE_STEP1 = 'step1';
60
+ export const STATE_STEP2 = 'step2';
61
+
62
+ export default class MyFlowPageMachine extends PageMachine {
63
+ constructor(initialData = {}) {
64
+ const routeMap = {
65
+ [STATE_INTRO]: '/my-flow/intro',
66
+ [STATE_STEP1]: '/my-flow/step1',
67
+ [STATE_STEP2]: '/my-flow/step2'
68
+ };
69
+
70
+ super(STATE_INTRO, routeMap, initialData);
71
+ }
72
+
73
+ // Computed properties for convenience
74
+ get inIntro() {
75
+ return this.current === STATE_INTRO;
76
+ }
77
+
78
+ get inStep1() {
79
+ return this.current === STATE_STEP1;
80
+ }
81
+ }
82
+ ```
83
+
84
+ ### 2. Use in state container
85
+
86
+ ```javascript
87
+ // my-flow.state.svelte.js
88
+ import { RouteStateContext } from '$lib/state/context.js';
89
+ import MyFlowPageMachine from './my-flow.machine.svelte.js';
90
+
91
+ export class MyFlowState extends RouteStateContext {
92
+ #pageMachine;
93
+
94
+ constructor() {
95
+ super();
96
+ this.#pageMachine = new MyFlowPageMachine();
97
+ }
98
+
99
+ get pageMachine() {
100
+ return this.#pageMachine;
101
+ }
102
+ }
103
+ ```
104
+
105
+ ### 3. Sync with route in +layout.svelte component
106
+
107
+ **This is IMPORTANT for url path to be connected to the page machine**
108
+
109
+ ```svelte
110
+ <script>
111
+ import { page } from '$app/stores';
112
+ import { getMyFlowState } from '../my-flow.state.svelte.js';
113
+
114
+ const flowState = getMyFlowState();
115
+ const pageMachine = flowState.pageMachine;
116
+
117
+ // Sync machine with URL changes
118
+ $effect(() => {
119
+ pageMachine.syncFromPath($page.url.pathname);
120
+ });
121
+ </script>
122
+
123
+ {#if pageMachine.inIntro}
124
+ <IntroView />
125
+ {:else if pageMachine.inStep1}
126
+ <Step1View />
127
+ {/if}
128
+ ```
129
+
130
+ ## Key Methods
131
+
132
+ ```javascript
133
+ // Sync with URL path
134
+ machine.syncFromPath(currentPath)
135
+
136
+ // Get current state
137
+ machine.current
138
+
139
+ // Get route for state
140
+ machine.getPathForState(stateName)
141
+
142
+ // Data properties (for business logic)
143
+ machine.setData('KEY', value)
144
+ machine.getData('KEY')
145
+
146
+ // Visited states tracking
147
+ machine.hasVisited(stateName)
148
+ machine.getVisitedStates()
149
+ ```
150
+
151
+ ## Important Notes
152
+
153
+ - Not a finite state machine - allows free navigation
154
+ - States map 1:1 with routes
155
+ - Use state constants instead of magic strings
156
+ - Always sync in `$effect` watching `$page.url.pathname`
157
+ - Data properties are for business logic, not UI state
@@ -1,2 +1,3 @@
1
1
  export * from "./machines/finite-state-machine/index.js";
2
2
  export * from "./machines/loading-state-machine/index.js";
3
+ export { default as PageMachine } from "./machines/page-machine/PageMachine.svelte.js";
@@ -1,2 +1,4 @@
1
1
  export * from './machines/finite-state-machine/index.js';
2
2
  export * from './machines/loading-state-machine/index.js';
3
+
4
+ export { default as PageMachine } from './machines/page-machine/PageMachine.svelte.js';
@@ -1,5 +1,5 @@
1
1
  <script>
2
- import { clamp, enableContainerScaling } from '../../../design/index.js';
2
+ import { enableContainerScaling } from '../../../design/index.js';
3
3
 
4
4
  /**
5
5
  * Wrapper component that applies container scaling to its children