@hkdigital/lib-core 0.5.45 → 0.5.47

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.
@@ -6,17 +6,13 @@ State machine for managing page view states with URL route mapping.
6
6
 
7
7
  ```
8
8
  ┌─────────────────────────────────────────────────────────┐
9
- MyFlowPageMachine (extends PageMachine)
9
+ PuzzleState (extends PageMachine)
10
10
  │ - Maps states to URL routes │
11
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; } │
12
+ │ - Manages start path and navigation
13
+ │ - Provides computed properties (inIntro, inLevel1, etc.)│
14
+ - Contains GameLogic for reactive game state │
15
+ - Optional: services, preload, reset, etc. │
20
16
  └────────────────┬────────────────────────────────────────┘
21
17
 
22
18
  │ Context provided to layout
@@ -25,11 +21,11 @@ State machine for managing page view states with URL route mapping.
25
21
  │ +layout.svelte │
26
22
  │ IMPORTANT: Must sync URL with state: │
27
23
  │ $effect(() => { │
28
- pageMachine.syncFromPath($page.url.pathname); │
24
+ puzzleState.syncFromPath($page.url.pathname); │
29
25
  │ }); │
30
26
  └────────────────┬────────────────────────────────────────┘
31
27
 
32
- │ Pages use machine state
28
+ │ Pages use state directly
33
29
 
34
30
  ┌────────┴─────────┬────────────────┐
35
31
  ▼ ▼ ▼
@@ -37,37 +33,71 @@ State machine for managing page view states with URL route mapping.
37
33
  │ +page │ │ +page │ │ Component│
38
34
  │ │ │ │ │ │
39
35
  └──────────┘ └──────────┘ └──────────┘
40
- Access via: pageMachine.current, pageMachine.inIntro, etc.
36
+ Access via: puzzleState.current, puzzleState.inIntro, etc.
41
37
  ```
42
38
 
43
39
  ## Main Purposes
44
40
 
45
41
  1. **Track current view/step** - Which page is active
46
42
  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
43
+ 3. **Manage start path** - Define and navigate to the entry point
44
+ 4. **Sync with browser navigation** - Keep state in sync with URL
45
+ 5. **Track visited states** - Know which pages user has seen
49
46
 
50
47
  ## Basic Usage
51
48
 
52
- ### 1. Create a page machine class
49
+ ### 1. Define state constants
50
+
51
+ ```javascript
52
+ // puzzle.constants.js
53
+ export const STATE_INTRO = 'intro';
54
+ export const STATE_TUTORIAL = 'tutorial';
55
+ export const STATE_LEVEL1 = 'level1';
56
+ export const STATE_LEVEL2 = 'level2';
57
+ export const STATE_COMPLETE = 'complete';
58
+ ```
59
+
60
+ ### 2. Create state class (extends PageMachine)
53
61
 
54
62
  ```javascript
55
- // my-flow.machine.svelte.js
63
+ // puzzle.state.svelte.js
64
+ import { defineStateContext } from '@hkdigital/lib-core/state/context.js';
56
65
  import PageMachine from '$lib/state/machines/PageMachine.svelte.js';
66
+ import PuzzleGameLogic from './puzzle.game-logic.svelte.js';
67
+ import {
68
+ STATE_INTRO,
69
+ STATE_TUTORIAL,
70
+ STATE_LEVEL1,
71
+ STATE_LEVEL2,
72
+ STATE_COMPLETE
73
+ } from './puzzle.constants.js';
74
+
75
+ // Data keys for persistent data
76
+ const KEY_TUTORIAL_SEEN = 'tutorial-seen';
77
+ const KEY_HIGHEST_LEVEL = 'highest-level';
78
+ const KEY_DIFFICULTY = 'difficulty';
79
+
80
+ export class PuzzleState extends PageMachine {
81
+ #gameLogic;
57
82
 
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);
83
+ constructor() {
84
+ // Call PageMachine constructor with route config
85
+ super({
86
+ startPath: '/puzzle/intro',
87
+ routeMap: {
88
+ [STATE_INTRO]: '/puzzle/intro',
89
+ [STATE_TUTORIAL]: '/puzzle/tutorial',
90
+ [STATE_LEVEL1]: '/puzzle/level1',
91
+ [STATE_LEVEL2]: '/puzzle/level2',
92
+ [STATE_COMPLETE]: '/puzzle/complete'
93
+ }
94
+ });
95
+
96
+ this.#gameLogic = new PuzzleGameLogic();
97
+ }
98
+
99
+ get gameLogic() {
100
+ return this.#gameLogic;
71
101
  }
72
102
 
73
103
  // Computed properties for convenience
@@ -75,31 +105,63 @@ export default class MyFlowPageMachine extends PageMachine {
75
105
  return this.current === STATE_INTRO;
76
106
  }
77
107
 
78
- get inStep1() {
79
- return this.current === STATE_STEP1;
108
+ get inTutorial() {
109
+ return this.current === STATE_TUTORIAL;
80
110
  }
81
- }
82
- ```
83
111
 
84
- ### 2. Use in state container
112
+ get inLevel1() {
113
+ return this.current === STATE_LEVEL1;
114
+ }
85
115
 
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';
116
+ get inLevel2() {
117
+ return this.current === STATE_LEVEL2;
118
+ }
90
119
 
91
- export class MyFlowState extends RouteStateContext {
92
- #pageMachine;
120
+ get isComplete() {
121
+ return this.current === STATE_COMPLETE;
122
+ }
93
123
 
94
- constructor() {
95
- super();
96
- this.#pageMachine = new MyFlowPageMachine();
124
+ // Persistent settings/progress (use getData/setData)
125
+ get hasSeenTutorial() {
126
+ return this.getData(KEY_TUTORIAL_SEEN) || false;
127
+ }
128
+
129
+ markTutorialComplete() {
130
+ this.setData(KEY_TUTORIAL_SEEN, true);
131
+ }
132
+
133
+ get highestLevel() {
134
+ return this.getData(KEY_HIGHEST_LEVEL) || 1;
135
+ }
136
+
137
+ updateHighestLevel(level) {
138
+ const current = this.highestLevel;
139
+ if (level > current) {
140
+ this.setData(KEY_HIGHEST_LEVEL, level);
141
+ }
142
+ }
143
+
144
+ get difficulty() {
145
+ return this.getData(KEY_DIFFICULTY) || 'normal';
97
146
  }
98
147
 
99
- get pageMachine() {
100
- return this.#pageMachine;
148
+ setDifficulty(level) {
149
+ this.setData(KEY_DIFFICULTY, level);
150
+ }
151
+
152
+ // Optional: Lifecycle methods
153
+ preload(onProgress) {
154
+ return loadPuzzleAssets(onProgress);
155
+ }
156
+
157
+ reset() {
158
+ this.#gameLogic = new PuzzleGameLogic();
101
159
  }
102
160
  }
161
+
162
+ // Export context helpers
163
+ export const [createOrGetPuzzleState, createPuzzleState, getPuzzleState] =
164
+ defineStateContext(PuzzleState);
103
165
  ```
104
166
 
105
167
  ### 3. Sync with route in +layout.svelte component
@@ -109,43 +171,126 @@ export class MyFlowState extends RouteStateContext {
109
171
  ```svelte
110
172
  <script>
111
173
  import { page } from '$app/stores';
112
- import { getMyFlowState } from '../my-flow.state.svelte.js';
174
+ import { createOrGetPuzzleState } from '../puzzle.state.svelte.js';
175
+
176
+ const puzzleState = createOrGetPuzzleState();
113
177
 
114
- const flowState = getMyFlowState();
115
- const pageMachine = flowState.pageMachine;
178
+ // Sync state with URL changes
179
+ $effect(() => {
180
+ puzzleState.syncFromPath($page.url.pathname);
181
+ });
116
182
 
117
- // Sync machine with URL changes
183
+ // Optional: Enforce that users must visit intro before playing
118
184
  $effect(() => {
119
- pageMachine.syncFromPath($page.url.pathname);
185
+ const currentPath = $page.url.pathname;
186
+
187
+ if (!puzzleState.isStartPath(currentPath) &&
188
+ currentPath.startsWith('/puzzle') &&
189
+ !puzzleState.hasVisited('intro')) {
190
+ puzzleState.redirectToStartPath();
191
+ }
192
+ });
193
+
194
+ // Optional: Preload assets
195
+ puzzleState.preload((progress) => {
196
+ console.log('Loading:', progress);
120
197
  });
121
198
  </script>
122
199
 
123
- {#if pageMachine.inIntro}
124
- <IntroView />
125
- {:else if pageMachine.inStep1}
126
- <Step1View />
200
+ {#if puzzleState.inIntro}
201
+ <IntroView onComplete={() => puzzleState.markTutorialComplete()} />
202
+ {:else if puzzleState.inTutorial}
203
+ <TutorialView />
204
+ {:else if puzzleState.inLevel1}
205
+ <Level1View gameLogic={puzzleState.gameLogic} />
206
+ {:else if puzzleState.inLevel2}
207
+ <Level2View gameLogic={puzzleState.gameLogic} />
208
+ {:else if puzzleState.isComplete}
209
+ <CompleteView
210
+ score={puzzleState.gameLogic.score}
211
+ highestLevel={puzzleState.highestLevel} />
127
212
  {/if}
128
213
  ```
129
214
 
130
215
  ## Key Methods
131
216
 
217
+ All PageMachine methods are directly available on your state class:
218
+
132
219
  ```javascript
133
220
  // Sync with URL path
134
- machine.syncFromPath(currentPath)
221
+ puzzleState.syncFromPath(currentPath)
135
222
 
136
223
  // Get current state
137
- machine.current
224
+ puzzleState.current
225
+
226
+ // Start path management
227
+ puzzleState.startPath // Get start path
228
+ puzzleState.startState // Get start state
229
+ puzzleState.isStartPath(path) // Check if path is start path
230
+ puzzleState.isOnStartState // Check if on start state
231
+ puzzleState.redirectToStartPath() // Navigate to start path
138
232
 
139
233
  // Get route for state
140
- machine.getPathForState(stateName)
234
+ puzzleState.getPathForState(stateName)
141
235
 
142
- // Data properties (for business logic)
143
- machine.setData('KEY', value)
144
- machine.getData('KEY')
236
+ // Persistent data properties
237
+ puzzleState.setData(KEY_NAME, value)
238
+ puzzleState.getData(KEY_NAME)
145
239
 
146
240
  // Visited states tracking
147
- machine.hasVisited(stateName)
148
- machine.getVisitedStates()
241
+ puzzleState.hasVisited(stateName)
242
+ puzzleState.getVisitedStates()
243
+
244
+ // Custom computed properties (from your class)
245
+ puzzleState.inIntro
246
+ puzzleState.inLevel1
247
+ puzzleState.hasSeenTutorial
248
+ ```
249
+
250
+ ## Data Storage Guidelines
251
+
252
+ ### When to use `getData/setData` (PageMachine)
253
+
254
+ Use PageMachine's data properties for **persistent settings and progress**:
255
+
256
+ - ✅ Tutorial completion flags
257
+ - ✅ User preferences (difficulty, language, sound)
258
+ - ✅ Progress tracking (highest level reached)
259
+ - ✅ Settings that survive page navigation
260
+ - ✅ Data that might be saved to server
261
+
262
+ ```javascript
263
+ const KEY_TUTORIAL_SEEN = 'tutorial-seen';
264
+ const KEY_DIFFICULTY = 'difficulty';
265
+ const KEY_HIGHEST_LEVEL = 'highest-level';
266
+
267
+ // Persistent data
268
+ pageMachine.setData(KEY_TUTORIAL_SEEN, true);
269
+ pageMachine.setData(KEY_DIFFICULTY, 'hard');
270
+ pageMachine.setData(KEY_HIGHEST_LEVEL, 5);
271
+ ```
272
+
273
+ ### When to use GameLogic with `$state`
274
+
275
+ Use a separate GameLogic class with `$state` fields for **reactive game state**:
276
+
277
+ - ✅ Live scores, lives, health
278
+ - ✅ Current player, selected items
279
+ - ✅ Complex objects (cards, inventory, enemies)
280
+ - ✅ Temporary turn/round state
281
+ - ✅ Reactive UI state that changes frequently
282
+
283
+ ```javascript
284
+ // puzzle.game-logic.svelte.js
285
+ export class PuzzleGameLogic {
286
+ #score = $state(0);
287
+ #lives = $state(3);
288
+ #selectedCards = $state([]);
289
+ #currentPuzzle = $state(null);
290
+
291
+ get score() { return this.#score; }
292
+ incrementScore(points) { this.#score += points; }
293
+ }
149
294
  ```
150
295
 
151
296
  ## Important Notes
@@ -154,4 +299,5 @@ machine.getVisitedStates()
154
299
  - States map 1:1 with routes
155
300
  - Use state constants instead of magic strings
156
301
  - Always sync in `$effect` watching `$page.url.pathname`
157
- - Data properties are for business logic, not UI state
302
+ - Use constants for data keys (e.g., `KEY_TUTORIAL_SEEN = 'tutorial-seen'`)
303
+ - Separate persistent data (PageMachine) from reactive game state (GameLogic)
@@ -461,6 +461,7 @@
461
461
 
462
462
  {#if gameHeight}
463
463
  <div
464
+ data-about="game-box-centering"
464
465
  class:center
465
466
  style:height={center ? `${iosWindowHeight ?? windowHeight}px` : undefined}
466
467
  >
@@ -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
@@ -60,6 +60,7 @@
60
60
 
61
61
  {#if snippet && snippetParams}
62
62
  <div
63
+ data-component="scaled-container"
63
64
  bind:this={container}
64
65
  class:hidden
65
66
  style:width="{width}px"
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Navigate to an internal path
3
+ * - Replaces the current url
4
+ *
5
+ * @param {string} path - Pathname to navigate to
6
+ */
7
+ export function switchToPage(path: string): void;
@@ -0,0 +1,12 @@
1
+ import { goto } from '$app/navigation';
2
+
3
+ /**
4
+ * Navigate to an internal path
5
+ * - Replaces the current url
6
+ *
7
+ * @param {string} path - Pathname to navigate to
8
+ */
9
+ export function switchToPage( path ) {
10
+ // eslint-disable-next-line svelte/no-navigation-without-resolve
11
+ goto( path, { replaceState: true } );
12
+ }
@@ -0,0 +1 @@
1
+ export * from "./sveltekit/navigation.js";
@@ -0,0 +1 @@
1
+ export * from './sveltekit/navigation.js';
@@ -185,12 +185,16 @@ export function getWeekNumber(dateOrTimestamp) {
185
185
  //
186
186
  // Create a copy of this date object
187
187
  //
188
- const target = new Date(date.valueOf());
188
+ const target = new Date(Date.UTC(
189
+ date.getUTCFullYear(),
190
+ date.getUTCMonth(),
191
+ date.getUTCDate()
192
+ ));
189
193
 
190
194
  //
191
195
  // ISO week date weeks start on Monday, so correct the day number
192
196
  //
193
- const dayNumber = (date.getDay() + 6) % 7;
197
+ const dayNumber = (date.getUTCDay() + 6) % 7;
194
198
 
195
199
  //
196
200
  // ISO 8601 states that week 1 is the week with the first Thursday
@@ -198,22 +202,29 @@ export function getWeekNumber(dateOrTimestamp) {
198
202
  //
199
203
  // Set the target date to the Thursday in the target week
200
204
  //
201
- target.setDate(target.getDate() - dayNumber + 3);
205
+ target.setUTCDate(target.getUTCDate() - dayNumber + 3);
202
206
 
203
207
  //
204
208
  // Store the millisecond value of the target date
205
209
  //
206
210
  const firstThursday = target.valueOf();
207
211
 
208
- // Set the target to the first Thursday of the year
209
- // First, set the target to January 1st
210
- target.setMonth(0, 1);
212
+ //
213
+ // Get the year of the Thursday in the target week
214
+ // (This is important for dates near year boundaries)
215
+ //
216
+ const yearOfThursday = target.getUTCFullYear();
217
+
218
+ // Set the target to the first Thursday of that year
219
+ // First, set the target to January 1st of that year
220
+ target.setUTCFullYear(yearOfThursday);
221
+ target.setUTCMonth(0, 1);
211
222
 
212
223
  //
213
224
  // Not a Thursday? Correct the date to the next Thursday
214
225
  //
215
- if (target.getDay() !== 4) {
216
- target.setMonth(0, 1 + ((4 - target.getDay() + 7) % 7));
226
+ if (target.getUTCDay() !== 4) {
227
+ target.setUTCMonth(0, 1 + ((4 - target.getUTCDay() + 7) % 7));
217
228
  }
218
229
 
219
230
  //
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hkdigital/lib-core",
3
- "version": "0.5.45",
3
+ "version": "0.5.47",
4
4
  "author": {
5
5
  "name": "HKdigital",
6
6
  "url": "https://hkdigital.nl"
@@ -1,44 +0,0 @@
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
- }
@@ -1,119 +0,0 @@
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
- }