@hkdigital/lib-core 0.5.44 → 0.5.45

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,226 @@
1
+ # RouteStateContext
2
+
3
+ Base class for route-level state containers.
4
+
5
+ ## How it connects
6
+
7
+ ```
8
+ ┌─────────────────────────────────────────────────────────┐
9
+ │ PuzzleState (extends RouteStateContext) │
10
+ │ - Container for route-level concerns │
11
+ │ - Contains PageMachine instance │
12
+ │ - Optional: services, preload, reset, etc. │
13
+ └────────────────┬────────────────────────────────────────┘
14
+
15
+ │ Provided via Svelte context
16
+
17
+ ┌────────────────▼────────────────────────────────────────┐
18
+ │ +layout.svelte │
19
+ │ - Creates state with createOrGetPuzzleState() │
20
+ │ - IMPORTANT: Syncs URL with pageMachine.syncFromPath() │
21
+ │ - Optional: Calls validateAndRedirect() for protection │
22
+ └────────────────┬────────────────────────────────────────┘
23
+
24
+ │ Context available to children
25
+
26
+ ┌────────┴─────────┬────────────────┐
27
+ ▼ ▼ ▼
28
+ ┌──────────┐ ┌──────────┐ ┌──────────┐
29
+ │ +page │ │ +page │ │ Component│
30
+ │ .svelte │ │ .svelte │ │ │
31
+ └──────────┘ └──────────┘ └──────────┘
32
+ Gets state via getPuzzleState()
33
+ ```
34
+
35
+ ## Main Purposes
36
+
37
+ 1. **State container** - Hold route-level concerns in one place
38
+ 2. **Apply enforceStartPath** - Control navigation flow (users must visit
39
+ start path before accessing subroutes)
40
+ 3. **Provide validateAndRedirect** - Route protection via layout
41
+
42
+ ## Key Features
43
+
44
+ - Share state between layout and pages without prop drilling
45
+ - Persist state across navigation within the same route group
46
+ - Lifecycle methods for setup/teardown (preload, reset)
47
+
48
+ ## Basic Usage
49
+
50
+ ### 1. Create state container class
51
+
52
+ ```javascript
53
+ // routes/puzzle/puzzle.state.svelte.js
54
+ import { defineStateContext } from '@hkdigital/lib-core/state/context.js';
55
+ import { RouteStateContext } from '$lib/state/context.js';
56
+ import PuzzlePageMachine from './puzzle.machine.svelte.js';
57
+
58
+ export class PuzzleState extends RouteStateContext {
59
+ #pageMachine;
60
+
61
+ constructor() {
62
+ super({
63
+ startPath: '/puzzle',
64
+ enforceStartPath: true // Optional: enforce route protection
65
+ });
66
+ this.#pageMachine = new PuzzlePageMachine();
67
+ }
68
+
69
+ get pageMachine() {
70
+ return this.#pageMachine;
71
+ }
72
+
73
+ // Optional: Service accessors
74
+ getPuzzleService() {
75
+ return getPuzzleService();
76
+ }
77
+
78
+ // Optional: Lifecycle methods
79
+ preload(onProgress) {
80
+ return loadPuzzleAssets(onProgress);
81
+ }
82
+
83
+ reset() {
84
+ // Reset state when needed
85
+ }
86
+ }
87
+
88
+ // Export helper functions
89
+ export const [createOrGetPuzzleState, createPuzzleState, getPuzzleState] =
90
+ defineStateContext(PuzzleState);
91
+ ```
92
+
93
+ ### 2. Provide context in layout
94
+
95
+ ```svelte
96
+ <!-- routes/puzzle/+layout.svelte -->
97
+ <script>
98
+ import { page } from '$app/stores';
99
+ import { createOrGetPuzzleState } from './puzzle.state.svelte.js';
100
+
101
+ // Create or get existing state container
102
+ const puzzleState = createOrGetPuzzleState();
103
+
104
+ // IMPORTANT: Sync URL with PageMachine state
105
+ $effect(() => {
106
+ puzzleState.pageMachine.syncFromPath($page.url.pathname);
107
+ });
108
+
109
+ // Optional: Enforce start path (redirect if user skips intro)
110
+ $effect(() => {
111
+ puzzleState.validateAndRedirect($page.url.pathname);
112
+ });
113
+
114
+ // Optional: Preload assets
115
+ puzzleState.preload((progress) => {
116
+ console.log('Loading:', progress);
117
+ });
118
+ </script>
119
+
120
+ <slot />
121
+ ```
122
+
123
+ ### 3. Consume context in pages
124
+
125
+ ```svelte
126
+ <!-- routes/puzzle/level1/+page.svelte -->
127
+ <script>
128
+ import { getPuzzleState } from '../puzzle.state.svelte.js';
129
+
130
+ const puzzleState = getPuzzleState();
131
+ const pageMachine = puzzleState.pageMachine;
132
+ </script>
133
+
134
+ <div>Current state: {pageMachine.current}</div>
135
+ ```
136
+
137
+ ## Context Helpers
138
+
139
+ The `defineStateContext` helper creates three functions:
140
+
141
+ ```javascript
142
+ // Get existing or create new (use in layout)
143
+ const state = createOrGetPuzzleState();
144
+
145
+ // Force create new instance
146
+ const state = createPuzzleState();
147
+
148
+ // Get existing (throws if not found, use in pages/components)
149
+ const state = getPuzzleState();
150
+ ```
151
+
152
+ ## Constructor Options
153
+
154
+ ```javascript
155
+ constructor({ startPath, enforceStartPath })
156
+ ```
157
+
158
+ - `startPath` **(required)** - The start path for this route
159
+ (e.g., `/puzzle`)
160
+ - `enforceStartPath` **(optional, default: false)** - If true, users must
161
+ visit the start path before accessing subroutes
162
+
163
+ ## validateAndRedirect Method
164
+
165
+ Automatically redirects users if they try to access subroutes before visiting
166
+ the start path.
167
+
168
+ **How it works:**
169
+ - If `enforceStartPath: true` is set in constructor
170
+ - User tries to access a subroute (e.g., `/puzzle/level2`)
171
+ - But hasn't visited the start path yet (`/puzzle`)
172
+ - → Automatically redirects to start path
173
+
174
+ **Example use case:** Puzzle game where users must see the intro before
175
+ accessing puzzle levels.
176
+
177
+ ```javascript
178
+ // In state constructor
179
+ export class PuzzleState extends RouteStateContext {
180
+ constructor() {
181
+ super({
182
+ startPath: '/puzzle',
183
+ enforceStartPath: true
184
+ });
185
+ }
186
+ }
187
+ ```
188
+
189
+ ```svelte
190
+ <!-- In +layout.svelte -->
191
+ <script>
192
+ import { page } from '$app/stores';
193
+
194
+ const puzzleState = createOrGetPuzzleState();
195
+
196
+ // Enforce route protection
197
+ $effect(() => {
198
+ puzzleState.validateAndRedirect($page.url.pathname);
199
+ });
200
+ </script>
201
+ ```
202
+
203
+ **Result:** If user navigates directly to `/puzzle/level2`, they'll be
204
+ redirected to `/puzzle` first. After visiting `/puzzle`, they can freely
205
+ navigate to any subroute.
206
+
207
+ **Custom redirect URL:**
208
+ ```javascript
209
+ // Redirect to a different URL instead of startPath
210
+ puzzleState.validateAndRedirect($page.url.pathname, '/puzzle/welcome');
211
+ ```
212
+
213
+ ## Separation of Concerns
214
+
215
+ **State Container** = Route-level concerns (MULTIPLE responsibilities)
216
+ - PageMachine instance
217
+ - Game engines
218
+ - Service accessors
219
+ - Media preloading
220
+ - Any other route-level concern
221
+
222
+ **PageMachine** = Page/view state ONLY (SINGLE responsibility)
223
+ - Current page state
224
+ - Route mapping
225
+ - Visited states
226
+ - Computed properties for state checks
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Base class for route state containers
3
+ *
4
+ * Main purposes:
5
+ * - Container for route-level concerns (PageMachine, services, engines)
6
+ * - Apply enforceStartPath to control navigation flow
7
+ * - Provide validateAndRedirect for route protection
8
+ *
9
+ * @example
10
+ * ```javascript
11
+ * export class PuzzleState extends RouteStateContext {
12
+ * constructor() {
13
+ * super({
14
+ * startPath: '/puzzle',
15
+ * enforceStartPath: true
16
+ * });
17
+ * }
18
+ *
19
+ * preload(onProgress) {
20
+ * return loadAudioAndVideoScenes(onProgress);
21
+ * }
22
+ * }
23
+ * ```
24
+ */
25
+ export default class RouteStateContext {
26
+ /**
27
+ * @param {Object} options - Configuration options
28
+ * @param {string} options.startPath - Start path for this route (required)
29
+ * @param {boolean} [options.enforceStartPath=false] - If true, redirect to start path before allowing subroutes
30
+ */
31
+ constructor({ startPath, enforceStartPath }: {
32
+ startPath: string;
33
+ enforceStartPath?: boolean | undefined;
34
+ });
35
+ /**
36
+ * Validate current path and redirect if needed
37
+ * Call this in a $effect in the layout
38
+ *
39
+ * @param {string} currentPath - Current URL pathname
40
+ * @param {string} [redirectUrl] - Optional redirect URL (defaults to startPath)
41
+ */
42
+ validateAndRedirect(currentPath: string, redirectUrl?: string): void;
43
+ #private;
44
+ }
@@ -0,0 +1,119 @@
1
+ import { goto } from '$app/navigation';
2
+
3
+ /**
4
+ * Base class for route state containers
5
+ *
6
+ * Main purposes:
7
+ * - Container for route-level concerns (PageMachine, services, engines)
8
+ * - Apply enforceStartPath to control navigation flow
9
+ * - Provide validateAndRedirect for route protection
10
+ *
11
+ * @example
12
+ * ```javascript
13
+ * export class PuzzleState extends RouteStateContext {
14
+ * constructor() {
15
+ * super({
16
+ * startPath: '/puzzle',
17
+ * enforceStartPath: true
18
+ * });
19
+ * }
20
+ *
21
+ * preload(onProgress) {
22
+ * return loadAudioAndVideoScenes(onProgress);
23
+ * }
24
+ * }
25
+ * ```
26
+ */
27
+ export default class RouteStateContext {
28
+ /**
29
+ * Start path for this route
30
+ * @type {string}
31
+ */
32
+ #startPath;
33
+
34
+ /**
35
+ * Whether to enforce that users visit start path before subroutes
36
+ * @type {boolean}
37
+ */
38
+ #enforceStartPath = false;
39
+
40
+ /**
41
+ * Track which paths have been visited during this session
42
+ * Used for enforceStartPath validation
43
+ * @type {Set<string>}
44
+ */
45
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
46
+ #visitedPaths = new Set();
47
+
48
+ /**
49
+ * @param {Object} options - Configuration options
50
+ * @param {string} options.startPath - Start path for this route (required)
51
+ * @param {boolean} [options.enforceStartPath=false] - If true, redirect to start path before allowing subroutes
52
+ */
53
+ constructor({ startPath, enforceStartPath = false }) {
54
+ if (!startPath) {
55
+ throw new Error(
56
+ 'RouteStateContext requires startPath parameter'
57
+ );
58
+ }
59
+
60
+ this.#startPath = startPath;
61
+ this.#enforceStartPath = enforceStartPath;
62
+ }
63
+
64
+ /**
65
+ * Determine if current path needs redirection
66
+ * Private method - enforces sequential access to subroutes
67
+ *
68
+ * @param {string} currentPath - Current URL pathname
69
+ *
70
+ * @returns {string|null}
71
+ * Path to redirect to, or null if no redirect needed
72
+ */
73
+ #determineRedirect(currentPath) {
74
+ // No enforcement configured
75
+ if (!this.#enforceStartPath) {
76
+ return null;
77
+ }
78
+
79
+ // Currently on the start path - mark as visited and allow
80
+ if (currentPath === this.#startPath) {
81
+ this.#visitedPaths.add(currentPath);
82
+ return null;
83
+ }
84
+
85
+ // On a subroute - check if start path was visited
86
+ if (currentPath.startsWith(this.#startPath + '/')) {
87
+ // Allow if user has visited the start path
88
+ if (this.#visitedPaths.has(this.#startPath)) {
89
+ return null;
90
+ }
91
+ // Redirect to start path if not visited yet
92
+ return this.#startPath;
93
+ }
94
+
95
+ // Path is valid (not a subroute)
96
+ return null;
97
+ }
98
+
99
+ /**
100
+ * Validate current path and redirect if needed
101
+ * Call this in a $effect in the layout
102
+ *
103
+ * @param {string} currentPath - Current URL pathname
104
+ * @param {string} [redirectUrl] - Optional redirect URL (defaults to startPath)
105
+ */
106
+ validateAndRedirect(currentPath, redirectUrl = null) {
107
+ const redirectPath = this.#determineRedirect(currentPath);
108
+
109
+ if (redirectPath && redirectPath !== currentPath) {
110
+ const targetPath = redirectUrl || redirectPath;
111
+
112
+ console.debug(
113
+ `[${this.constructor.name}] Redirecting: ${currentPath} → ${targetPath}`
114
+ );
115
+
116
+ goto(targetPath, { replaceState: true });
117
+ }
118
+ }
119
+ }
@@ -1 +1,2 @@
1
- export { defineStateContext, DEFAULT_CONTEXT_KEY } from "./context/state-context.js";
1
+ export { default as RouteStateContext } from "./context/RouteStateContext.svelte.js";
2
+ export { defineStateContext, DEFAULT_CONTEXT_KEY } from "./context/util.js";
@@ -1 +1,2 @@
1
- export { defineStateContext, DEFAULT_CONTEXT_KEY } from './context/state-context.js';
1
+ export { default as RouteStateContext } from './context/RouteStateContext.svelte.js';
2
+ export { defineStateContext, DEFAULT_CONTEXT_KEY } from './context/util.js';
@@ -0,0 +1,195 @@
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
+ *
13
+ * Basic usage:
14
+ * ```javascript
15
+ * const machine = cityState.getOrCreatePageMachine('intro', IntroPageMachine);
16
+ *
17
+ * // Sync machine state with URL changes
18
+ * $effect(() => {
19
+ * machine.syncFromPath($page.url.pathname);
20
+ * });
21
+ * ```
22
+ *
23
+ * With data properties (for business logic):
24
+ * ```javascript
25
+ * // Initialize with server data
26
+ * const initialData = {
27
+ * HAS_STRONG_PROFILE: false,
28
+ * PROFILE_COMPLETED: false,
29
+ * MATCHED_SECTOR: null
30
+ * };
31
+ * const machine = new CircuitPageMachine(initialState, routeMap, initialData);
32
+ *
33
+ * // Read data
34
+ * if (machine.getData('HAS_STRONG_PROFILE')) {
35
+ * // Show advanced content
36
+ * }
37
+ *
38
+ * // Update data (triggers reactivity)
39
+ * machine.setData('HAS_STRONG_PROFILE', true);
40
+ *
41
+ * // Check visited states
42
+ * if (machine.hasVisited(STATE_PROFILE)) {
43
+ * // User has seen profile page before
44
+ * }
45
+ * ```
46
+ */
47
+ export default class PageMachine {
48
+ /**
49
+ * Constructor
50
+ *
51
+ * @param {string} initialState - Initial state name
52
+ * @param {Record<string, string>} routeMap - Map of states to route paths
53
+ * @param {Record<string, any>} [initialData={}] - Initial data properties (from server)
54
+ *
55
+ * @example
56
+ * ```javascript
57
+ * const routeMap = {
58
+ * [STATE_MATCH]: '/city/intro/match',
59
+ * [STATE_CIRCUIT]: '/city/intro/racecircuit'
60
+ * };
61
+ *
62
+ * const initialData = {
63
+ * INTRO_COMPLETED: false,
64
+ * PROFILE_SCORE: 0
65
+ * };
66
+ *
67
+ * const machine = new CityIntroPageMachine(STATE_START, routeMap, initialData);
68
+ * ```
69
+ */
70
+ constructor(initialState: string, routeMap?: Record<string, string>, initialData?: Record<string, any>);
71
+ /**
72
+ * Synchronize machine state with URL path
73
+ *
74
+ * Call this in a $effect that watches $page.url.pathname
75
+ * Automatically tracks visited states
76
+ *
77
+ * @param {string} currentPath - Current URL pathname
78
+ *
79
+ * @returns {boolean} True if state was changed
80
+ */
81
+ syncFromPath(currentPath: string): boolean;
82
+ /**
83
+ * Set the current state directly
84
+ *
85
+ * @param {string} newState - Target state
86
+ */
87
+ setState(newState: string): void;
88
+ /**
89
+ * Get route path for a given state
90
+ *
91
+ * @param {string} state - State name
92
+ *
93
+ * @returns {string|null} Route path or null if no mapping
94
+ */
95
+ getPathForState(state: string): string | null;
96
+ /**
97
+ * Get route path for current state
98
+ *
99
+ * @returns {string|null} Route path or null if no mapping
100
+ */
101
+ getCurrentPath(): string | null;
102
+ /**
103
+ * Get current state
104
+ *
105
+ * @returns {string} Current state name
106
+ */
107
+ get current(): string;
108
+ /**
109
+ * Get the route map
110
+ *
111
+ * @returns {Record<string, string>} Copy of route map
112
+ */
113
+ get routeMap(): Record<string, string>;
114
+ /**
115
+ * Set a data property value
116
+ *
117
+ * @param {string} key - Property key
118
+ * @param {any} value - Property value
119
+ *
120
+ * @example
121
+ * ```javascript
122
+ * machine.setData('HAS_STRONG_PROFILE', true);
123
+ * machine.setData('PROFILE_SCORE', 85);
124
+ * ```
125
+ */
126
+ setData(key: string, value: any): void;
127
+ /**
128
+ * Get a data property value
129
+ *
130
+ * @param {string} key - Property key
131
+ *
132
+ * @returns {any} Property value or undefined
133
+ *
134
+ * @example
135
+ * ```javascript
136
+ * const hasProfile = machine.getData('HAS_STRONG_PROFILE');
137
+ * const score = machine.getData('PROFILE_SCORE');
138
+ * ```
139
+ */
140
+ getData(key: string): any;
141
+ /**
142
+ * Get all data properties
143
+ *
144
+ * @returns {Record<string, any>} Copy of all data
145
+ *
146
+ * @example
147
+ * ```javascript
148
+ * const allData = machine.getAllData();
149
+ * await playerService.saveData(allData);
150
+ * ```
151
+ */
152
+ getAllData(): Record<string, any>;
153
+ /**
154
+ * Update multiple data properties at once
155
+ *
156
+ * @param {Record<string, any>} dataUpdates - Object with key-value pairs
157
+ *
158
+ * @example
159
+ * ```javascript
160
+ * machine.updateData({
161
+ * HAS_STRONG_PROFILE: true,
162
+ * PROFILE_SCORE: 85,
163
+ * MATCHED_SECTOR: 'technology'
164
+ * });
165
+ * ```
166
+ */
167
+ updateData(dataUpdates: Record<string, any>): void;
168
+ /**
169
+ * Check if a state has been visited
170
+ *
171
+ * @param {string} state - State name to check
172
+ *
173
+ * @returns {boolean} True if the state has been visited
174
+ *
175
+ * @example
176
+ * ```javascript
177
+ * if (machine.hasVisited(STATE_PROFILE)) {
178
+ * // User has seen profile page, skip intro
179
+ * }
180
+ * ```
181
+ */
182
+ hasVisited(state: string): boolean;
183
+ /**
184
+ * Get all visited states
185
+ *
186
+ * @returns {string[]} Array of visited state names
187
+ */
188
+ getVisitedStates(): string[];
189
+ /**
190
+ * Reset visited states tracking
191
+ * Useful for testing or resetting experience
192
+ */
193
+ resetVisitedStates(): void;
194
+ #private;
195
+ }
@@ -0,0 +1,337 @@
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
+ *
13
+ * Basic usage:
14
+ * ```javascript
15
+ * const machine = cityState.getOrCreatePageMachine('intro', IntroPageMachine);
16
+ *
17
+ * // Sync machine state with URL changes
18
+ * $effect(() => {
19
+ * machine.syncFromPath($page.url.pathname);
20
+ * });
21
+ * ```
22
+ *
23
+ * With data properties (for business logic):
24
+ * ```javascript
25
+ * // Initialize with server data
26
+ * const initialData = {
27
+ * HAS_STRONG_PROFILE: false,
28
+ * PROFILE_COMPLETED: false,
29
+ * MATCHED_SECTOR: null
30
+ * };
31
+ * const machine = new CircuitPageMachine(initialState, routeMap, initialData);
32
+ *
33
+ * // Read data
34
+ * if (machine.getData('HAS_STRONG_PROFILE')) {
35
+ * // Show advanced content
36
+ * }
37
+ *
38
+ * // Update data (triggers reactivity)
39
+ * machine.setData('HAS_STRONG_PROFILE', true);
40
+ *
41
+ * // Check visited states
42
+ * if (machine.hasVisited(STATE_PROFILE)) {
43
+ * // User has seen profile page before
44
+ * }
45
+ * ```
46
+ */
47
+ export default class PageMachine {
48
+ /**
49
+ * Current state
50
+ * @type {string}
51
+ */
52
+ // @ts-ignore
53
+ #current = $state();
54
+
55
+ /**
56
+ * Map of states to route paths
57
+ * @type {Record<string, string>}
58
+ */
59
+ #routeMap = {};
60
+
61
+ /**
62
+ * Reverse map of route paths to states
63
+ * @type {Record<string, string>}
64
+ */
65
+ #pathToStateMap = {};
66
+
67
+ /**
68
+ * Data properties for business/domain state
69
+ * Can be initialized from server and synced back
70
+ * @type {Record<string, any>}
71
+ */
72
+ #data = $state({});
73
+
74
+ /**
75
+ * Track which states have been visited during this session
76
+ * Useful for showing first-time hints/tips
77
+ * @type {Set<string>}
78
+ */
79
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
80
+ #visitedStates = new Set();
81
+
82
+ /**
83
+ * Revision counter for triggering reactivity
84
+ * @type {number}
85
+ */
86
+ #revision = $state(0);
87
+
88
+ /**
89
+ * Constructor
90
+ *
91
+ * @param {string} initialState - Initial state name
92
+ * @param {Record<string, string>} routeMap - Map of states to route paths
93
+ * @param {Record<string, any>} [initialData={}] - Initial data properties (from server)
94
+ *
95
+ * @example
96
+ * ```javascript
97
+ * const routeMap = {
98
+ * [STATE_MATCH]: '/city/intro/match',
99
+ * [STATE_CIRCUIT]: '/city/intro/racecircuit'
100
+ * };
101
+ *
102
+ * const initialData = {
103
+ * INTRO_COMPLETED: false,
104
+ * PROFILE_SCORE: 0
105
+ * };
106
+ *
107
+ * const machine = new CityIntroPageMachine(STATE_START, routeMap, initialData);
108
+ * ```
109
+ */
110
+ constructor(initialState, routeMap = {}, initialData = {}) {
111
+ this.#current = initialState;
112
+ this.#routeMap = routeMap;
113
+ this.#data = initialData;
114
+
115
+ // Build reverse map (path -> state)
116
+ for (const [state, path] of Object.entries(routeMap)) {
117
+ this.#pathToStateMap[path] = state;
118
+ }
119
+
120
+ // Mark initial state as visited
121
+ this.#visitedStates.add(initialState);
122
+ }
123
+
124
+ /**
125
+ * Synchronize machine state with URL path
126
+ *
127
+ * Call this in a $effect that watches $page.url.pathname
128
+ * Automatically tracks visited states
129
+ *
130
+ * @param {string} currentPath - Current URL pathname
131
+ *
132
+ * @returns {boolean} True if state was changed
133
+ */
134
+ syncFromPath(currentPath) {
135
+ const targetState = this.#getStateFromPath(currentPath);
136
+
137
+ if (targetState && targetState !== this.#current) {
138
+ this.#current = targetState;
139
+ this.#visitedStates.add(targetState);
140
+ this.#revision++;
141
+ return true;
142
+ }
143
+
144
+ return false;
145
+ }
146
+
147
+ /**
148
+ * Set the current state directly
149
+ *
150
+ * @param {string} newState - Target state
151
+ */
152
+ setState(newState) {
153
+ if (newState !== this.#current) {
154
+ this.#current = newState;
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Get state name from URL path
160
+ *
161
+ * @param {string} path - URL pathname
162
+ *
163
+ * @returns {string|null} State name or null
164
+ */
165
+ #getStateFromPath(path) {
166
+ // Try exact match first
167
+ if (this.#pathToStateMap[path]) {
168
+ return this.#pathToStateMap[path];
169
+ }
170
+
171
+ // Try partial match (path includes route)
172
+ for (const [routePath, state] of Object.entries(this.#pathToStateMap)) {
173
+ if (path.includes(routePath)) {
174
+ return state;
175
+ }
176
+ }
177
+
178
+ return null;
179
+ }
180
+
181
+ /**
182
+ * Get route path for a given state
183
+ *
184
+ * @param {string} state - State name
185
+ *
186
+ * @returns {string|null} Route path or null if no mapping
187
+ */
188
+ getPathForState(state) {
189
+ return this.#routeMap[state] || null;
190
+ }
191
+
192
+ /**
193
+ * Get route path for current state
194
+ *
195
+ * @returns {string|null} Route path or null if no mapping
196
+ */
197
+ getCurrentPath() {
198
+ return this.getPathForState(this.#current);
199
+ }
200
+
201
+ /**
202
+ * Get current state
203
+ *
204
+ * @returns {string} Current state name
205
+ */
206
+ get current() {
207
+ return this.#current;
208
+ }
209
+
210
+ /**
211
+ * Get the route map
212
+ *
213
+ * @returns {Record<string, string>} Copy of route map
214
+ */
215
+ get routeMap() {
216
+ return { ...this.#routeMap };
217
+ }
218
+
219
+ /* ===== Data Properties (Business/Domain State) ===== */
220
+
221
+ /**
222
+ * Set a data property value
223
+ *
224
+ * @param {string} key - Property key
225
+ * @param {any} value - Property value
226
+ *
227
+ * @example
228
+ * ```javascript
229
+ * machine.setData('HAS_STRONG_PROFILE', true);
230
+ * machine.setData('PROFILE_SCORE', 85);
231
+ * ```
232
+ */
233
+ setData(key, value) {
234
+ this.#data[key] = value;
235
+ this.#revision++;
236
+ }
237
+
238
+ /**
239
+ * Get a data property value
240
+ *
241
+ * @param {string} key - Property key
242
+ *
243
+ * @returns {any} Property value or undefined
244
+ *
245
+ * @example
246
+ * ```javascript
247
+ * const hasProfile = machine.getData('HAS_STRONG_PROFILE');
248
+ * const score = machine.getData('PROFILE_SCORE');
249
+ * ```
250
+ */
251
+ getData(key) {
252
+ // Access revision to ensure reactivity
253
+ this.#revision;
254
+ return this.#data[key];
255
+ }
256
+
257
+ /**
258
+ * Get all data properties
259
+ *
260
+ * @returns {Record<string, any>} Copy of all data
261
+ *
262
+ * @example
263
+ * ```javascript
264
+ * const allData = machine.getAllData();
265
+ * await playerService.saveData(allData);
266
+ * ```
267
+ */
268
+ getAllData() {
269
+ // Access revision to ensure reactivity
270
+ this.#revision;
271
+ return { ...this.#data };
272
+ }
273
+
274
+ /**
275
+ * Update multiple data properties at once
276
+ *
277
+ * @param {Record<string, any>} dataUpdates - Object with key-value pairs
278
+ *
279
+ * @example
280
+ * ```javascript
281
+ * machine.updateData({
282
+ * HAS_STRONG_PROFILE: true,
283
+ * PROFILE_SCORE: 85,
284
+ * MATCHED_SECTOR: 'technology'
285
+ * });
286
+ * ```
287
+ */
288
+ updateData(dataUpdates) {
289
+ for (const [key, value] of Object.entries(dataUpdates)) {
290
+ this.#data[key] = value;
291
+ }
292
+ this.#revision++;
293
+ }
294
+
295
+ /* ===== Visited States Tracking ===== */
296
+
297
+ /**
298
+ * Check if a state has been visited
299
+ *
300
+ * @param {string} state - State name to check
301
+ *
302
+ * @returns {boolean} True if the state has been visited
303
+ *
304
+ * @example
305
+ * ```javascript
306
+ * if (machine.hasVisited(STATE_PROFILE)) {
307
+ * // User has seen profile page, skip intro
308
+ * }
309
+ * ```
310
+ */
311
+ hasVisited(state) {
312
+ // Access revision to ensure reactivity
313
+ this.#revision;
314
+ return this.#visitedStates.has(state);
315
+ }
316
+
317
+ /**
318
+ * Get all visited states
319
+ *
320
+ * @returns {string[]} Array of visited state names
321
+ */
322
+ getVisitedStates() {
323
+ // Access revision to ensure reactivity
324
+ this.#revision;
325
+ return Array.from(this.#visitedStates);
326
+ }
327
+
328
+ /**
329
+ * Reset visited states tracking
330
+ * Useful for testing or resetting experience
331
+ */
332
+ resetVisitedStates() {
333
+ this.#visitedStates.clear();
334
+ this.#visitedStates.add(this.#current);
335
+ this.#revision++;
336
+ }
337
+ }
@@ -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';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hkdigital/lib-core",
3
- "version": "0.5.44",
3
+ "version": "0.5.45",
4
4
  "author": {
5
5
  "name": "HKdigital",
6
6
  "url": "https://hkdigital.nl"