@daltonr/pathwrite-svelte 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,547 +1,158 @@
1
1
  # @daltonr/pathwrite-svelte
2
2
 
3
- Svelte 5 adapter for [@daltonr/pathwrite-core](../core) — reactive stores with lifecycle hooks, guards, and optional `<PathShell>` default UI.
3
+ Svelte 5 adapter for `@daltonr/pathwrite-core`runes-based reactive state with an optional `<PathShell>` UI component.
4
4
 
5
5
  ## Installation
6
6
 
7
7
  ```bash
8
- npm install @daltonr/pathwrite-svelte
8
+ npm install @daltonr/pathwrite-core @daltonr/pathwrite-svelte
9
9
  ```
10
10
 
11
- > **Requires Svelte 5.** This adapter uses runes (`$props`, `$derived`, `$state`) and snippets (`{#snippet}`, `{@render}`).
11
+ Peer dependencies: Svelte 5+.
12
12
 
13
- ## Quick Start
13
+ > Uses Svelte 5 runes (`$state`, `$derived`, `$props`) and snippets (`{#snippet}`, `{@render}`). Not compatible with Svelte 4.
14
14
 
15
- ### Option 1: Use `usePath()` with custom UI
15
+ ## Quick start
16
16
 
17
17
  ```svelte
18
+ <!-- JobApplicationFlow.svelte -->
18
19
  <script lang="ts">
19
- import { onMount } from 'svelte';
20
- import { usePath } from '@daltonr/pathwrite-svelte';
21
-
22
- const { snapshot, start, next, previous, setData } = usePath();
23
-
24
- const myPath = {
25
- id: 'signup',
26
- steps: [
27
- { id: 'details', title: 'Your Details' },
28
- { id: 'review', title: 'Review' }
29
- ]
30
- };
31
-
32
- onMount(() => {
33
- start(myPath, { name: '', email: '' });
34
- });
35
-
36
- let snap = $derived($snapshot);
37
- </script>
38
-
39
- {#if snap}
40
- <h2>{snap.steps[snap.stepIndex].title}</h2>
41
-
42
- {#if snap.stepId === 'details'}
43
- <input
44
- type="text"
45
- value={snap.data.name || ''}
46
- oninput={(e) => setData('name', e.currentTarget.value)}
47
- placeholder="Name"
48
- />
49
- <input
50
- type="email"
51
- value={snap.data.email || ''}
52
- oninput={(e) => setData('email', e.currentTarget.value)}
53
- placeholder="Email"
54
- />
55
- {:else if snap.stepId === 'review'}
56
- <p>Name: {snap.data.name}</p>
57
- <p>Email: {snap.data.email}</p>
58
- {/if}
59
-
60
- <button onclick={previous} disabled={snap.isFirstStep || snap.isNavigating}>
61
- Previous
62
- </button>
63
- <button onclick={next} disabled={!snap.canMoveNext || snap.isNavigating}>
64
- {snap.isLastStep ? 'Complete' : 'Next'}
65
- </button>
66
- {/if}
67
- ```
20
+ import { PathShell } from "@daltonr/pathwrite-svelte";
21
+ import "@daltonr/pathwrite-svelte/styles.css";
22
+ import { applicationPath } from "./application-path";
23
+ import DetailsStep from "./DetailsStep.svelte";
24
+ import CoverNoteStep from "./CoverNoteStep.svelte";
68
25
 
69
- ### Option 2: Use `<PathShell>` with snippets
70
-
71
- ```svelte
72
- <script lang="ts">
73
- import { PathShell } from '@daltonr/pathwrite-svelte';
74
- import '@daltonr/pathwrite-svelte/styles.css';
75
-
76
- import DetailsForm from './DetailsForm.svelte';
77
- import ReviewPanel from './ReviewPanel.svelte';
78
-
79
- const signupPath = {
80
- id: 'signup',
81
- steps: [
82
- { id: 'details', title: 'Your Details' },
83
- { id: 'review', title: 'Review' }
84
- ]
85
- };
86
-
87
26
  function handleComplete(data) {
88
- console.log('Completed!', data);
27
+ console.log("Submitted:", data);
89
28
  }
90
29
  </script>
91
30
 
92
31
  <PathShell
93
- path={signupPath}
94
- initialData={{ name: '', email: '' }}
32
+ path={applicationPath}
33
+ initialData={{ name: "", email: "", coverNote: "" }}
95
34
  oncomplete={handleComplete}
96
35
  >
97
36
  {#snippet details()}
98
- <DetailsForm />
99
- {/snippet}
100
- {#snippet review()}
101
- <ReviewPanel />
37
+ <DetailsStep />
102
38
  {/snippet}
103
- </PathShell>
104
- ```
105
-
106
- Each step is a **Svelte 5 snippet** whose name matches the step ID. PathShell collects them automatically and renders the active one.
107
-
108
- > **⚠️ Important: Snippet Names Must Match Step IDs**
109
- >
110
- > When passing step content to `<PathShell>`, each snippet's name **must exactly match** the corresponding step's `id`:
111
- >
112
- > ```typescript
113
- > const myPath = {
114
- > id: 'signup',
115
- > steps: [
116
- > { id: 'details' }, // ← Step ID
117
- > { id: 'review' } // ← Step ID
118
- > ]
119
- > };
120
- > ```
121
- >
122
- > ```svelte
123
- > <PathShell path={myPath}>
124
- > {#snippet details()} <!-- ✅ Matches "details" step -->
125
- > <DetailsForm />
126
- > {/snippet}
127
- > {#snippet review()} <!-- ✅ Matches "review" step -->
128
- > <ReviewPanel />
129
- > {/snippet}
130
- > {#snippet foo()} <!-- ❌ No step with id "foo" -->
131
- > <FooPanel />
132
- > {/snippet}
133
- > </PathShell>
134
- > ```
135
- >
136
- > If a snippet name doesn't match any step ID, PathShell will render:
137
- > **`No content for step "foo"`**
138
- >
139
- > **💡 Tip:** Use your IDE's "Go to Definition" on the step ID in your path definition, then copy-paste the exact string when creating the snippet. This ensures perfect matching and avoids typos.
140
-
141
- ---
142
-
143
- ## Simple vs Persisted
144
-
145
- PathShell supports two modes. Pick the one that fits your use case:
146
-
147
- ### Simple — PathShell manages the engine
148
-
149
- Pass `path` and `initialData`. PathShell creates and starts the engine for you:
150
39
 
151
- ```svelte
152
- <PathShell
153
- path={signupPath}
154
- initialData={{ name: '' }}
155
- oncomplete={handleComplete}
156
- >
157
- {#snippet details()}
158
- <DetailsForm />
40
+ <!-- Step ID is "cover-note"; PathShell resolves the camelCase snippet automatically -->
41
+ {#snippet coverNote()}
42
+ <CoverNoteStep />
159
43
  {/snippet}
160
44
  </PathShell>
161
45
  ```
162
46
 
163
- **Use this when:** you don't need persistence, restoration, or custom observers. Quick prototypes, simple forms, one-off wizards.
164
-
165
- ### Persisted — you create the engine
166
-
167
- Create the engine yourself with `restoreOrStart()` and pass it via `engine`. PathShell subscribes to it but does not start or own it:
168
-
169
- ```svelte
170
- <script>
171
- import { HttpStore, restoreOrStart, persistence } from '@daltonr/pathwrite-store';
172
-
173
- let engine = $state(null);
174
-
175
- onMount(async () => {
176
- const result = await restoreOrStart({
177
- store: new HttpStore({ baseUrl: '/api/wizard' }),
178
- key: 'user:onboarding',
179
- path: signupPath,
180
- initialData: { name: '' },
181
- observers: [
182
- persistence({ store, key: 'user:onboarding', strategy: 'onNext' })
183
- ]
184
- });
185
- engine = result.engine;
186
- });
187
- </script>
188
-
189
- {#if engine}
190
- <PathShell {engine} oncomplete={handleComplete}>
191
- {#snippet details()}
192
- <DetailsForm />
193
- {/snippet}
194
- </PathShell>
195
- {/if}
196
- ```
197
-
198
- **Use this when:** you need auto-persistence, restore-on-reload, or custom observers. Production apps, checkout flows, anything where losing progress matters.
199
-
200
- > **Don't pass both.** `path` and `engine` are mutually exclusive. If you pass `engine`, PathShell will not call `start()` — it assumes the engine is already running.
201
-
202
- ---
203
-
204
- ## ⚠️ Step Components Must Use `getPathContext()`
205
-
206
- Step components rendered inside `<PathShell>` access the path engine via `getPathContext()` — **not** Svelte's raw `getContext()`.
207
-
208
47
  ```svelte
48
+ <!-- DetailsStep.svelte — step component uses usePathContext -->
209
49
  <script lang="ts">
210
- // Correct always use getPathContext()
211
- import { getPathContext } from '@daltonr/pathwrite-svelte';
212
- const { snapshot, setData } = getPathContext();
50
+ import { usePathContext } from "@daltonr/pathwrite-svelte";
213
51
 
214
- // Wrong — this will silently return undefined
215
- // import { getContext } from 'svelte';
216
- // const { snapshot, setData } = getContext('pathContext');
52
+ const ctx = usePathContext();
217
53
  </script>
218
54
 
219
- {#if $snapshot}
55
+ {#if ctx.snapshot}
220
56
  <input
221
- value={$snapshot.data.name || ''}
222
- oninput={(e) => setData('name', e.currentTarget.value)}
57
+ value={ctx.snapshot.data.name ?? ""}
58
+ oninput={(e) => ctx.setData("name", e.currentTarget.value)}
59
+ placeholder="Name"
223
60
  />
61
+ <button onclick={ctx.next}>Next</button>
224
62
  {/if}
225
63
  ```
226
64
 
227
- `getPathContext()` uses a `Symbol` key internally and throws a clear error if called outside a `<PathShell>`. Using `getContext()` with a string key will fail silently.
65
+ ## usePath
228
66
 
229
- ---
230
-
231
- ## API
67
+ `usePath<TData>(options?)` creates an isolated path engine instance with runes-based reactive state. The engine is unsubscribed automatically when the component is destroyed.
232
68
 
233
- ### `usePath<TData>(options?)`
69
+ > Do not destructure `snapshot` — it is a reactive getter backed by `$state`. Destructuring captures the value once and loses reactivity. Access it as `path.snapshot` throughout the template.
234
70
 
235
- Create a Pathwrite engine with Svelte store bindings. Returns reactive stores and action methods.
236
-
237
- ```typescript
238
- const {
239
- snapshot, // Readable<PathSnapshot | null>
240
- start, // (path, initialData?) => Promise<void>
241
- next, // () => Promise<void>
242
- previous, // () => Promise<void>
243
- cancel, // () => Promise<void>
244
- setData, // (key, value) => Promise<void>
245
- // ... more actions
246
- } = usePath();
247
- ```
71
+ | Return value | Type | Description |
72
+ |---|---|---|
73
+ | `snapshot` | `PathSnapshot \| null` | Reactive getter. `null` when no path is active. |
74
+ | `start(definition, data?)` | `Promise<void>` | Start or restart a path. |
75
+ | `restart(definition, data?)` | `Promise<void>` | Tear down any active path and start fresh. |
76
+ | `next()` | `Promise<void>` | Advance one step. Completes on the last step. |
77
+ | `previous()` | `Promise<void>` | Go back one step. No-op on the first step of a top-level path. |
78
+ | `cancel()` | `Promise<void>` | Cancel the active path (or sub-path). |
79
+ | `goToStep(stepId)` | `Promise<void>` | Jump to a step by ID. Calls `onLeave`/`onEnter`; bypasses guards. |
80
+ | `goToStepChecked(stepId)` | `Promise<void>` | Jump to a step by ID, checking the current step's guard first. |
81
+ | `setData(key, value)` | `Promise<void>` | Update a single data field. Type-safe when `TData` is specified. |
82
+ | `startSubPath(definition, data?, meta?)` | `Promise<void>` | Push a sub-path. `meta` is returned to `onSubPathComplete`/`onSubPathCancel`. |
248
83
 
249
- #### Options
84
+ **Options:**
250
85
 
251
86
  | Option | Type | Description |
252
- |--------|------|-------------|
253
- | `engine` | `PathEngine` | External engine (e.g., from `restoreOrStart()`) |
254
- | `onEvent` | `(event) => void` | Called for every engine event |
87
+ |---|---|---|
88
+ | `engine` | `PathEngine` | Externally-managed engine (e.g. from `restoreOrStart()`). `usePath` subscribes to it; the caller owns the lifecycle. |
89
+ | `onEvent` | `(event: PathEvent) => void` | Called for every engine event. |
255
90
 
256
- #### Returns
91
+ ## PathShell props
257
92
 
258
- | Property | Type | Description |
259
- |----------|------|-------------|
260
- | `snapshot` | `Readable<PathSnapshot \| null>` | Current path state (reactive store) |
261
- | `start` | `function` | Start a path |
262
- | `startSubPath` | `function` | Launch a sub-path |
263
- | `next` | `function` | Go to next step |
264
- | `previous` | `function` | Go to previous step |
265
- | `cancel` | `function` | Cancel the path |
266
- | `goToStep` | `function` | Jump to step by ID |
267
- | `goToStepChecked` | `function` | Jump with guard checks |
268
- | `setData` | `function` | Update data |
269
- | `restart` | `function` | Restart the path |
270
-
271
- ### `<PathShell>`
272
-
273
- Default UI shell with progress indicator and navigation buttons.
274
-
275
- #### Props
93
+ Step content is supplied as Svelte 5 snippets whose names match each step's `id`. For hyphenated step IDs (e.g. `"cover-letter"`), pass the snippet as the camelCase prop (`coverLetter={...}`) — PathShell resolves it automatically. A `console.warn` fires in development if no snippet is found under either the exact ID or the camelCase form.
276
94
 
277
95
  | Prop | Type | Default | Description |
278
- |------|------|---------|-------------|
279
- | `path` | `PathDefinition` | — | Path definition (for self-managed engine) |
280
- | `engine` | `PathEngine` | — | External engine (for persistence see below) |
281
- | `initialData` | `PathData` | `{}` | Initial data |
282
- | `autoStart` | `boolean` | `true` | Auto-start on mount |
283
- | `backLabel` | `string` | `"Previous"` | Previous button label |
284
- | `nextLabel` | `string` | `"Next"` | Next button label |
285
- | `completeLabel` | `string` | `"Complete"` | Complete button label |
286
- | `cancelLabel` | `string` | `"Cancel"` | Cancel button label |
287
- | `hideCancel` | `boolean` | `false` | Hide cancel button |
288
- | `hideProgress` | `boolean` | `false` | Hide progress indicator. Also hidden automatically for single-step top-level paths. |
289
- | `footerLayout` | `"wizard" \| "form" \| "auto"` | `"auto"` | Footer button layout. `"auto"` uses `"form"` for single-step top-level paths, `"wizard"` otherwise. `"wizard"`: Back on left, Cancel+Submit on right. `"form"`: Cancel on left, Submit on right, no Back button. |
290
-
291
- > **`path` vs `engine`:** Pass `path` for simple wizards where PathShell manages the engine. Pass `engine` when you create the engine yourself (e.g., via `restoreOrStart()` for persistence). These are mutually exclusive — don't pass both.
292
-
293
- #### Callbacks
294
-
295
- | Callback | Type | Description |
296
- |----------|------|-------------|
297
- | `oncomplete` | `(data) => void` | Called when path completes |
298
- | `oncancel` | `(data) => void` | Called when path is cancelled |
299
- | `onevent` | `(event) => void` | Called for every event |
300
-
301
- #### Snippets
302
-
303
- Step content is provided as Svelte 5 snippets. The snippet name must match the step ID:
304
-
305
- ```svelte
306
- <PathShell path={myPath}>
307
- {#snippet details()}
308
- <DetailsStep />
309
- {/snippet}
310
- {#snippet review()}
311
- <ReviewStep />
312
- {/snippet}
313
- </PathShell>
314
- ```
315
-
316
- You can also override the header and footer:
96
+ |---|---|---|---|
97
+ | `path` | `PathDefinition` | — | Path to run. Mutually exclusive with `engine`. |
98
+ | `engine` | `PathEngine` | — | Externally-managed engine (e.g. from `restoreOrStart()`). Mutually exclusive with `path`. |
99
+ | `initialData` | `PathData` | `{}` | Initial data passed to `engine.start()`. |
100
+ | `autoStart` | `boolean` | `true` | Start on mount. Ignored when `engine` is provided. |
101
+ | `footerLayout` | `"wizard" \| "form" \| "auto"` | `"auto"` | `"wizard"`: Back on left, Cancel+Submit on right. `"form"`: Cancel on left, Submit on right, no Back. `"auto"` picks `"form"` for single-step paths. |
102
+ | `hideProgress` | `boolean` | `false` | Hide the progress indicator. Also hidden automatically for single-step paths. |
103
+ | `backLabel` | `string` | `"Previous"` | Previous button label. |
104
+ | `nextLabel` | `string` | `"Next"` | Next button label. |
105
+ | `completeLabel` | `string` | `"Complete"` | Complete button label (last step). |
106
+ | `cancelLabel` | `string` | `"Cancel"` | Cancel button label. |
107
+ | `hideCancel` | `boolean` | `false` | Hide the Cancel button. |
108
+ | `oncomplete` | `(data: PathData) => void` | — | Called when the path finishes naturally. |
109
+ | `oncancel` | `(data: PathData) => void` | | Called when the path is cancelled. |
110
+ | `onevent` | `(event: PathEvent) => void` | — | Called for every engine event. |
111
+
112
+ You can also replace the built-in header and footer with custom snippets:
317
113
 
318
114
  ```svelte
319
115
  <PathShell path={myPath}>
320
116
  {#snippet header(snap)}
321
- <h2>Step {snap.stepIndex + 1} of {snap.stepCount}</h2>
117
+ <p>Step {snap.stepIndex + 1} of {snap.stepCount}</p>
322
118
  {/snippet}
323
119
 
324
- {#snippet details()}
325
- <DetailsStep />
326
- {/snippet}
120
+ {#snippet details()}<DetailsStep />{/snippet}
327
121
 
328
122
  {#snippet footer(snap, actions)}
329
- <button onclick={actions.previous} disabled={snap.isFirstStep}>
330
- ← Back
331
- </button>
123
+ <button onclick={actions.previous} disabled={snap.isFirstStep}>Back</button>
332
124
  <button onclick={actions.next} disabled={!snap.canMoveNext}>
333
- {snap.isLastStep ? 'Finish' : 'Continue →'}
125
+ {snap.isLastStep ? "Submit" : "Continue"}
334
126
  </button>
335
127
  {/snippet}
336
128
  </PathShell>
337
129
  ```
338
130
 
339
- #### Resetting the path
340
-
341
- There are two ways to reset `<PathShell>` to step 1.
342
-
343
- **Option 1 — Toggle mount** (simplest, always correct)
344
-
345
- Toggle a `$state` rune to destroy and recreate the shell:
346
-
347
- ```svelte
348
- <script>
349
- let isActive = $state(true);
350
- </script>
351
-
352
- {#if isActive}
353
- <PathShell path={myPath} oncomplete={() => (isActive = false)}>
354
- {#snippet details()}<DetailsStep />{/snippet}
355
- </PathShell>
356
- {:else}
357
- <button onclick={() => (isActive = true)}>Try Again</button>
358
- {/if}
359
- ```
360
-
361
- **Option 2 — Call `restart()` on the shell ref** (in-place, no unmount)
362
-
363
- Use `bind:this` to get a reference to the shell instance, then call `restart()`:
364
-
365
- ```svelte
366
- <script>
367
- let shellRef;
368
- </script>
369
-
370
- <PathShell bind:this={shellRef} path={myPath} oncomplete={onDone}>
371
- {#snippet details()}<DetailsStep />{/snippet}
372
- </PathShell>
373
-
374
- <button onclick={() => shellRef.restart()}>Try Again</button>
375
- ```
376
-
377
- `restart()` resets the path engine to step 1 with the original `initialData` without unmounting the component. Use this when you need to keep the shell mounted — for example, to preserve scroll position or drive a CSS transition.
378
-
379
- ### `getPathContext<TData>()`
131
+ ## usePathContext
380
132
 
381
- Get the path context from a parent `<PathShell>`. Use this inside step components.
133
+ `usePathContext<TData>()` is the preferred way for step components rendered inside `<PathShell>` to access the path engine. `<PathShell>` calls `setContext()` internally with a private `Symbol` key; `usePathContext()` calls the matching `getContext()` and returns the same interface as `usePath`. It throws a clear error if called outside a `<PathShell>` do not use Svelte's raw `getContext()` directly, as the key is a private `Symbol` and will silently return `undefined`.
382
134
 
383
135
  ```svelte
384
136
  <script lang="ts">
385
- import { getPathContext } from '@daltonr/pathwrite-svelte';
386
-
387
- const { snapshot, next, setData } = getPathContext();
137
+ import { usePathContext } from "@daltonr/pathwrite-svelte";
138
+
139
+ const ctx = usePathContext<ApplicationData>();
388
140
  </script>
389
141
 
390
- {#if $snapshot}
142
+ {#if ctx.snapshot}
391
143
  <input
392
- value={$snapshot.data.name || ''}
393
- oninput={(e) => setData('name', e.currentTarget.value)}
144
+ value={ctx.snapshot.data.name ?? ""}
145
+ oninput={(e) => ctx.setData("name", e.currentTarget.value)}
394
146
  />
395
- <button onclick={next}>Next</button>
396
147
  {/if}
397
148
  ```
398
149
 
399
- ### `bindData(snapshot, setData, key)`
400
-
401
- Helper to create a two-way binding store.
402
-
403
- ```svelte
404
- <script lang="ts">
405
- import { usePath, bindData } from '@daltonr/pathwrite-svelte';
406
-
407
- const { snapshot, setData } = usePath();
408
- const name = bindData(snapshot, setData, 'name');
409
- </script>
410
-
411
- <input bind:value={$name} />
412
- ```
413
-
414
- ## PathSnapshot
415
-
416
- The `snapshot` store contains the current path state:
417
-
418
- ```typescript
419
- interface PathSnapshot {
420
- pathId: string;
421
- stepId: string;
422
- stepIndex: number;
423
- stepCount: number;
424
- data: PathData;
425
- nestingLevel: number;
426
- isFirstStep: boolean;
427
- isLastStep: boolean;
428
- canMoveNext: boolean;
429
- canMovePrevious: boolean;
430
- isNavigating: boolean;
431
- progress: number; // 0-1
432
- steps: Array<{ id: string; title?: string; status: 'completed' | 'current' | 'upcoming' }>;
433
- validationMessages: string[];
434
- }
435
- ```
436
-
437
- Access it via the store: `$snapshot`
438
-
439
- ## Guards and Hooks
440
-
441
- Define validation and lifecycle logic in your path definition:
442
-
443
- ```typescript
444
- const myPath = {
445
- id: 'signup',
446
- steps: [
447
- {
448
- id: 'details',
449
- canMoveNext: (ctx) => ctx.data.name && ctx.data.email,
450
- validationMessages: (ctx) => {
451
- const errors = [];
452
- if (!ctx.data.name) errors.push('Name is required');
453
- if (!ctx.data.email) errors.push('Email is required');
454
- return errors;
455
- },
456
- onEnter: (ctx) => {
457
- console.log('Entered details step');
458
- },
459
- onLeave: (ctx) => {
460
- console.log('Leaving details step');
461
- }
462
- },
463
- { id: 'review' }
464
- ]
465
- };
466
- ```
467
-
468
- ## Persistence
469
-
470
- Use with [@daltonr/pathwrite-store](../store) for automatic state persistence:
471
-
472
- ```svelte
473
- <script lang="ts">
474
- import { onMount } from 'svelte';
475
- import { PathShell } from '@daltonr/pathwrite-svelte';
476
- import { HttpStore, restoreOrStart, persistence } from '@daltonr/pathwrite-store';
477
- import DetailsForm from './DetailsForm.svelte';
478
- import ReviewPanel from './ReviewPanel.svelte';
479
-
480
- const store = new HttpStore({ baseUrl: '/api/wizard' });
481
- const key = 'user:123:signup';
482
-
483
- let engine = $state(null);
484
- let restored = $state(false);
485
-
486
- onMount(async () => {
487
- const result = await restoreOrStart({
488
- store,
489
- key,
490
- path: signupPath,
491
- initialData: { name: '', email: '' },
492
- observers: [
493
- persistence({ store, key, strategy: 'onNext' })
494
- ]
495
- });
496
- engine = result.engine;
497
- restored = result.restored;
498
- });
499
- </script>
500
-
501
- {#if engine}
502
- <PathShell {engine} oncomplete={(data) => console.log('Done!', data)}>
503
- {#snippet details()}
504
- <DetailsForm />
505
- {/snippet}
506
- {#snippet review()}
507
- <ReviewPanel />
508
- {/snippet}
509
- </PathShell>
510
- {/if}
511
- ```
512
-
513
- ## TypeScript
514
-
515
- Type your path data for full type safety:
516
-
517
- ```typescript
518
- interface SignupData {
519
- name: string;
520
- email: string;
521
- age: number;
522
- }
523
-
524
- const { snapshot, setData } = usePath<SignupData>();
525
-
526
- // ✅ Type-checked
527
- setData('name', 'John');
528
-
529
- // ❌ Type error
530
- setData('invalid', 'value');
531
- ```
532
-
533
- ## License
534
-
535
- MIT — © 2026 Devjoy Ltd.
536
-
537
- ## See Also
538
-
539
- - [@daltonr/pathwrite-core](../core) - Core engine
540
- - [@daltonr/pathwrite-store](../store) - HTTP persistence
541
- - [Documentation](../../docs/guides/DEVELOPER_GUIDE.md)
150
+ ## Further reading
542
151
 
152
+ - [Svelte getting started guide](../../docs/getting-started/frameworks/svelte.md)
153
+ - [Navigation & guards](../../docs/guides/navigation.md)
154
+ - [Full documentation](../../docs/README.md)
543
155
 
544
156
  ---
545
157
 
546
- © 2026 Devjoy Ltd. MIT License.
547
-
158
+ MIT — © 2026 Devjoy Ltd.
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { onMount } from 'svelte';
3
- import { usePath, setPathContext, formatFieldKey, errorPhaseMessage } from './index.svelte.js';
3
+ import { usePath, setPathContext, formatFieldKey, errorPhaseMessage, stepIdToCamelCase } from './index.svelte.js';
4
4
  import type { PathDefinition, PathData, PathEngine, PathSnapshot, ProgressLayout } from './index.svelte.js';
5
5
  import type { Snippet, Component } from 'svelte';
6
6
 
@@ -17,6 +17,10 @@
17
17
  cancelLabel?: string;
18
18
  hideCancel?: boolean;
19
19
  hideProgress?: boolean;
20
+ /** If true, hide the footer (navigation buttons). The error panel is still shown on async failure regardless of this prop. */
21
+ hideFooter?: boolean;
22
+ /** When true, calls `validate()` on the engine so all steps show inline errors simultaneously. Useful when this shell is nested inside a step of an outer shell: bind to the outer snapshot's `hasAttemptedNext`. */
23
+ validateWhen?: boolean;
20
24
  /**
21
25
  * Footer layout mode:
22
26
  * - "auto" (default): Uses "form" for single-step top-level paths, "wizard" otherwise.
@@ -67,6 +71,8 @@
67
71
  cancelLabel = 'Cancel',
68
72
  hideCancel = false,
69
73
  hideProgress = false,
74
+ hideFooter = false,
75
+ validateWhen = false,
70
76
  footerLayout = 'auto',
71
77
  validationDisplay = 'summary',
72
78
  progressLayout = 'merged',
@@ -115,6 +121,18 @@
115
121
  }
116
122
  });
117
123
 
124
+ $effect(() => {
125
+ if (validateWhen) pathReturn.validate();
126
+ });
127
+
128
+ function warnMissingStep(stepId: string): void {
129
+ const camel = stepIdToCamelCase(stepId);
130
+ const hint = camel !== stepId
131
+ ? ` No snippet found for "${stepId}" or its camelCase form "${camel}". If your step ID contains hyphens, pass the snippet as a camelCase prop: ${camel}={YourComponent}.`
132
+ : ` No snippet found for "${stepId}".`;
133
+ console.warn(`[PathShell]${hint}`);
134
+ }
135
+
118
136
  let snap = $derived(pathReturn.snapshot);
119
137
  let actions = $derived({ next, previous, cancel, goToStep, goToStepChecked, setData, restart: () => restartFn(path, initialData), retry, suspend });
120
138
 
@@ -194,7 +212,10 @@
194
212
 
195
213
  <!-- Body: current step rendered via named snippet.
196
214
  Prefer formId (inner step id of a StepChoice) so consumers can
197
- register snippets by inner step ids directly. -->
215
+ register snippets by inner step ids directly.
216
+ Hyphenated step IDs (e.g. "cover-letter") are normalised to camelCase
217
+ ("coverLetter") as a fallback, since Svelte props must be valid JS
218
+ identifiers. -->
198
219
  <div class="pw-shell__body">
199
220
  {#if snap.formId && stepSnippets[snap.formId]}
200
221
  {@const StepComponent = stepSnippets[snap.formId]}
@@ -202,13 +223,17 @@
202
223
  {:else if stepSnippets[snap.stepId]}
203
224
  {@const StepComponent = stepSnippets[snap.stepId]}
204
225
  <StepComponent />
226
+ {:else if stepSnippets[stepIdToCamelCase(snap.formId ?? snap.stepId)]}
227
+ {@const StepComponent = stepSnippets[stepIdToCamelCase(snap.formId ?? snap.stepId)]}
228
+ <StepComponent />
205
229
  {:else}
230
+ {warnMissingStep(snap.stepId)}
206
231
  <p>No content for step "{snap.stepId}"</p>
207
232
  {/if}
208
233
  </div>
209
234
 
210
235
  <!-- Validation messages — suppressed when validationDisplay="inline" -->
211
- {#if validationDisplay !== 'inline' && snap.hasAttemptedNext && Object.keys(snap.fieldErrors).length > 0}
236
+ {#if validationDisplay !== 'inline' && (snap.hasAttemptedNext || snap.hasValidated) && Object.keys(snap.fieldErrors).length > 0}
212
237
  <ul class="pw-shell__validation">
213
238
  {#each Object.entries(snap.fieldErrors) as [key, msg]}
214
239
  <li class="pw-shell__validation-item">
@@ -230,7 +255,7 @@
230
255
  {/if}
231
256
 
232
257
  <!-- Blocking error — guard returned { allowed: false, reason } -->
233
- {#if validationDisplay !== 'inline' && snap.hasAttemptedNext && snap.blockingError}
258
+ {#if validationDisplay !== 'inline' && (snap.hasAttemptedNext || snap.hasValidated) && snap.blockingError}
234
259
  <p class="pw-shell__blocking-error">{snap.blockingError}</p>
235
260
  {/if}
236
261
 
@@ -258,9 +283,9 @@
258
283
  </div>
259
284
  </div>
260
285
  <!-- Footer: navigation buttons (overridable via footer snippet) -->
261
- {:else if footer}
286
+ {:else if !hideFooter && footer}
262
287
  {@render footer(snap, actions)}
263
- {:else}
288
+ {:else if !hideFooter}
264
289
  <div class="pw-shell__footer">
265
290
  <div class="pw-shell__footer-left">
266
291
  {#if resolvedFooterLayout === 'form' && !hideCancel}
@@ -12,6 +12,10 @@ interface Props {
12
12
  cancelLabel?: string;
13
13
  hideCancel?: boolean;
14
14
  hideProgress?: boolean;
15
+ /** If true, hide the footer (navigation buttons). The error panel is still shown on async failure regardless of this prop. */
16
+ hideFooter?: boolean;
17
+ /** When true, calls `validate()` on the engine so all steps show inline errors simultaneously. Useful when this shell is nested inside a step of an outer shell: bind to the outer snapshot's `hasAttemptedNext`. */
18
+ validateWhen?: boolean;
15
19
  /**
16
20
  * Footer layout mode:
17
21
  * - "auto" (default): Uses "form" for single-step top-level paths, "wizard" otherwise.
@@ -1 +1 @@
1
- {"version":3,"file":"PathShell.svelte.d.ts","sourceRoot":"","sources":["../src/PathShell.svelte.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,cAAc,EAAE,QAAQ,EAAE,UAAU,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAC5G,OAAO,KAAK,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,QAAQ,CAAC;AAI/C,UAAU,KAAK;IACb,IAAI,CAAC,EAAE,cAAc,CAAC,GAAG,CAAC,CAAC;IAC3B,MAAM,CAAC,EAAE,UAAU,CAAC;IACpB,WAAW,CAAC,EAAE,QAAQ,CAAC;IACvB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB;;;;;OAKG;IACH,YAAY,CAAC,EAAE,QAAQ,GAAG,MAAM,GAAG,MAAM,CAAC;IAC1C;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,SAAS,GAAG,QAAQ,GAAG,MAAM,CAAC;IAClD;;;;;;OAMG;IACH,cAAc,CAAC,EAAE,cAAc,CAAC;IAChC;;;OAGG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IAEnB,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,QAAQ,KAAK,IAAI,CAAC;IACtC,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,QAAQ,KAAK,IAAI,CAAC;IACpC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,GAAG,KAAK,IAAI,CAAC;IAE/B,MAAM,CAAC,EAAE,OAAO,CAAC,CAAC,YAAY,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;IACtC,MAAM,CAAC,EAAE,OAAO,CAAC,CAAC,YAAY,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC,CAAC,CAAC;IAE9C,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC;CACrC;AAmPH,QAAA,MAAM,SAAS;mBA5JQ,QAAQ,IAAI,CAAC;MA4JmB,CAAC;AACxD,KAAK,SAAS,GAAG,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC;AAC9C,eAAe,SAAS,CAAC"}
1
+ {"version":3,"file":"PathShell.svelte.d.ts","sourceRoot":"","sources":["../src/PathShell.svelte.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,cAAc,EAAE,QAAQ,EAAE,UAAU,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAC5G,OAAO,KAAK,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,QAAQ,CAAC;AAI/C,UAAU,KAAK;IACb,IAAI,CAAC,EAAE,cAAc,CAAC,GAAG,CAAC,CAAC;IAC3B,MAAM,CAAC,EAAE,UAAU,CAAC;IACpB,WAAW,CAAC,EAAE,QAAQ,CAAC;IACvB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,8HAA8H;IAC9H,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,qNAAqN;IACrN,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB;;;;;OAKG;IACH,YAAY,CAAC,EAAE,QAAQ,GAAG,MAAM,GAAG,MAAM,CAAC;IAC1C;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,SAAS,GAAG,QAAQ,GAAG,MAAM,CAAC;IAClD;;;;;;OAMG;IACH,cAAc,CAAC,EAAE,cAAc,CAAC;IAChC;;;OAGG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IAEnB,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,QAAQ,KAAK,IAAI,CAAC;IACtC,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,QAAQ,KAAK,IAAI,CAAC;IACpC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,GAAG,KAAK,IAAI,CAAC;IAE/B,MAAM,CAAC,EAAE,OAAO,CAAC,CAAC,YAAY,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;IACtC,MAAM,CAAC,EAAE,OAAO,CAAC,CAAC,YAAY,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC,CAAC,CAAC;IAE9C,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC;CACrC;AAqQH,QAAA,MAAM,SAAS;mBAhKQ,QAAQ,IAAI,CAAC;MAgKmB,CAAC;AACxD,KAAK,SAAS,GAAG,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC;AAC9C,eAAe,SAAS,CAAC"}
@@ -52,6 +52,8 @@ export interface UsePathReturn<TData extends PathData = PathData> {
52
52
  retry: () => Promise<void>;
53
53
  /** Pauses the path with intent to return. Emits `suspended`. All state is preserved. */
54
54
  suspend: () => Promise<void>;
55
+ /** Trigger inline validation on all steps without navigating. Sets `snapshot.hasValidated`. */
56
+ validate: () => void;
55
57
  }
56
58
  /**
57
59
  * Create a Pathwrite engine with Svelte 5 runes-based reactivity.
@@ -178,5 +180,12 @@ export declare function bindData<TData extends PathData, K extends string & keyo
178
180
  readonly value: TData[K];
179
181
  set: (value: TData[K]) => void;
180
182
  };
183
+ /**
184
+ * Converts a hyphenated step ID to camelCase.
185
+ * Used internally by PathShell to resolve step snippets when a step ID contains
186
+ * hyphens (e.g. "cover-letter" → "coverLetter"), since Svelte prop names must
187
+ * be valid JavaScript identifiers.
188
+ */
189
+ export declare function stepIdToCamelCase(id: string): string;
181
190
  export { default as PathShell } from "./PathShell.svelte";
182
191
  //# sourceMappingURL=index.svelte.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.svelte.d.ts","sourceRoot":"","sources":["../src/index.svelte.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.svelte.d.ts","sourceRoot":"","sources":["../src/index.svelte.ts"],"names":[],"mappings":"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"}
@@ -87,6 +87,7 @@ export function usePath(options) {
87
87
  const restart = () => engine.restart();
88
88
  const retry = () => engine.retry();
89
89
  const suspend = () => engine.suspend();
90
+ const validate = () => engine.validate();
90
91
  return {
91
92
  get snapshot() { return _snapshot; },
92
93
  start,
@@ -100,7 +101,8 @@ export function usePath(options) {
100
101
  resetStep,
101
102
  restart,
102
103
  retry,
103
- suspend
104
+ suspend,
105
+ validate
104
106
  };
105
107
  }
106
108
  // ---------------------------------------------------------------------------
@@ -175,5 +177,14 @@ export function bindData(getSnapshot, setData, key) {
175
177
  }
176
178
  };
177
179
  }
180
+ /**
181
+ * Converts a hyphenated step ID to camelCase.
182
+ * Used internally by PathShell to resolve step snippets when a step ID contains
183
+ * hyphens (e.g. "cover-letter" → "coverLetter"), since Svelte prop names must
184
+ * be valid JavaScript identifiers.
185
+ */
186
+ export function stepIdToCamelCase(id) {
187
+ return id.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
188
+ }
178
189
  // Export PathShell component
179
190
  export { default as PathShell } from "./PathShell.svelte";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daltonr/pathwrite-svelte",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Svelte 5 adapter for @daltonr/pathwrite-core — runes-based reactive bindings and optional PathShell component.",
@@ -52,7 +52,7 @@
52
52
  "svelte": ">=5.0.0"
53
53
  },
54
54
  "dependencies": {
55
- "@daltonr/pathwrite-core": "^0.10.0"
55
+ "@daltonr/pathwrite-core": "^0.11.0"
56
56
  },
57
57
  "devDependencies": {
58
58
  "@sveltejs/package": "^2.5.7",
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { onMount } from 'svelte';
3
- import { usePath, setPathContext, formatFieldKey, errorPhaseMessage } from './index.svelte.js';
3
+ import { usePath, setPathContext, formatFieldKey, errorPhaseMessage, stepIdToCamelCase } from './index.svelte.js';
4
4
  import type { PathDefinition, PathData, PathEngine, PathSnapshot, ProgressLayout } from './index.svelte.js';
5
5
  import type { Snippet, Component } from 'svelte';
6
6
 
@@ -17,6 +17,10 @@
17
17
  cancelLabel?: string;
18
18
  hideCancel?: boolean;
19
19
  hideProgress?: boolean;
20
+ /** If true, hide the footer (navigation buttons). The error panel is still shown on async failure regardless of this prop. */
21
+ hideFooter?: boolean;
22
+ /** When true, calls `validate()` on the engine so all steps show inline errors simultaneously. Useful when this shell is nested inside a step of an outer shell: bind to the outer snapshot's `hasAttemptedNext`. */
23
+ validateWhen?: boolean;
20
24
  /**
21
25
  * Footer layout mode:
22
26
  * - "auto" (default): Uses "form" for single-step top-level paths, "wizard" otherwise.
@@ -67,6 +71,8 @@
67
71
  cancelLabel = 'Cancel',
68
72
  hideCancel = false,
69
73
  hideProgress = false,
74
+ hideFooter = false,
75
+ validateWhen = false,
70
76
  footerLayout = 'auto',
71
77
  validationDisplay = 'summary',
72
78
  progressLayout = 'merged',
@@ -115,6 +121,18 @@
115
121
  }
116
122
  });
117
123
 
124
+ $effect(() => {
125
+ if (validateWhen) pathReturn.validate();
126
+ });
127
+
128
+ function warnMissingStep(stepId: string): void {
129
+ const camel = stepIdToCamelCase(stepId);
130
+ const hint = camel !== stepId
131
+ ? ` No snippet found for "${stepId}" or its camelCase form "${camel}". If your step ID contains hyphens, pass the snippet as a camelCase prop: ${camel}={YourComponent}.`
132
+ : ` No snippet found for "${stepId}".`;
133
+ console.warn(`[PathShell]${hint}`);
134
+ }
135
+
118
136
  let snap = $derived(pathReturn.snapshot);
119
137
  let actions = $derived({ next, previous, cancel, goToStep, goToStepChecked, setData, restart: () => restartFn(path, initialData), retry, suspend });
120
138
 
@@ -194,7 +212,10 @@
194
212
 
195
213
  <!-- Body: current step rendered via named snippet.
196
214
  Prefer formId (inner step id of a StepChoice) so consumers can
197
- register snippets by inner step ids directly. -->
215
+ register snippets by inner step ids directly.
216
+ Hyphenated step IDs (e.g. "cover-letter") are normalised to camelCase
217
+ ("coverLetter") as a fallback, since Svelte props must be valid JS
218
+ identifiers. -->
198
219
  <div class="pw-shell__body">
199
220
  {#if snap.formId && stepSnippets[snap.formId]}
200
221
  {@const StepComponent = stepSnippets[snap.formId]}
@@ -202,13 +223,17 @@
202
223
  {:else if stepSnippets[snap.stepId]}
203
224
  {@const StepComponent = stepSnippets[snap.stepId]}
204
225
  <StepComponent />
226
+ {:else if stepSnippets[stepIdToCamelCase(snap.formId ?? snap.stepId)]}
227
+ {@const StepComponent = stepSnippets[stepIdToCamelCase(snap.formId ?? snap.stepId)]}
228
+ <StepComponent />
205
229
  {:else}
230
+ {warnMissingStep(snap.stepId)}
206
231
  <p>No content for step "{snap.stepId}"</p>
207
232
  {/if}
208
233
  </div>
209
234
 
210
235
  <!-- Validation messages — suppressed when validationDisplay="inline" -->
211
- {#if validationDisplay !== 'inline' && snap.hasAttemptedNext && Object.keys(snap.fieldErrors).length > 0}
236
+ {#if validationDisplay !== 'inline' && (snap.hasAttemptedNext || snap.hasValidated) && Object.keys(snap.fieldErrors).length > 0}
212
237
  <ul class="pw-shell__validation">
213
238
  {#each Object.entries(snap.fieldErrors) as [key, msg]}
214
239
  <li class="pw-shell__validation-item">
@@ -230,7 +255,7 @@
230
255
  {/if}
231
256
 
232
257
  <!-- Blocking error — guard returned { allowed: false, reason } -->
233
- {#if validationDisplay !== 'inline' && snap.hasAttemptedNext && snap.blockingError}
258
+ {#if validationDisplay !== 'inline' && (snap.hasAttemptedNext || snap.hasValidated) && snap.blockingError}
234
259
  <p class="pw-shell__blocking-error">{snap.blockingError}</p>
235
260
  {/if}
236
261
 
@@ -258,9 +283,9 @@
258
283
  </div>
259
284
  </div>
260
285
  <!-- Footer: navigation buttons (overridable via footer snippet) -->
261
- {:else if footer}
286
+ {:else if !hideFooter && footer}
262
287
  {@render footer(snap, actions)}
263
- {:else}
288
+ {:else if !hideFooter}
264
289
  <div class="pw-shell__footer">
265
290
  <div class="pw-shell__footer-left">
266
291
  {#if resolvedFooterLayout === 'form' && !hideCancel}
@@ -80,6 +80,8 @@ export interface UsePathReturn<TData extends PathData = PathData> {
80
80
  retry: () => Promise<void>;
81
81
  /** Pauses the path with intent to return. Emits `suspended`. All state is preserved. */
82
82
  suspend: () => Promise<void>;
83
+ /** Trigger inline validation on all steps without navigating. Sets `snapshot.hasValidated`. */
84
+ validate: () => void;
83
85
  }
84
86
 
85
87
  // ---------------------------------------------------------------------------
@@ -188,6 +190,8 @@ export function usePath<TData extends PathData = PathData>(
188
190
  const retry = (): Promise<void> => engine.retry();
189
191
  const suspend = (): Promise<void> => engine.suspend();
190
192
 
193
+ const validate = (): void => engine.validate();
194
+
191
195
  return {
192
196
  get snapshot() { return _snapshot; },
193
197
  start,
@@ -201,7 +205,8 @@ export function usePath<TData extends PathData = PathData>(
201
205
  resetStep,
202
206
  restart,
203
207
  retry,
204
- suspend
208
+ suspend,
209
+ validate
205
210
  };
206
211
  }
207
212
 
@@ -310,6 +315,16 @@ export function bindData<TData extends PathData, K extends string & keyof TData>
310
315
  };
311
316
  }
312
317
 
318
+ /**
319
+ * Converts a hyphenated step ID to camelCase.
320
+ * Used internally by PathShell to resolve step snippets when a step ID contains
321
+ * hyphens (e.g. "cover-letter" → "coverLetter"), since Svelte prop names must
322
+ * be valid JavaScript identifiers.
323
+ */
324
+ export function stepIdToCamelCase(id: string): string {
325
+ return id.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
326
+ }
327
+
313
328
  // Export PathShell component
314
329
  export { default as PathShell } from "./PathShell.svelte";
315
330