@prabhask5/stellar-engine 1.1.8 → 1.1.10

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.
Files changed (70) hide show
  1. package/README.md +19 -26
  2. package/dist/auth/crypto.d.ts +0 -23
  3. package/dist/auth/crypto.d.ts.map +1 -1
  4. package/dist/auth/crypto.js +0 -25
  5. package/dist/auth/crypto.js.map +1 -1
  6. package/dist/auth/deviceVerification.d.ts +2 -2
  7. package/dist/auth/deviceVerification.js +2 -2
  8. package/dist/auth/loginGuard.d.ts +7 -14
  9. package/dist/auth/loginGuard.d.ts.map +1 -1
  10. package/dist/auth/loginGuard.js +27 -62
  11. package/dist/auth/loginGuard.js.map +1 -1
  12. package/dist/auth/offlineCredentials.d.ts +6 -59
  13. package/dist/auth/offlineCredentials.d.ts.map +1 -1
  14. package/dist/auth/offlineCredentials.js +8 -111
  15. package/dist/auth/offlineCredentials.js.map +1 -1
  16. package/dist/auth/resolveAuthState.d.ts +14 -18
  17. package/dist/auth/resolveAuthState.d.ts.map +1 -1
  18. package/dist/auth/resolveAuthState.js +16 -58
  19. package/dist/auth/resolveAuthState.js.map +1 -1
  20. package/dist/auth/singleUser.js +4 -4
  21. package/dist/auth/singleUser.js.map +1 -1
  22. package/dist/bin/install-pwa.d.ts +4 -2
  23. package/dist/bin/install-pwa.d.ts.map +1 -1
  24. package/dist/bin/install-pwa.js +2289 -249
  25. package/dist/bin/install-pwa.js.map +1 -1
  26. package/dist/config.d.ts +3 -7
  27. package/dist/config.d.ts.map +1 -1
  28. package/dist/config.js +1 -1
  29. package/dist/config.js.map +1 -1
  30. package/dist/data.d.ts +105 -0
  31. package/dist/data.d.ts.map +1 -1
  32. package/dist/data.js +126 -0
  33. package/dist/data.js.map +1 -1
  34. package/dist/entries/auth.d.ts +8 -13
  35. package/dist/entries/auth.d.ts.map +1 -1
  36. package/dist/entries/auth.js +11 -40
  37. package/dist/entries/auth.js.map +1 -1
  38. package/dist/entries/stores.d.ts +2 -0
  39. package/dist/entries/stores.d.ts.map +1 -1
  40. package/dist/entries/stores.js +7 -0
  41. package/dist/entries/stores.js.map +1 -1
  42. package/dist/entries/types.d.ts +0 -1
  43. package/dist/entries/types.d.ts.map +1 -1
  44. package/dist/index.d.ts +6 -6
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +21 -18
  47. package/dist/index.js.map +1 -1
  48. package/dist/kit/loads.d.ts +3 -5
  49. package/dist/kit/loads.d.ts.map +1 -1
  50. package/dist/kit/loads.js +2 -10
  51. package/dist/kit/loads.js.map +1 -1
  52. package/dist/stores/authState.d.ts +2 -2
  53. package/dist/stores/authState.js +1 -1
  54. package/dist/stores/factories.d.ts +138 -0
  55. package/dist/stores/factories.d.ts.map +1 -0
  56. package/dist/stores/factories.js +154 -0
  57. package/dist/stores/factories.js.map +1 -0
  58. package/dist/supabase/auth.d.ts +14 -181
  59. package/dist/supabase/auth.d.ts.map +1 -1
  60. package/dist/supabase/auth.js +17 -317
  61. package/dist/supabase/auth.js.map +1 -1
  62. package/package.json +1 -1
  63. package/dist/auth/admin.d.ts +0 -49
  64. package/dist/auth/admin.d.ts.map +0 -1
  65. package/dist/auth/admin.js +0 -66
  66. package/dist/auth/admin.js.map +0 -1
  67. package/dist/auth/offlineLogin.d.ts +0 -120
  68. package/dist/auth/offlineLogin.d.ts.map +0 -1
  69. package/dist/auth/offlineLogin.js +0 -142
  70. package/dist/auth/offlineLogin.js.map +0 -1
@@ -12,18 +12,21 @@
12
12
  *
13
13
  * Files are written non-destructively: existing files are skipped, not overwritten.
14
14
  *
15
+ * Launches an interactive walkthrough when invoked as `stellar-engine install pwa`.
16
+ *
15
17
  * @example
16
18
  * ```bash
17
- * stellar-engine install pwa --name "App Name" --short_name "Short" --prefix "myprefix" [--description "..."]
19
+ * stellar-engine install pwa
18
20
  * ```
19
21
  *
20
22
  * @see {@link main} for the entry point
21
- * @see {@link parseArgs} for CLI argument parsing
23
+ * @see {@link runInteractiveSetup} for the interactive walkthrough
22
24
  * @see {@link writeIfMissing} for the non-destructive file write strategy
23
25
  */
24
26
  import { writeFileSync, existsSync, mkdirSync } from 'fs';
25
27
  import { join } from 'path';
26
28
  import { execSync } from 'child_process';
29
+ import { createInterface } from 'readline';
27
30
  // =============================================================================
28
31
  // HELPERS
29
32
  // =============================================================================
@@ -37,12 +40,14 @@ import { execSync } from 'child_process';
37
40
  * @param content - The file content to write.
38
41
  * @param createdFiles - Accumulator for newly-created file paths (relative).
39
42
  * @param skippedFiles - Accumulator for skipped file paths (relative).
43
+ * @param quiet - When `true`, suppresses per-file console output (used during animated progress).
40
44
  */
41
- function writeIfMissing(filePath, content, createdFiles, skippedFiles) {
45
+ function writeIfMissing(filePath, content, createdFiles, skippedFiles, quiet = false) {
42
46
  const relPath = filePath.replace(process.cwd() + '/', '');
43
47
  if (existsSync(filePath)) {
44
48
  skippedFiles.push(relPath);
45
- console.log(` [skip] ${relPath} already exists`);
49
+ if (!quiet)
50
+ console.log(` [skip] ${relPath} already exists`);
46
51
  }
47
52
  else {
48
53
  const dir = filePath.substring(0, filePath.lastIndexOf('/'));
@@ -51,59 +56,239 @@ function writeIfMissing(filePath, content, createdFiles, skippedFiles) {
51
56
  }
52
57
  writeFileSync(filePath, content, 'utf-8');
53
58
  createdFiles.push(relPath);
54
- console.log(` [write] ${relPath}`);
59
+ if (!quiet)
60
+ console.log(` [write] ${relPath}`);
61
+ }
62
+ }
63
+ // =============================================================================
64
+ // ANSI STYLE HELPERS
65
+ // =============================================================================
66
+ /** Wrap text in ANSI bold. */
67
+ const bold = (s) => `\x1b[1m${s}\x1b[22m`;
68
+ /** Wrap text in ANSI dim. */
69
+ const dim = (s) => `\x1b[2m${s}\x1b[22m`;
70
+ /** Wrap text in ANSI cyan. */
71
+ const cyan = (s) => `\x1b[36m${s}\x1b[39m`;
72
+ /** Wrap text in ANSI green. */
73
+ const green = (s) => `\x1b[32m${s}\x1b[39m`;
74
+ /** Wrap text in ANSI yellow. */
75
+ const yellow = (s) => `\x1b[33m${s}\x1b[39m`;
76
+ /** Wrap text in ANSI red. */
77
+ const red = (s) => `\x1b[31m${s}\x1b[39m`;
78
+ /**
79
+ * Draw a box around lines of text using Unicode box-drawing characters.
80
+ *
81
+ * @param lines - The lines of text to display inside the box.
82
+ * @param style - `"double"` for `╔═╗║╚═╝`, `"single"` for `┌─┐│└─┘`.
83
+ * @param title - Optional title to display in the top border.
84
+ * @returns The formatted box string with leading two-space indent.
85
+ */
86
+ function box(lines, style, title) {
87
+ const [tl, h, tr, v, bl, br] = style === 'double'
88
+ ? ['\u2554', '\u2550', '\u2557', '\u2551', '\u255a', '\u255d']
89
+ : ['\u250c', '\u2500', '\u2510', '\u2502', '\u2514', '\u2518'];
90
+ const width = Math.max(...lines.map((l) => l.length), (title ?? '').length + 4, 50);
91
+ let top;
92
+ if (title) {
93
+ const titleStr = `${h} ${title} `;
94
+ top = ` ${tl}${titleStr}${h.repeat(width - titleStr.length)}${tr}`;
55
95
  }
96
+ else {
97
+ top = ` ${tl}${h.repeat(width)}${tr}`;
98
+ }
99
+ const mid = lines.map((l) => ` ${v} ${l.padEnd(width - 2)}${v}`).join('\n');
100
+ const bot = ` ${bl}${h.repeat(width)}${br}`;
101
+ return `${top}\n${mid}\n${bot}`;
102
+ }
103
+ /**
104
+ * Draw a double-bordered box with a header and body separated by a mid-rule.
105
+ *
106
+ * @param header - The header line(s) to display above the divider.
107
+ * @param body - The body lines to display below the divider.
108
+ * @returns The formatted box string with leading two-space indent.
109
+ */
110
+ function doubleBoxWithHeader(header, body) {
111
+ const width = Math.max(...header.map((l) => l.length), ...body.map((l) => l.length), 50);
112
+ const top = ` \u2554${'═'.repeat(width)}\u2557`;
113
+ const headLines = header.map((l) => ` \u2551 ${l.padEnd(width - 2)}\u2551`).join('\n');
114
+ const mid = ` \u2560${'═'.repeat(width)}\u2563`;
115
+ const bodyLines = body.map((l) => ` \u2551 ${l.padEnd(width - 2)}\u2551`).join('\n');
116
+ const bot = ` \u255a${'═'.repeat(width)}\u255d`;
117
+ return `${top}\n${headLines}\n${mid}\n${bodyLines}\n${bot}`;
118
+ }
119
+ // =============================================================================
120
+ // SPINNER
121
+ // =============================================================================
122
+ /** Braille spinner frames for animated progress. */
123
+ const SPINNER_FRAMES = [
124
+ '\u280b',
125
+ '\u2819',
126
+ '\u2839',
127
+ '\u2838',
128
+ '\u283c',
129
+ '\u2834',
130
+ '\u2826',
131
+ '\u2827',
132
+ '\u2807',
133
+ '\u280f'
134
+ ];
135
+ /**
136
+ * Create a terminal spinner that updates a single line in-place.
137
+ *
138
+ * @param text - Initial text to display beside the spinner.
139
+ * @returns An object with `update`, `succeed`, and `stop` methods.
140
+ */
141
+ function createSpinner(text) {
142
+ let frame = 0;
143
+ let current = text;
144
+ let timer = null;
145
+ const render = () => {
146
+ const spinner = cyan(SPINNER_FRAMES[frame % SPINNER_FRAMES.length]);
147
+ process.stdout.write(`\r ${spinner} ${current}`);
148
+ frame++;
149
+ };
150
+ timer = setInterval(render, 80);
151
+ render();
152
+ return {
153
+ update(newText) {
154
+ current = newText;
155
+ },
156
+ succeed(finalText) {
157
+ if (timer)
158
+ clearInterval(timer);
159
+ timer = null;
160
+ process.stdout.write(`\r ${green('\u2713')} ${finalText}\x1b[K\n`);
161
+ },
162
+ stop() {
163
+ if (timer)
164
+ clearInterval(timer);
165
+ timer = null;
166
+ process.stdout.write('\x1b[K');
167
+ }
168
+ };
56
169
  }
57
170
  // =============================================================================
58
- // ARG PARSING
171
+ // INTERACTIVE SETUP
59
172
  // =============================================================================
60
173
  /**
61
- * Parse command-line arguments into an {@link InstallOptions} object.
174
+ * Promisified readline question helper.
62
175
  *
63
- * Expects the format:
64
- * `stellar-engine install pwa --name "..." --short_name "..." --prefix "..." [--description "..."]`
176
+ * @param rl - The readline interface.
177
+ * @param prompt - The prompt string to display.
178
+ * @returns The user's input string.
179
+ */
180
+ function ask(rl, prompt) {
181
+ return new Promise((resolve) => {
182
+ rl.question(prompt, (answer) => resolve(answer));
183
+ });
184
+ }
185
+ /**
186
+ * Run the interactive setup walkthrough, collecting all required options
187
+ * from the user via sequential prompts.
65
188
  *
66
- * Exits the process with a usage message if required arguments are missing.
189
+ * Displays a welcome banner, then prompts for App Name, Short Name, Prefix,
190
+ * and Description with inline validation. Shows a confirmation summary and
191
+ * asks the user to proceed before returning.
67
192
  *
68
- * @param argv - The raw `process.argv` array.
69
- * @returns The parsed {@link InstallOptions}.
193
+ * @returns A promise that resolves with the validated {@link InstallOptions}.
70
194
  *
71
- * @throws {SystemExit} Exits with code 1 if `--name`, `--short_name`, or `--prefix` are missing.
195
+ * @throws {SystemExit} Exits with code 0 if the user declines to proceed.
72
196
  */
73
- function parseArgs(argv) {
74
- const args = argv.slice(2);
75
- if (args[0] !== 'install' || args[1] !== 'pwa') {
76
- console.error('Usage: stellar-engine install pwa --name "App Name" --short_name "Short" --prefix "myprefix" [--description "..."]');
77
- process.exit(1);
78
- }
197
+ async function runInteractiveSetup() {
198
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
199
+ /* ── Welcome banner ── */
200
+ console.log();
201
+ console.log(doubleBoxWithHeader([` ${bold('\u2726 stellar-engine \u00b7 PWA scaffolder \u2726')}`], [
202
+ 'Creates a complete offline-first SvelteKit PWA ',
203
+ 'with auth, sync, and service worker support. '
204
+ ]));
205
+ console.log();
206
+ /* ── App Name ── */
79
207
  let name = '';
208
+ while (!name) {
209
+ console.log(box([
210
+ 'The full name of your application. ',
211
+ 'Used in the page title, README, and manifest. ',
212
+ `Example: ${dim('"Stellar Planner"')} `
213
+ ], 'single', 'App Name'));
214
+ const input = (await ask(rl, ` ${yellow('\u2192')} App name: `)).trim();
215
+ if (!input) {
216
+ console.log(red(' App name is required.\n'));
217
+ }
218
+ else {
219
+ name = input;
220
+ }
221
+ }
222
+ console.log();
223
+ /* ── Short Name ── */
80
224
  let shortName = '';
81
- let prefix = '';
82
- let description = 'A self-hosted offline-first PWA';
83
- for (let i = 2; i < args.length; i++) {
84
- switch (args[i]) {
85
- case '--name':
86
- name = args[++i];
87
- break;
88
- case '--short_name':
89
- shortName = args[++i];
90
- break;
91
- case '--prefix':
92
- prefix = args[++i];
93
- break;
94
- case '--description':
95
- description = args[++i];
96
- break;
225
+ while (!shortName) {
226
+ console.log(box([
227
+ 'A short label for the home screen and app bar. ',
228
+ 'Must be under 12 characters. ',
229
+ `Example: ${dim('"Stellar"')} `
230
+ ], 'single', 'Short Name'));
231
+ const input = (await ask(rl, ` ${yellow('\u2192')} Short name: `)).trim();
232
+ if (!input) {
233
+ console.log(red(' Short name is required.\n'));
234
+ }
235
+ else if (input.length >= 12) {
236
+ console.log(red(' Short name must be under 12 characters.\n'));
237
+ }
238
+ else {
239
+ shortName = input;
97
240
  }
98
241
  }
99
- if (!name || !shortName || !prefix) {
100
- console.error('Error: --name, --short_name, and --prefix are required.\n' +
101
- 'Usage: stellar-engine install pwa --name "App Name" --short_name "Short" --prefix "myprefix" [--description "..."]');
102
- process.exit(1);
242
+ console.log();
243
+ /* ── Prefix ── */
244
+ const suggestedPrefix = name.toLowerCase().replace(/[^a-z0-9]/g, '');
245
+ let prefix = '';
246
+ while (!prefix) {
247
+ console.log(box([
248
+ 'Lowercase key used for localStorage, caches, ',
249
+ 'and the service worker scope. ',
250
+ 'No spaces. Letters and numbers only. ',
251
+ `Suggested: ${dim(`"${suggestedPrefix}"`)}${' '.repeat(Math.max(0, 36 - suggestedPrefix.length - 3))}`
252
+ ], 'single', 'Prefix'));
253
+ const input = (await ask(rl, ` ${yellow('\u2192')} Prefix ${dim(`(${suggestedPrefix})`)}: `)).trim();
254
+ const value = input || suggestedPrefix;
255
+ if (!/^[a-z][a-z0-9]*$/.test(value)) {
256
+ console.log(red(' Prefix must be lowercase, start with a letter, no spaces.\n'));
257
+ }
258
+ else {
259
+ prefix = value;
260
+ }
103
261
  }
262
+ console.log();
263
+ /* ── Description ── */
264
+ const defaultDesc = 'A self-hosted offline-first PWA';
265
+ console.log(box([
266
+ 'A brief description for meta tags and manifest. ',
267
+ `Press Enter to use the default. `,
268
+ `Default: ${dim(`"${defaultDesc}"`)}`
269
+ ], 'single', 'Description'));
270
+ const descInput = (await ask(rl, ` ${yellow('\u2192')} Description ${dim('(optional)')}: `)).trim();
271
+ const description = descInput || defaultDesc;
272
+ console.log();
104
273
  /* Derive kebab-case name for package.json from the full name */
105
274
  const kebabName = name.toLowerCase().replace(/\s+/g, '-');
106
- return { name, shortName, prefix, description, kebabName };
275
+ const opts = { name, shortName, prefix, description, kebabName };
276
+ /* ── Confirmation summary ── */
277
+ console.log(box([
278
+ `${bold('Name:')} ${opts.name}${' '.repeat(Math.max(0, 38 - opts.name.length))}`,
279
+ `${bold('Short name:')} ${opts.shortName}${' '.repeat(Math.max(0, 38 - opts.shortName.length))}`,
280
+ `${bold('Prefix:')} ${opts.prefix}${' '.repeat(Math.max(0, 38 - opts.prefix.length))}`,
281
+ `${bold('Description:')} ${opts.description}${' '.repeat(Math.max(0, 38 - opts.description.length))}`
282
+ ], 'single', 'Configuration'));
283
+ const proceed = (await ask(rl, ` Proceed? ${dim('(Y/n)')}: `)).trim().toLowerCase();
284
+ if (proceed === 'n' || proceed === 'no') {
285
+ console.log(dim('\n Setup cancelled.\n'));
286
+ rl.close();
287
+ process.exit(0);
288
+ }
289
+ rl.close();
290
+ console.log();
291
+ return opts;
107
292
  }
108
293
  // =============================================================================
109
294
  // TEMPLATE GENERATORS
@@ -1239,7 +1424,7 @@ export const prerender = false;
1239
1424
  // },
1240
1425
  // supabase,
1241
1426
  // prefix: '${opts.prefix}',
1242
- // auth: { mode: 'single-user', singleUser: { gateType: 'code', codeLength: 6 } },
1427
+ // auth: { singleUser: { gateType: 'code', codeLength: 6 } },
1243
1428
  // onAuthStateChange: (event, session) => { /* handle auth events */ },
1244
1429
  // onAuthKicked: async () => { await lockSingleUser(); goto('/login'); }
1245
1430
  // });
@@ -1301,7 +1486,7 @@ export const load: LayoutLoad = async ({ url }): Promise<LayoutData> => {
1301
1486
  *
1302
1487
  * @returns The Svelte component source for `src/routes/+layout.svelte`.
1303
1488
  */
1304
- function generateRootLayoutSvelte() {
1489
+ function generateRootLayoutSvelte(opts) {
1305
1490
  return `<!--
1306
1491
  @fileoverview Root layout component — app shell, auth hydration,
1307
1492
  navigation chrome, overlays, and PWA lifecycle.
@@ -1313,48 +1498,236 @@ function generateRootLayoutSvelte() {
1313
1498
  -->
1314
1499
  <script lang="ts">
1315
1500
  /**
1316
- * @fileoverview Root layout script — imports, props, and reactive auth hydration.
1501
+ * @fileoverview Root layout script — auth state management, navigation logic,
1502
+ * service worker communication, and global event handlers.
1317
1503
  */
1318
1504
 
1319
- // ==========================================================================
1320
- // IMPORTS
1321
- // ==========================================================================
1505
+ // =============================================================================
1506
+ // Imports
1507
+ // =============================================================================
1508
+
1509
+ /* ── Svelte Lifecycle & Transitions ── */
1510
+ import { onMount, onDestroy } from 'svelte';
1511
+
1512
+ /* ── SvelteKit Utilities ── */
1513
+ import { page } from '$app/stores';
1514
+ import { browser } from '$app/environment';
1322
1515
 
1323
1516
  /* ── Stellar Engine — Auth & Stores ── */
1517
+ import { lockSingleUser } from '@prabhask5/stellar-engine/auth';
1518
+ import { debug } from '@prabhask5/stellar-engine/utils';
1324
1519
  import { hydrateAuthState } from '@prabhask5/stellar-engine/kit';
1325
- import { authState } from '@prabhask5/stellar-engine/stores';
1326
1520
 
1327
1521
  /* ── Types ── */
1328
1522
  import type { LayoutData } from './+layout';
1329
1523
 
1330
- // ==========================================================================
1331
- // PROPS
1332
- // ==========================================================================
1524
+ // =============================================================================
1525
+ // Props
1526
+ // =============================================================================
1333
1527
 
1334
1528
  interface Props {
1335
- /** Default slot content (the routed page component). */
1529
+ /** Default slot content the matched page component. */
1336
1530
  children?: import('svelte').Snippet;
1337
- /** Layout data returned by the root \`+layout.ts\` load function. */
1531
+
1532
+ /** Layout data from \`+layout.ts\` — session, auth mode, offline profile. */
1338
1533
  data: LayoutData;
1339
1534
  }
1340
1535
 
1341
1536
  let { children, data }: Props = $props();
1342
1537
 
1343
- // ==========================================================================
1344
- // COMPONENT STATE
1345
- // ==========================================================================
1538
+ // =============================================================================
1539
+ // Component State
1540
+ // =============================================================================
1541
+
1542
+ /* ── Toast Notification ── */
1543
+ /** Whether the toast notification is currently visible. */
1544
+ let showToast = $state(false);
1545
+
1546
+ /** The text content of the current toast notification. */
1547
+ let toastMessage = $state('');
1548
+
1549
+ /** The visual style of the toast — \`'info'\` (purple) or \`'error'\` (pink). */
1550
+ let toastType = $state<'info' | 'error'>('info');
1551
+
1552
+ /* ── Sign-Out ── */
1553
+ /** When \`true\`, a full-screen overlay is shown to mask the sign-out transition. */
1554
+ let isSigningOut = $state(false);
1555
+
1556
+ /* ── Cleanup References ── */
1557
+ /** Stored reference to the chunk error handler so we can remove it on destroy. */
1558
+ let chunkErrorHandler: ((event: PromiseRejectionEvent) => void) | null = null;
1559
+
1560
+ // =============================================================================
1561
+ // Reactive Effects
1562
+ // =============================================================================
1346
1563
 
1347
1564
  /**
1348
- * Hydrate the global auth store whenever the load function returns
1349
- * fresh data (e.g. after navigation or token refresh).
1565
+ * Effect: hydrate the global \`authState\` store from layout load data.
1566
+ *
1567
+ * Runs whenever \`data\` changes (e.g. after navigation or revalidation).
1568
+ * Maps the three possible auth modes to the corresponding store setter:
1569
+ * - \`'supabase'\` + session → \`setSupabaseAuth\`
1570
+ * - \`'offline'\` + cached profile → \`setOfflineAuth\`
1571
+ * - anything else → \`setNoAuth\`
1350
1572
  */
1351
1573
  $effect(() => {
1352
1574
  hydrateAuthState(data);
1353
1575
  });
1354
1576
 
1355
- // TODO: Add app shell (navbar, tab bar, overlays, sign-out logic, etc.)
1356
- // TODO: Import and use UpdatePrompt from '$lib/components/UpdatePrompt.svelte'
1357
- // TODO: Import and use SyncStatus from '@prabhask5/stellar-engine/components/SyncStatus'
1577
+ // =============================================================================
1578
+ // Lifecycle Mount
1579
+ // =============================================================================
1580
+
1581
+ onMount(() => {
1582
+ // ── Chunk Error Handler ────────────────────────────────────────────────
1583
+ // When navigating offline to a page whose JS chunks aren't cached,
1584
+ // the dynamic import fails and shows a cryptic error. Catch and show a friendly message.
1585
+ chunkErrorHandler = (event: PromiseRejectionEvent) => {
1586
+ const error = event.reason;
1587
+ // Check if this is a chunk loading error (fetch failed or syntax error from 503 response)
1588
+ const isChunkError =
1589
+ error?.message?.includes('Failed to fetch dynamically imported module') ||
1590
+ error?.message?.includes('error loading dynamically imported module') ||
1591
+ error?.message?.includes('Importing a module script failed') ||
1592
+ error?.name === 'ChunkLoadError' ||
1593
+ (error?.message?.includes('Loading chunk') && error?.message?.includes('failed'));
1594
+
1595
+ if (isChunkError && !navigator.onLine) {
1596
+ event.preventDefault(); // Prevent default error handling
1597
+ // Show offline navigation toast
1598
+ toastMessage = "This page isn't available offline. Please reconnect or go back.";
1599
+ toastType = 'info';
1600
+ showToast = true;
1601
+ setTimeout(() => {
1602
+ showToast = false;
1603
+ }, 5000);
1604
+ }
1605
+ };
1606
+
1607
+ window.addEventListener('unhandledrejection', chunkErrorHandler);
1608
+
1609
+ // ── Sign-Out Event Listener ───────────────────────────────────────────
1610
+ // Listen for sign out requests from child pages (e.g. mobile profile page)
1611
+ window.addEventListener('${opts.prefix}:signout', handleSignOut);
1612
+
1613
+ // ── Service Worker — Background Precaching ────────────────────────────
1614
+ // Proactively cache all app chunks for full offline support.
1615
+ // This runs in the background after page load, so it doesn't affect Lighthouse scores.
1616
+ if ('serviceWorker' in navigator) {
1617
+ // Listen for precache completion messages from service worker
1618
+ navigator.serviceWorker.addEventListener('message', (event) => {
1619
+ if (event.data?.type === 'PRECACHE_COMPLETE') {
1620
+ const { cached, total } = event.data;
1621
+ debug('log', \`[PWA] Background precaching complete: \${cached}/\${total} assets cached\`);
1622
+ if (cached === total) {
1623
+ debug('log', '[PWA] Full offline support ready - all pages accessible offline');
1624
+ } else {
1625
+ debug('warn', \`[PWA] Some assets failed to cache: \${total - cached} missing\`);
1626
+ }
1627
+ }
1628
+ });
1629
+
1630
+ // Wait for service worker to be ready (handles first load case)
1631
+ navigator.serviceWorker.ready.then((registration) => {
1632
+ debug('log', '[PWA] Service worker ready, scheduling background precache...');
1633
+
1634
+ // Give the page time to fully load, then trigger background precaching
1635
+ setTimeout(() => {
1636
+ const controller = navigator.serviceWorker.controller || registration.active;
1637
+ if (!controller) {
1638
+ debug('warn', '[PWA] No service worker controller available');
1639
+ return;
1640
+ }
1641
+
1642
+ // First, cache current page's assets (scripts + stylesheets)
1643
+ const scripts = Array.from(document.querySelectorAll('script[src]'))
1644
+ .map((el) => (el as HTMLScriptElement).src)
1645
+ .filter((src) => src.startsWith(location.origin));
1646
+
1647
+ const styles = Array.from(document.querySelectorAll('link[rel="stylesheet"]'))
1648
+ .map((el) => (el as HTMLLinkElement).href)
1649
+ .filter((href) => href.startsWith(location.origin));
1650
+
1651
+ const urls = [...scripts, ...styles];
1652
+
1653
+ if (urls.length > 0) {
1654
+ debug('log', \`[PWA] Caching \${urls.length} current page assets...\`);
1655
+ controller.postMessage({
1656
+ type: 'CACHE_URLS',
1657
+ urls
1658
+ });
1659
+ }
1660
+
1661
+ // Then trigger full background precaching for all app chunks.
1662
+ // This ensures offline support for all pages, not just visited ones.
1663
+ debug('log', '[PWA] Triggering background precache of all app chunks...');
1664
+ controller.postMessage({
1665
+ type: 'PRECACHE_ALL'
1666
+ });
1667
+ }, 500); // Cache assets quickly to reduce window for uncached refreshes
1668
+ });
1669
+ }
1670
+ });
1671
+
1672
+ // =============================================================================
1673
+ // Lifecycle — Destroy
1674
+ // =============================================================================
1675
+
1676
+ onDestroy(() => {
1677
+ if (browser) {
1678
+ // Cleanup chunk error handler
1679
+ if (chunkErrorHandler) {
1680
+ window.removeEventListener('unhandledrejection', chunkErrorHandler);
1681
+ }
1682
+ // Cleanup sign out listener
1683
+ window.removeEventListener('${opts.prefix}:signout', handleSignOut);
1684
+ }
1685
+ });
1686
+
1687
+ // =============================================================================
1688
+ // Event Handlers
1689
+ // =============================================================================
1690
+
1691
+ /**
1692
+ * Handles the sign-out flow with a visual transition.
1693
+ *
1694
+ * 1. Shows a full-screen "Locking..." overlay immediately.
1695
+ * 2. Waits 250ms for the overlay fade-in to complete.
1696
+ * 3. Calls \`lockSingleUser()\` to stop the engine and clear the session
1697
+ * (but NOT destroy user data).
1698
+ * 4. Hard-navigates to \`/login\` (full page reload to reset all state).
1699
+ */
1700
+ async function handleSignOut() {
1701
+ // Show full-screen overlay immediately
1702
+ isSigningOut = true;
1703
+
1704
+ // Wait for overlay to fully appear
1705
+ await new Promise((resolve) => setTimeout(resolve, 250));
1706
+
1707
+ // Lock the single-user session (stops engine, resets auth state, does NOT destroy data)
1708
+ await lockSingleUser();
1709
+
1710
+ // Navigate to login
1711
+ window.location.href = '/login';
1712
+ }
1713
+
1714
+ /**
1715
+ * Checks whether a given route \`href\` matches the current page path.
1716
+ * Used to highlight the active nav item.
1717
+ *
1718
+ * @param href - The route path to check (e.g. \`'/agenda'\`)
1719
+ * @returns \`true\` if the current path starts with \`href\`
1720
+ */
1721
+ function isActive(href: string): boolean {
1722
+ return $page.url.pathname.startsWith(href);
1723
+ }
1724
+
1725
+ /**
1726
+ * Dismisses the currently visible toast notification.
1727
+ */
1728
+ function dismissToast() {
1729
+ showToast = false;
1730
+ }
1358
1731
  </script>
1359
1732
 
1360
1733
  <!-- TODO: Add your app shell template (navbar, tab bar, page transitions, etc.) -->
@@ -1369,7 +1742,7 @@ function generateRootLayoutSvelte() {
1369
1742
  *
1370
1743
  * @returns The Svelte component source for `src/routes/+page.svelte`.
1371
1744
  */
1372
- function generateHomePage() {
1745
+ function generateHomePage(opts) {
1373
1746
  return `<!--
1374
1747
  @fileoverview Home / landing page — welcome screen and primary content.
1375
1748
 
@@ -1401,9 +1774,31 @@ function generateHomePage() {
1401
1774
  resolveFirstName($authState.session, $authState.offlineProfile)
1402
1775
  );
1403
1776
 
1777
+ // =============================================================================
1778
+ // Reactive Effects
1779
+ // =============================================================================
1780
+
1781
+ /**
1782
+ * Effect: auth redirect guard.
1783
+ *
1784
+ * Once the auth store finishes loading and resolves to \`'none'\` (no session),
1785
+ * redirect to \`/login\` with a \`redirect\` query param so the login page knows
1786
+ * this was an automatic redirect rather than direct navigation.
1787
+ */
1788
+ $effect(() => {
1789
+ if (!$authState.isLoading && $authState.mode === 'none') {
1790
+ // Include redirect param so login page knows this was a redirect, not direct navigation
1791
+ goto('/login?redirect=%2F', { replaceState: true });
1792
+ }
1793
+ });
1794
+
1404
1795
  // TODO: Add home page state and logic
1405
1796
  </script>
1406
1797
 
1798
+ <svelte:head>
1799
+ <title>Home - ${opts.name}</title>
1800
+ </svelte:head>
1801
+
1407
1802
  <!-- TODO: Add home page template -->
1408
1803
  `;
1409
1804
  }
@@ -1412,7 +1807,7 @@ function generateHomePage() {
1412
1807
  *
1413
1808
  * @returns The Svelte component source for `src/routes/+error.svelte`.
1414
1809
  */
1415
- function generateErrorPage() {
1810
+ function generateErrorPage(opts) {
1416
1811
  return `<!--
1417
1812
  @fileoverview Error boundary — handles three scenarios:
1418
1813
  1. **Offline** — device has no connectivity, show a friendly offline message
@@ -1434,26 +1829,68 @@ function generateErrorPage() {
1434
1829
  // STATE
1435
1830
  // ==========================================================================
1436
1831
 
1437
- // TODO: Add error page logic (offline detection, retry handlers, etc.)
1832
+ /** Whether the user is currently offline drives which error variant is shown. */
1833
+ let isOffline = $state(false);
1438
1834
 
1439
1835
  // ==========================================================================
1440
1836
  // REACTIVE EFFECTS
1441
1837
  // ==========================================================================
1442
1838
 
1443
- // TODO: Add reactive effects (e.g. watch online/offline status)
1839
+ /**
1840
+ * Effect: tracks the browser's online/offline status in real time.
1841
+ * Sets \`isOffline\` on mount and attaches \`online\` / \`offline\` event listeners.
1842
+ * Returns a cleanup function that removes the listeners on destroy.
1843
+ */
1844
+ $effect(() => {
1845
+ if (browser) {
1846
+ isOffline = !navigator.onLine;
1847
+
1848
+ const handleOnline = () => {
1849
+ isOffline = false;
1850
+ };
1851
+ const handleOffline = () => {
1852
+ isOffline = true;
1853
+ };
1854
+
1855
+ window.addEventListener('online', handleOnline);
1856
+ window.addEventListener('offline', handleOffline);
1857
+
1858
+ return () => {
1859
+ window.removeEventListener('online', handleOnline);
1860
+ window.removeEventListener('offline', handleOffline);
1861
+ };
1862
+ }
1863
+ });
1444
1864
 
1445
1865
  // ==========================================================================
1446
1866
  // EVENT HANDLERS
1447
1867
  // ==========================================================================
1448
1868
 
1449
- // TODO: Add event handlers (retry, go home, etc.)
1869
+ /**
1870
+ * Reload the current page — useful when the user regains connectivity or
1871
+ * wants to retry after a transient server error.
1872
+ */
1873
+ function handleRetry() {
1874
+ window.location.reload();
1875
+ }
1876
+
1877
+ /**
1878
+ * Navigate back to the home page via SvelteKit client-side routing.
1879
+ */
1880
+ function handleGoHome() {
1881
+ goto('/');
1882
+ }
1450
1883
  </script>
1451
1884
 
1885
+ <svelte:head>
1886
+ <title>Error - ${opts.name}</title>
1887
+ </svelte:head>
1888
+
1452
1889
  <!-- TODO: Add error page template (status code display, retry button, go home button) -->
1453
1890
  `;
1454
1891
  }
1455
1892
  /**
1456
- * Generate the setup page load function with first-setup / admin-only guard.
1893
+ * Generate the setup page load function with first-setup / auth guard.
1457
1894
  *
1458
1895
  * @returns The TypeScript source for `src/routes/setup/+page.ts`.
1459
1896
  */
@@ -1464,18 +1901,18 @@ function generateSetupPageTs() {
1464
1901
  * Two modes:
1465
1902
  * - **Unconfigured** — no runtime config exists yet; anyone can access the
1466
1903
  * setup wizard to perform first-time Supabase configuration.
1467
- * - **Configured + admin** — config already saved; only authenticated admin
1468
- * users may revisit the setup page to update credentials or redeploy.
1904
+ * - **Configured** — config already saved; only authenticated users may
1905
+ * revisit the setup page to update credentials or redeploy.
1469
1906
  */
1470
1907
 
1471
1908
  import { browser } from '$app/environment';
1472
1909
  import { redirect } from '@sveltejs/kit';
1473
1910
  import { getConfig } from '@prabhask5/stellar-engine/config';
1474
- import { getValidSession, isAdmin } from '@prabhask5/stellar-engine/auth';
1911
+ import { getValidSession } from '@prabhask5/stellar-engine/auth';
1475
1912
  import type { PageLoad } from './$types';
1476
1913
 
1477
1914
  /**
1478
- * Guard the setup route — allow first-time setup or admin-only access.
1915
+ * Guard the setup route — allow first-time setup or authenticated access.
1479
1916
  *
1480
1917
  * @returns Page data with an \`isFirstSetup\` flag.
1481
1918
  */
@@ -1489,9 +1926,6 @@ export const load: PageLoad = async () => {
1489
1926
  if (!session?.user) {
1490
1927
  redirect(307, '/login');
1491
1928
  }
1492
- if (!isAdmin(session.user)) {
1493
- redirect(307, '/');
1494
- }
1495
1929
  return { isFirstSetup: false };
1496
1930
  };
1497
1931
  `;
@@ -1501,7 +1935,7 @@ export const load: PageLoad = async () => {
1501
1935
  *
1502
1936
  * @returns The Svelte component source for `src/routes/setup/+page.svelte`.
1503
1937
  */
1504
- function generateSetupPageSvelte() {
1938
+ function generateSetupPageSvelte(opts) {
1505
1939
  return `<!--
1506
1940
  @fileoverview Five-step Supabase configuration wizard.
1507
1941
 
@@ -1510,13 +1944,219 @@ function generateSetupPageSvelte() {
1510
1944
  and reloading the app with the new config active.
1511
1945
  -->
1512
1946
  <script lang="ts">
1947
+ /**
1948
+ * @fileoverview Setup wizard page — first-time Supabase configuration.
1949
+ *
1950
+ * Guides the user through a five-step process to connect their own
1951
+ * Supabase backend to Stellar:
1952
+ *
1953
+ * 1. Create a Supabase project (instructions only).
1954
+ * 2. Configure authentication (enable anonymous sign-ins).
1955
+ * 3. Initialize the database by running the schema SQL.
1956
+ * 4. Enter and validate Supabase credentials (URL + anon key).
1957
+ * 5. Persist configuration via Vercel API (set env vars + redeploy).
1958
+ *
1959
+ * After a successful deploy the page polls for a new service-worker
1960
+ * version — once detected the user is prompted to refresh.
1961
+ *
1962
+ * Access is controlled by the companion \`+page.ts\` load function:
1963
+ * - Unconfigured → anyone can reach this page (\`isFirstSetup: true\`).
1964
+ * - Configured → authenticated users only (\`isFirstSetup: false\`).
1965
+ */
1966
+
1967
+ import { page } from '$app/stores';
1513
1968
  import { setConfig } from '@prabhask5/stellar-engine/config';
1514
1969
  import { isOnline } from '@prabhask5/stellar-engine/stores';
1515
1970
  import { pollForNewServiceWorker } from '@prabhask5/stellar-engine/kit';
1516
1971
 
1517
- // TODO: Add setup wizard state (steps, form fields, validation, deployment)
1972
+ // =============================================================================
1973
+ // Form State — Supabase + Vercel credentials
1974
+ // =============================================================================
1975
+
1976
+ /** Supabase project URL entered by the user */
1977
+ let supabaseUrl = $state('');
1978
+
1979
+ /** Supabase public anon key entered by the user */
1980
+ let supabaseAnonKey = $state('');
1981
+
1982
+ /** One-time Vercel API token for setting env vars */
1983
+ let vercelToken = $state('');
1984
+
1985
+ // =============================================================================
1986
+ // UI State — Validation & Deployment feedback
1987
+ // =============================================================================
1988
+
1989
+ /** Whether the "Test Connection" request is in-flight */
1990
+ let validating = $state(false);
1991
+
1992
+ /** Whether the deploy/redeploy flow is in-flight */
1993
+ let deploying = $state(false);
1994
+
1995
+ /** Error from credential validation, if any */
1996
+ let validateError = $state<string | null>(null);
1997
+
1998
+ /** \`true\` after credentials have been successfully validated */
1999
+ let validateSuccess = $state(false);
2000
+
2001
+ /** Error from the deployment step, if any */
2002
+ let deployError = $state<string | null>(null);
2003
+
2004
+ /** Current deployment pipeline stage — drives the progress UI */
2005
+ let deployStage = $state<'idle' | 'setting-env' | 'deploying' | 'ready'>('idle');
2006
+
2007
+ /** URL returned by Vercel for the triggered deployment (informational) */
2008
+ let _deploymentUrl = $state('');
2009
+
2010
+ // =============================================================================
2011
+ // Derived State
2012
+ // =============================================================================
2013
+
2014
+ /** Whether this is a first-time setup (public) or reconfiguration */
2015
+ const isFirstSetup = $derived(($page.data as { isFirstSetup?: boolean }).isFirstSetup ?? false);
2016
+
2017
+ /**
2018
+ * Snapshot of the credentials at validation time — used to detect
2019
+ * if the user edits the inputs *after* a successful validation.
2020
+ */
2021
+ let validatedUrl = $state('');
2022
+ let validatedKey = $state('');
2023
+
2024
+ /**
2025
+ * \`true\` when the user changes credentials after a successful
2026
+ * validation — the "Continue" button should be re-disabled.
2027
+ */
2028
+ const credentialsChanged = $derived(
2029
+ validateSuccess && (supabaseUrl !== validatedUrl || supabaseAnonKey !== validatedKey)
2030
+ );
2031
+
2032
+ // =============================================================================
2033
+ // Effects
2034
+ // =============================================================================
2035
+
2036
+ /**
2037
+ * Auto-reset validation state when the user modifies credentials
2038
+ * after they were already validated — forces re-validation.
2039
+ */
2040
+ $effect(() => {
2041
+ if (credentialsChanged) {
2042
+ validateSuccess = false;
2043
+ validateError = null;
2044
+ }
2045
+ });
2046
+
2047
+ // =============================================================================
2048
+ // Validation — "Test Connection"
2049
+ // =============================================================================
2050
+
2051
+ /**
2052
+ * Send the entered Supabase credentials to \`/api/setup/validate\`
2053
+ * and update UI state based on the result. On success, also
2054
+ * cache the config locally via \`setConfig\` so the app is usable
2055
+ * immediately after the deployment finishes.
2056
+ */
2057
+ async function handleValidate() {
2058
+ validateError = null;
2059
+ validateSuccess = false;
2060
+ validating = true;
2061
+
2062
+ try {
2063
+ const res = await fetch('/api/setup/validate', {
2064
+ method: 'POST',
2065
+ headers: { 'Content-Type': 'application/json' },
2066
+ body: JSON.stringify({ supabaseUrl, supabaseAnonKey })
2067
+ });
2068
+
2069
+ const data = await res.json();
2070
+
2071
+ if (data.valid) {
2072
+ validateSuccess = true;
2073
+ validatedUrl = supabaseUrl;
2074
+ validatedKey = supabaseAnonKey;
2075
+ /* Cache config locally so the app works immediately after deploy */
2076
+ setConfig({
2077
+ supabaseUrl,
2078
+ supabaseAnonKey,
2079
+ configured: true
2080
+ });
2081
+ } else {
2082
+ validateError = data.error || 'Validation failed';
2083
+ }
2084
+ } catch (e) {
2085
+ validateError = e instanceof Error ? e.message : 'Network error';
2086
+ }
2087
+
2088
+ validating = false;
2089
+ }
2090
+
2091
+ // =============================================================================
2092
+ // Deployment Polling
2093
+ // =============================================================================
2094
+
2095
+ /**
2096
+ * Poll for a new service-worker version to detect when the Vercel
2097
+ * redeployment has finished. Uses the engine's \`pollForNewServiceWorker\`
2098
+ * helper which checks \`registration.update()\` at regular intervals.
2099
+ *
2100
+ * Resolves a Promise when a new SW is detected in the waiting state.
2101
+ */
2102
+ function pollForDeployment(): Promise<void> {
2103
+ return new Promise((resolve) => {
2104
+ pollForNewServiceWorker({
2105
+ intervalMs: 3000,
2106
+ maxAttempts: 200,
2107
+ onFound: () => {
2108
+ deployStage = 'ready';
2109
+ resolve();
2110
+ }
2111
+ });
2112
+ });
2113
+ }
2114
+
2115
+ // =============================================================================
2116
+ // Deployment — Set env vars + trigger Vercel redeploy
2117
+ // =============================================================================
2118
+
2119
+ /**
2120
+ * Send credentials and the Vercel token to \`/api/setup/deploy\`,
2121
+ * which sets the environment variables on the Vercel project and
2122
+ * triggers a fresh deployment. Then poll until the new build is live.
2123
+ */
2124
+ async function handleDeploy() {
2125
+ deployError = null;
2126
+ deploying = true;
2127
+ deployStage = 'setting-env';
2128
+
2129
+ try {
2130
+ const res = await fetch('/api/setup/deploy', {
2131
+ method: 'POST',
2132
+ headers: { 'Content-Type': 'application/json' },
2133
+ body: JSON.stringify({ supabaseUrl, supabaseAnonKey, vercelToken })
2134
+ });
2135
+
2136
+ const data = await res.json();
2137
+
2138
+ if (data.success) {
2139
+ deployStage = 'deploying';
2140
+ _deploymentUrl = data.deploymentUrl || '';
2141
+ /* Poll for the new SW version → marks \`deployStage = 'ready'\` */
2142
+ await pollForDeployment();
2143
+ } else {
2144
+ deployError = data.error || 'Deployment failed';
2145
+ deployStage = 'idle';
2146
+ }
2147
+ } catch (e) {
2148
+ deployError = e instanceof Error ? e.message : 'Network error';
2149
+ deployStage = 'idle';
2150
+ }
2151
+
2152
+ deploying = false;
2153
+ }
1518
2154
  </script>
1519
2155
 
2156
+ <svelte:head>
2157
+ <title>Setup - ${opts.name}</title>
2158
+ </svelte:head>
2159
+
1520
2160
  <!-- TODO: Add setup wizard template (Supabase credentials form, validation, Vercel deployment) -->
1521
2161
  `;
1522
2162
  }
@@ -1525,7 +2165,7 @@ function generateSetupPageSvelte() {
1525
2165
  *
1526
2166
  * @returns The Svelte component source for `src/routes/policy/+page.svelte`.
1527
2167
  */
1528
- function generatePolicyPage() {
2168
+ function generatePolicyPage(opts) {
1529
2169
  return `<!--
1530
2170
  @fileoverview Privacy policy page.
1531
2171
 
@@ -1536,6 +2176,10 @@ function generatePolicyPage() {
1536
2176
  // TODO: Add any needed imports
1537
2177
  </script>
1538
2178
 
2179
+ <svelte:head>
2180
+ <title>Privacy Policy - ${opts.name}</title>
2181
+ </svelte:head>
2182
+
1539
2183
  <!-- TODO: Add privacy policy page content -->
1540
2184
  `;
1541
2185
  }
@@ -1545,7 +2189,7 @@ function generatePolicyPage() {
1545
2189
  *
1546
2190
  * @returns The Svelte component source for `src/routes/login/+page.svelte`.
1547
2191
  */
1548
- function generateLoginPage() {
2192
+ function generateLoginPage(opts) {
1549
2193
  return `<!--
1550
2194
  @fileoverview Login page — three modes:
1551
2195
  1. **Setup** — first-time account creation (email + PIN)
@@ -1575,105 +2219,821 @@ function generateLoginPage() {
1575
2219
  // LAYOUT / PAGE DATA
1576
2220
  // ==========================================================================
1577
2221
 
1578
- // TODO: Add login page state (setup/unlock/link-device modes, PIN inputs, modals)
2222
+ /** Whether the single-user account has already been set up on this device */
2223
+ const singleUserSetUp = $derived($page.data.singleUserSetUp);
2224
+
2225
+ /** Post-login redirect URL extracted from \`?redirect=\` query param */
2226
+ const redirectUrl = $derived($page.url.searchParams.get('redirect') || '/');
1579
2227
 
1580
2228
  // ==========================================================================
1581
2229
  // SHARED UI STATE
1582
2230
  // ==========================================================================
1583
2231
 
1584
- // TODO: Add BroadcastChannel listener for auth-confirmed events from /confirm
1585
- </script>
2232
+ /** \`true\` while any async auth operation is in-flight */
2233
+ let loading = $state(false);
1586
2234
 
1587
- <!-- TODO: Add login page template (PIN inputs, setup wizard, device verification modal) -->
1588
- `;
1589
- }
1590
- /**
1591
- * Generate the email confirmation page component that handles token
1592
- * verification and cross-tab broadcast.
1593
- *
1594
- * @returns The Svelte component source for `src/routes/confirm/+page.svelte`.
1595
- */
1596
- function generateConfirmPage() {
1597
- return `<!--
1598
- @fileoverview Email confirmation page — token verification, BroadcastChannel
1599
- relay, and close/redirect flow.
2235
+ /** Current error message shown to the user (null = no error) */
2236
+ let error = $state<string | null>(null);
1600
2237
 
1601
- Supabase email links land here with \`?token_hash=...&type=...\` query
1602
- params. The page verifies the token, broadcasts the result to the
1603
- originating tab via BroadcastChannel, and either tells the user they
1604
- can close the tab or redirects them to the app root.
1605
- -->
1606
- <script lang="ts">
1607
- import { onMount } from 'svelte';
1608
- import { goto } from '$app/navigation';
1609
- import { page } from '$app/stores';
1610
- import { handleEmailConfirmation, broadcastAuthConfirmed } from '@prabhask5/stellar-engine/kit';
2238
+ /** Triggers the CSS shake animation on the login card */
2239
+ let shaking = $state(false);
1611
2240
 
1612
- // ==========================================================================
1613
- // STATE
1614
- // ==========================================================================
2241
+ /** Set to \`true\` after the component mounts — enables entrance animation */
2242
+ let mounted = $state(false);
1615
2243
 
1616
- /** Current page state — drives which UI variant is rendered. */
1617
- let status: 'verifying' | 'success' | 'error' | 'redirecting' | 'can_close' = 'verifying';
1618
- /** Human-readable error message when verification fails. */
1619
- let errorMessage = '';
2244
+ // =============================================================================
2245
+ // Setup Mode State (step 1 email/name, step 2 PIN creation)
2246
+ // =============================================================================
1620
2247
 
1621
- // ==========================================================================
1622
- // CONSTANTS
1623
- // ==========================================================================
2248
+ /** User's email address for account creation */
2249
+ let email = $state('');
1624
2250
 
1625
- /** BroadcastChannel name shared with the login page. */
1626
- const CHANNEL_NAME = 'auth-channel'; // TODO: Customize channel name
2251
+ /** User's first name */
2252
+ let firstName = $state('');
1627
2253
 
1628
- // ==========================================================================
1629
- // LIFECYCLE
1630
- // ==========================================================================
2254
+ /** User's last name (optional) */
2255
+ let lastName = $state('');
1631
2256
 
1632
- onMount(async () => {
1633
- /* ── Read Supabase callback params ── */
1634
- const tokenHash = $page.url.searchParams.get('token_hash');
1635
- const type = $page.url.searchParams.get('type');
2257
+ /** Individual digit values for the 6-digit PIN code */
2258
+ let codeDigits = $state(['', '', '', '', '', '']);
1636
2259
 
1637
- /* ── Token present verify it ── */
1638
- if (tokenHash && type) {
1639
- const result = await handleEmailConfirmation(
1640
- tokenHash,
1641
- type as 'signup' | 'email' | 'email_change' | 'magiclink'
1642
- );
2260
+ /** Individual digit values for the PIN confirmation */
2261
+ let confirmDigits = $state(['', '', '', '', '', '']);
1643
2262
 
1644
- if (!result.success) {
1645
- status = 'error';
1646
- errorMessage = result.error || 'Unknown error';
1647
- return;
1648
- }
2263
+ /** Concatenated PIN code — derived from \`codeDigits\` */
2264
+ const code = $derived(codeDigits.join(''));
1649
2265
 
1650
- status = 'success';
1651
- /* Brief pause so the user sees the success state */
1652
- await new Promise((resolve) => setTimeout(resolve, 800));
1653
- }
2266
+ /** Concatenated confirmation code — derived from \`confirmDigits\` */
2267
+ const confirmCode = $derived(confirmDigits.join(''));
1654
2268
 
1655
- /* ── Notify the originating tab and decide next action ── */
1656
- const tabResult = await broadcastAuthConfirmed(CHANNEL_NAME, type || 'signup');
1657
- if (tabResult === 'can_close') {
1658
- status = 'can_close';
1659
- } else if (tabResult === 'no_broadcast') {
1660
- focusOrRedirect();
1661
- }
1662
- });
2269
+ /** Current setup wizard step: 1 = email + name, 2 = PIN creation */
2270
+ let setupStep = $state(1); // 1 = email + name, 2 = code
1663
2271
 
1664
- // ==========================================================================
1665
- // HELPERS
1666
- // ==========================================================================
2272
+ // =============================================================================
2273
+ // Unlock Mode State (returning user on this device)
2274
+ // =============================================================================
1667
2275
 
1668
- /**
1669
- * Navigate to the app root when no originating tab is available to
1670
- * receive the BroadcastChannel message (e.g. the user opened the
1671
- * confirmation link in a different browser).
1672
- */
1673
- function focusOrRedirect() {
1674
- goto('/', { replaceState: true });
1675
- }
1676
- </script>
2276
+ /** Individual digit values for the unlock PIN */
2277
+ let unlockDigits = $state(['', '', '', '', '', '']);
2278
+
2279
+ /** Concatenated unlock code derived from \`unlockDigits\` */
2280
+ const unlockCode = $derived(unlockDigits.join(''));
2281
+
2282
+ /** Cached user profile info (first/last name) for the welcome message */
2283
+ let userInfo = $state<{ firstName: string; lastName: string } | null>(null);
2284
+
2285
+ // =============================================================================
2286
+ // Link Device Mode State (new device, existing remote user)
2287
+ // =============================================================================
2288
+
2289
+ /** Individual digit values for the device-linking PIN */
2290
+ let linkDigits = $state(['', '', '', '', '', '']);
2291
+
2292
+ /** Concatenated link code — derived from \`linkDigits\` */
2293
+ const linkCode = $derived(linkDigits.join(''));
2294
+
2295
+ /**
2296
+ * Remote user info fetched from the gate config — contains email,
2297
+ * gate type, code length, and profile data for the welcome message.
2298
+ */
2299
+ let remoteUser = $state<{
2300
+ email: string;
2301
+ gateType: string;
2302
+ codeLength: number;
2303
+ profile: Record<string, unknown>;
2304
+ } | null>(null);
2305
+
2306
+ /** \`true\` when we detected a remote user and entered link-device mode */
2307
+ let linkMode = $state(false);
2308
+
2309
+ /** Loading state specific to the link-device flow */
2310
+ let linkLoading = $state(false);
2311
+
2312
+ /** \`true\` when offline and no local setup exists — shows offline card */
2313
+ let offlineNoSetup = $state(false);
2314
+
2315
+ // =============================================================================
2316
+ // Rate-Limit Countdown State
2317
+ // =============================================================================
2318
+
2319
+ /** Seconds remaining before the user can retry after a rate-limit */
2320
+ let retryCountdown = $state(0);
2321
+
2322
+ /** Interval handle for the retry countdown timer */
2323
+ let retryTimer: ReturnType<typeof setInterval> | null = null;
2324
+
2325
+ // =============================================================================
2326
+ // Modal State — Email Confirmation & Device Verification
2327
+ // =============================================================================
2328
+
2329
+ /** Show the "check your email" modal after initial signup */
2330
+ let showConfirmationModal = $state(false);
2331
+
2332
+ /** Show the "new device detected" verification modal */
2333
+ let showDeviceVerificationModal = $state(false);
2334
+
2335
+ /** Masked email address displayed in the device-verification modal */
2336
+ let maskedEmail = $state('');
2337
+
2338
+ /** Seconds remaining before the "resend" button re-enables */
2339
+ let resendCooldown = $state(0);
2340
+
2341
+ /** Interval handle for the resend cooldown timer */
2342
+ let resendTimer: ReturnType<typeof setInterval> | null = null;
2343
+
2344
+ /** Interval handle for polling device verification status */
2345
+ let verificationPollTimer: ReturnType<typeof setInterval> | null = null;
2346
+
2347
+ /** Guard flag to prevent double-execution of verification completion */
2348
+ let verificationCompleting = false; // guard against double execution
2349
+
2350
+ // =============================================================================
2351
+ // Input Refs — DOM references for focus management
2352
+ // =============================================================================
2353
+
2354
+ /** References to the 6 setup-code \`<input>\` elements */
2355
+ let codeInputs: HTMLInputElement[] = $state([]);
2356
+
2357
+ /** References to the 6 confirm-code \`<input>\` elements */
2358
+ let confirmInputs: HTMLInputElement[] = $state([]);
2359
+
2360
+ /** References to the 6 unlock-code \`<input>\` elements */
2361
+ let unlockInputs: HTMLInputElement[] = $state([]);
2362
+
2363
+ /** References to the link-code \`<input>\` elements */
2364
+ let linkInputs: HTMLInputElement[] = $state([]);
2365
+
2366
+ // =============================================================================
2367
+ // Cross-Tab Communication
2368
+ // =============================================================================
2369
+
2370
+ /** BroadcastChannel instance for receiving \`AUTH_CONFIRMED\` from \`/confirm\` */
2371
+ let authChannel: BroadcastChannel | null = null;
2372
+
2373
+ // =============================================================================
2374
+ // Lifecycle — onMount
2375
+ // =============================================================================
2376
+
2377
+ onMount(async () => {
2378
+ mounted = true;
2379
+
2380
+ /* ── Existing local account → fetch user info for the welcome card ──── */
2381
+ if (singleUserSetUp) {
2382
+ const info = await getSingleUserInfo();
2383
+ if (info) {
2384
+ userInfo = {
2385
+ firstName: (info.profile.firstName as string) || '',
2386
+ lastName: (info.profile.lastName as string) || ''
2387
+ };
2388
+ }
2389
+ } else {
2390
+ /* ── No local setup → check for a remote user to link to ──── */
2391
+ const isOffline = typeof navigator !== 'undefined' && !navigator.onLine;
2392
+ if (isOffline) {
2393
+ offlineNoSetup = true;
2394
+ } else {
2395
+ try {
2396
+ const remote = await fetchRemoteGateConfig();
2397
+ if (remote) {
2398
+ remoteUser = remote;
2399
+ linkMode = true;
2400
+ }
2401
+ } catch {
2402
+ /* No remote user found — fall through to normal setup */
2403
+ }
2404
+ }
2405
+ }
2406
+
2407
+ /* ── Listen for auth confirmation from the \`/confirm\` page ──── */
2408
+ try {
2409
+ authChannel = new BroadcastChannel('stellar-auth-channel');
2410
+ authChannel.onmessage = async (event) => {
2411
+ if (event.data?.type === 'AUTH_CONFIRMED') {
2412
+ /* Bring this tab to the foreground before the confirm tab closes */
2413
+ window.focus();
2414
+ if (showConfirmationModal) {
2415
+ /* Setup confirmation complete → finalize account */
2416
+ const result = await completeSingleUserSetup();
2417
+ if (!result.error) {
2418
+ showConfirmationModal = false;
2419
+ await invalidateAll();
2420
+ goto('/');
2421
+ } else {
2422
+ error = result.error;
2423
+ showConfirmationModal = false;
2424
+ }
2425
+ } else if (showDeviceVerificationModal) {
2426
+ /* Device verification complete (same-browser broadcast) */
2427
+ await handleVerificationComplete();
2428
+ }
2429
+ }
2430
+ };
2431
+ } catch {
2432
+ /* BroadcastChannel not supported — user must manually refresh */
2433
+ }
2434
+ });
2435
+
2436
+ // =============================================================================
2437
+ // Lifecycle — onDestroy (cleanup timers & channels)
2438
+ // =============================================================================
2439
+
2440
+ onDestroy(() => {
2441
+ authChannel?.close();
2442
+ if (resendTimer) clearInterval(resendTimer);
2443
+ if (retryTimer) clearInterval(retryTimer);
2444
+ stopVerificationPolling();
2445
+ });
2446
+
2447
+ // =============================================================================
2448
+ // Device Verification Polling
2449
+ // =============================================================================
2450
+
2451
+ /**
2452
+ * Start polling the engine every 3 seconds to check whether the
2453
+ * device has been trusted (the user clicked the email link on
2454
+ * another device/browser).
2455
+ */
2456
+ function startVerificationPolling() {
2457
+ stopVerificationPolling();
2458
+ verificationPollTimer = setInterval(async () => {
2459
+ if (verificationCompleting) return;
2460
+ const trusted = await pollDeviceVerification();
2461
+ if (trusted) {
2462
+ await handleVerificationComplete();
2463
+ }
2464
+ }, 3000);
2465
+ }
2466
+
2467
+ /**
2468
+ * Stop the verification polling interval and clear the handle.
2469
+ */
2470
+ function stopVerificationPolling() {
2471
+ if (verificationPollTimer) {
2472
+ clearInterval(verificationPollTimer);
2473
+ verificationPollTimer = null;
2474
+ }
2475
+ }
2476
+
2477
+ /**
2478
+ * Finalize device verification — calls \`completeDeviceVerification\`
2479
+ * and redirects on success. Guarded by \`verificationCompleting\` to
2480
+ * prevent double-execution from both polling and BroadcastChannel.
2481
+ */
2482
+ async function handleVerificationComplete() {
2483
+ if (verificationCompleting) return;
2484
+ verificationCompleting = true;
2485
+ stopVerificationPolling();
2486
+
2487
+ const result = await completeDeviceVerification();
2488
+ if (!result.error) {
2489
+ showDeviceVerificationModal = false;
2490
+ await invalidateAll();
2491
+ goto(redirectUrl);
2492
+ } else {
2493
+ error = result.error;
2494
+ showDeviceVerificationModal = false;
2495
+ verificationCompleting = false;
2496
+ }
2497
+ }
2498
+
2499
+ // =============================================================================
2500
+ // Resend & Retry Cooldowns
2501
+ // =============================================================================
2502
+
2503
+ /**
2504
+ * Start a 30-second cooldown on the "Resend email" button to
2505
+ * prevent spamming the email service.
2506
+ */
2507
+ function startResendCooldown() {
2508
+ resendCooldown = 30;
2509
+ if (resendTimer) clearInterval(resendTimer);
2510
+ resendTimer = setInterval(() => {
2511
+ resendCooldown--;
2512
+ if (resendCooldown <= 0 && resendTimer) {
2513
+ clearInterval(resendTimer);
2514
+ resendTimer = null;
2515
+ }
2516
+ }, 1000);
2517
+ }
2518
+
2519
+ /**
2520
+ * Start a countdown after receiving a rate-limit response from the
2521
+ * server. Disables the code inputs and auto-clears the error when
2522
+ * the countdown reaches zero.
2523
+ *
2524
+ * @param ms - The \`retryAfterMs\` value from the server response
2525
+ */
2526
+ function startRetryCountdown(ms: number) {
2527
+ retryCountdown = Math.ceil(ms / 1000);
2528
+ if (retryTimer) clearInterval(retryTimer);
2529
+ retryTimer = setInterval(() => {
2530
+ retryCountdown--;
2531
+ if (retryCountdown <= 0) {
2532
+ retryCountdown = 0;
2533
+ error = null;
2534
+ if (retryTimer) {
2535
+ clearInterval(retryTimer);
2536
+ retryTimer = null;
2537
+ }
2538
+ }
2539
+ }, 1000);
2540
+ }
2541
+
2542
+ // =============================================================================
2543
+ // Email Resend Handler
2544
+ // =============================================================================
2545
+
2546
+ /**
2547
+ * Resend the confirmation or verification email depending on
2548
+ * which modal is currently visible. Respects the resend cooldown.
2549
+ */
2550
+ async function handleResendEmail() {
2551
+ if (resendCooldown > 0) return;
2552
+ startResendCooldown();
2553
+ /* For setup confirmation → resend the signup email */
2554
+ if (showConfirmationModal) {
2555
+ const { resendConfirmationEmail } = await import('@prabhask5/stellar-engine');
2556
+ await resendConfirmationEmail(email);
2557
+ }
2558
+ /* For device verification → resend the OTP email */
2559
+ if (showDeviceVerificationModal) {
2560
+ const info = await getSingleUserInfo();
2561
+ if (info?.email) {
2562
+ await sendDeviceVerification(info.email);
2563
+ }
2564
+ }
2565
+ }
2566
+
2567
+ // =============================================================================
2568
+ // Digit Input Handlers — Shared across all PIN-code fields
2569
+ // =============================================================================
2570
+
2571
+ /**
2572
+ * Handle a single digit being typed into a PIN input box. Filters
2573
+ * non-numeric characters, auto-advances focus, and triggers
2574
+ * \`onComplete\` when the last digit is filled.
2575
+ *
2576
+ * @param digits - The reactive digit array being edited
2577
+ * @param index - Which position in the array this input represents
2578
+ * @param event - The native \`input\` DOM event
2579
+ * @param inputs - Array of \`HTMLInputElement\` refs for focus management
2580
+ * @param onComplete - Optional callback invoked when all digits are filled
2581
+ */
2582
+ function handleDigitInput(
2583
+ digits: string[],
2584
+ index: number,
2585
+ event: Event,
2586
+ inputs: HTMLInputElement[],
2587
+ onComplete?: () => void
2588
+ ) {
2589
+ const input = event.target as HTMLInputElement;
2590
+ const value = input.value.replace(/[^0-9]/g, '');
2591
+
2592
+ if (value.length > 0) {
2593
+ digits[index] = value.charAt(value.length - 1);
2594
+ input.value = digits[index];
2595
+ /* Auto-focus the next input box */
2596
+ if (index < digits.length - 1 && inputs[index + 1]) {
2597
+ inputs[index + 1].focus();
2598
+ }
2599
+ /* Auto-submit when the last digit is entered (brief delay for UX) */
2600
+ if (index === digits.length - 1 && onComplete && digits.every((d) => d !== '')) {
2601
+ setTimeout(() => onComplete(), 300);
2602
+ }
2603
+ } else {
2604
+ digits[index] = '';
2605
+ }
2606
+ }
2607
+
2608
+ /**
2609
+ * Handle backspace in a PIN input — moves focus to the previous
2610
+ * input when the current one is already empty.
2611
+ *
2612
+ * @param digits - The reactive digit array
2613
+ * @param index - Current position index
2614
+ * @param event - The native \`keydown\` event
2615
+ * @param inputs - Array of \`HTMLInputElement\` refs
2616
+ */
2617
+ function handleDigitKeydown(
2618
+ digits: string[],
2619
+ index: number,
2620
+ event: KeyboardEvent,
2621
+ inputs: HTMLInputElement[]
2622
+ ) {
2623
+ if (event.key === 'Backspace') {
2624
+ if (digits[index] === '' && index > 0 && inputs[index - 1]) {
2625
+ inputs[index - 1].focus();
2626
+ digits[index - 1] = '';
2627
+ } else {
2628
+ digits[index] = '';
2629
+ }
2630
+ }
2631
+ }
2632
+
2633
+ /**
2634
+ * Handle paste into a PIN input — distributes pasted digits across
2635
+ * all input boxes and auto-submits if the full code was pasted.
2636
+ *
2637
+ * @param digits - The reactive digit array
2638
+ * @param event - The native \`paste\` clipboard event
2639
+ * @param inputs - Array of \`HTMLInputElement\` refs
2640
+ * @param onComplete - Optional callback invoked when all digits are filled
2641
+ */
2642
+ function handleDigitPaste(
2643
+ digits: string[],
2644
+ event: ClipboardEvent,
2645
+ inputs: HTMLInputElement[],
2646
+ onComplete?: () => void
2647
+ ) {
2648
+ event.preventDefault();
2649
+ const pasted = (event.clipboardData?.getData('text') || '').replace(/[^0-9]/g, '');
2650
+ for (let i = 0; i < digits.length && i < pasted.length; i++) {
2651
+ digits[i] = pasted[i];
2652
+ if (inputs[i]) inputs[i].value = pasted[i];
2653
+ }
2654
+ const focusIndex = Math.min(pasted.length, digits.length - 1);
2655
+ if (inputs[focusIndex]) inputs[focusIndex].focus();
2656
+ /* Auto-submit if the full code was pasted at once */
2657
+ if (pasted.length >= digits.length && onComplete && digits.every((d) => d !== '')) {
2658
+ onComplete();
2659
+ }
2660
+ }
2661
+
2662
+ // =============================================================================
2663
+ // Setup Mode — Step Navigation
2664
+ // =============================================================================
2665
+
2666
+ /**
2667
+ * Validate email and first name, then advance to the PIN-creation
2668
+ * step (step 2). Shows an error if validation fails.
2669
+ */
2670
+ function goToCodeStep() {
2671
+ if (!email.trim() || !/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(email.trim())) {
2672
+ error = 'Please enter a valid email address';
2673
+ return;
2674
+ }
2675
+ if (!firstName.trim()) {
2676
+ error = 'First name is required';
2677
+ return;
2678
+ }
2679
+ error = null;
2680
+ setupStep = 2;
2681
+ }
2682
+
2683
+ /**
2684
+ * Navigate back from step 2 (PIN creation) to step 1 (email/name).
2685
+ */
2686
+ function goBackToNameStep() {
2687
+ setupStep = 1;
2688
+ error = null;
2689
+ }
2690
+
2691
+ /**
2692
+ * Auto-focus the first confirm-code input when the primary code
2693
+ * is fully entered.
2694
+ */
2695
+ function autoFocusConfirm() {
2696
+ if (confirmInputs[0]) confirmInputs[0].focus();
2697
+ }
2698
+
2699
+ /**
2700
+ * Trigger setup submission when the confirm-code auto-completes.
2701
+ */
2702
+ function autoSubmitSetup() {
2703
+ if (confirmDigits.every((d) => d !== '')) {
2704
+ handleSetup();
2705
+ }
2706
+ }
2707
+
2708
+ /**
2709
+ * Trigger unlock submission when the unlock-code auto-completes.
2710
+ */
2711
+ function autoSubmitUnlock() {
2712
+ handleUnlock();
2713
+ }
2714
+
2715
+ // =============================================================================
2716
+ // Setup Mode — Account Creation
2717
+ // =============================================================================
2718
+
2719
+ /**
2720
+ * Handle the full setup flow: validate the code matches its
2721
+ * confirmation, call \`setupSingleUser\`, and handle the response
2722
+ * (which may require email confirmation or succeed immediately).
2723
+ */
2724
+ async function handleSetup() {
2725
+ if (loading) return;
2726
+
2727
+ error = null;
2728
+
2729
+ if (code.length !== 6) {
2730
+ error = 'Please enter a 6-digit code';
2731
+ return;
2732
+ }
2733
+
2734
+ /* Verify code and confirmation match */
2735
+ if (code !== confirmCode) {
2736
+ error = 'Codes do not match';
2737
+ shaking = true;
2738
+ setTimeout(() => {
2739
+ shaking = false;
2740
+ }, 500);
2741
+ /* Clear confirm digits and refocus the first confirm input */
2742
+ confirmDigits = ['', '', '', '', '', ''];
2743
+ if (confirmInputs[0]) confirmInputs[0].focus();
2744
+ return;
2745
+ }
2746
+
2747
+ loading = true;
2748
+
2749
+ try {
2750
+ const result = await setupSingleUser(
2751
+ code,
2752
+ {
2753
+ firstName: firstName.trim(),
2754
+ lastName: lastName.trim()
2755
+ },
2756
+ email.trim()
2757
+ );
2758
+ if (result.error) {
2759
+ error = result.error;
2760
+ shaking = true;
2761
+ setTimeout(() => {
2762
+ shaking = false;
2763
+ }, 500);
2764
+ codeDigits = ['', '', '', '', '', ''];
2765
+ confirmDigits = ['', '', '', '', '', ''];
2766
+ if (codeInputs[0]) codeInputs[0].focus();
2767
+ return;
2768
+ }
2769
+ if (result.confirmationRequired) {
2770
+ /* Email confirmation needed → show the "check your email" modal */
2771
+ showConfirmationModal = true;
2772
+ startResendCooldown();
2773
+ return;
2774
+ }
2775
+ /* No confirmation needed → go straight to the app */
2776
+ await invalidateAll();
2777
+ goto('/');
2778
+ } catch (err: unknown) {
2779
+ error = err instanceof Error ? err.message : 'Setup failed. Please try again.';
2780
+ shaking = true;
2781
+ setTimeout(() => {
2782
+ shaking = false;
2783
+ }, 500);
2784
+ codeDigits = ['', '', '', '', '', ''];
2785
+ confirmDigits = ['', '', '', '', '', ''];
2786
+ if (codeInputs[0]) codeInputs[0].focus();
2787
+ } finally {
2788
+ loading = false;
2789
+ }
2790
+ }
2791
+
2792
+ // =============================================================================
2793
+ // Unlock Mode — PIN Entry for Returning Users
2794
+ // =============================================================================
2795
+
2796
+ /**
2797
+ * Attempt to unlock the local account with the entered 6-digit PIN.
2798
+ * Handles rate-limiting, device verification requirements, and
2799
+ * error feedback with shake animation.
2800
+ */
2801
+ async function handleUnlock() {
2802
+ if (loading || retryCountdown > 0) return;
2803
+
2804
+ error = null;
2805
+
2806
+ if (unlockCode.length !== 6) {
2807
+ error = 'Please enter your 6-digit code';
2808
+ return;
2809
+ }
2810
+
2811
+ loading = true;
2812
+
2813
+ try {
2814
+ const result = await unlockSingleUser(unlockCode);
2815
+ if (result.error) {
2816
+ error = result.error;
2817
+ if (result.retryAfterMs) {
2818
+ startRetryCountdown(result.retryAfterMs);
2819
+ }
2820
+ shaking = true;
2821
+ setTimeout(() => {
2822
+ shaking = false;
2823
+ }, 500);
2824
+ unlockDigits = ['', '', '', '', '', ''];
2825
+ return;
2826
+ }
2827
+ if (result.deviceVerificationRequired) {
2828
+ /* Untrusted device → show verification modal + start polling */
2829
+ maskedEmail = result.maskedEmail || '';
2830
+ showDeviceVerificationModal = true;
2831
+ startResendCooldown();
2832
+ startVerificationPolling();
2833
+ return;
2834
+ }
2835
+ /* Success → navigate to the redirect target */
2836
+ await invalidateAll();
2837
+ goto(redirectUrl);
2838
+ } catch (err: unknown) {
2839
+ error = err instanceof Error ? err.message : 'Incorrect code';
2840
+ shaking = true;
2841
+ setTimeout(() => {
2842
+ shaking = false;
2843
+ }, 500);
2844
+ unlockDigits = ['', '', '', '', '', ''];
2845
+ } finally {
2846
+ loading = false;
2847
+ if (error) {
2848
+ await tick();
2849
+ if (unlockInputs[0]) unlockInputs[0].focus();
2850
+ }
2851
+ }
2852
+ }
2853
+
2854
+ // =============================================================================
2855
+ // Link Device Mode — Connect a New Device to an Existing Account
2856
+ // =============================================================================
2857
+
2858
+ /**
2859
+ * Trigger link submission when the link-code auto-completes.
2860
+ */
2861
+ function autoSubmitLink() {
2862
+ if (linkDigits.every((d) => d !== '')) {
2863
+ handleLink();
2864
+ }
2865
+ }
2866
+
2867
+ /**
2868
+ * Attempt to link this device to the remote user account by
2869
+ * submitting the PIN. Similar flow to unlock — may require device
2870
+ * verification or trigger rate-limiting.
2871
+ */
2872
+ async function handleLink() {
2873
+ if (linkLoading || !remoteUser || retryCountdown > 0) return;
2874
+
2875
+ error = null;
2876
+
2877
+ if (linkCode.length !== remoteUser.codeLength) {
2878
+ error = \`Please enter a \${remoteUser.codeLength}-digit code\`;
2879
+ return;
2880
+ }
2881
+
2882
+ linkLoading = true;
2883
+ try {
2884
+ const result = await linkSingleUserDevice(remoteUser.email, linkCode);
2885
+ if (result.error) {
2886
+ error = result.error;
2887
+ if (result.retryAfterMs) {
2888
+ startRetryCountdown(result.retryAfterMs);
2889
+ }
2890
+ shaking = true;
2891
+ setTimeout(() => {
2892
+ shaking = false;
2893
+ }, 500);
2894
+ linkDigits = Array(remoteUser.codeLength).fill('');
2895
+ return;
2896
+ }
2897
+ if (result.deviceVerificationRequired) {
2898
+ maskedEmail = result.maskedEmail || '';
2899
+ showDeviceVerificationModal = true;
2900
+ startResendCooldown();
2901
+ startVerificationPolling();
2902
+ return;
2903
+ }
2904
+ /* Success → navigate to the redirect target */
2905
+ await invalidateAll();
2906
+ goto(redirectUrl);
2907
+ } catch (err: unknown) {
2908
+ error = err instanceof Error ? err.message : 'Incorrect code';
2909
+ shaking = true;
2910
+ setTimeout(() => {
2911
+ shaking = false;
2912
+ }, 500);
2913
+ linkDigits = Array(remoteUser.codeLength).fill('');
2914
+ } finally {
2915
+ linkLoading = false;
2916
+ if (error) {
2917
+ await tick();
2918
+ if (linkInputs[0]) linkInputs[0].focus();
2919
+ }
2920
+ }
2921
+ }
2922
+ </script>
2923
+
2924
+ <svelte:head>
2925
+ <title>Login - ${opts.name}</title>
2926
+ </svelte:head>
2927
+
2928
+ <!-- TODO: Add login page template (PIN inputs, setup wizard, device verification modal) -->
2929
+ `;
2930
+ }
2931
+ /**
2932
+ * Generate the email confirmation page component that handles token
2933
+ * verification and cross-tab broadcast.
2934
+ *
2935
+ * @returns The Svelte component source for `src/routes/confirm/+page.svelte`.
2936
+ */
2937
+ function generateConfirmPage(opts) {
2938
+ return `<!--
2939
+ @fileoverview Email confirmation page — token verification, BroadcastChannel
2940
+ relay, and close/redirect flow.
2941
+
2942
+ Supabase email links land here with \`?token_hash=...&type=...\` query
2943
+ params. The page verifies the token, broadcasts the result to the
2944
+ originating tab via BroadcastChannel, and either tells the user they
2945
+ can close the tab or redirects them to the app root.
2946
+ -->
2947
+ <script lang="ts">
2948
+ import { onMount } from 'svelte';
2949
+ import { goto } from '$app/navigation';
2950
+ import { page } from '$app/stores';
2951
+ import { handleEmailConfirmation, broadcastAuthConfirmed } from '@prabhask5/stellar-engine/kit';
2952
+
2953
+ // ==========================================================================
2954
+ // STATE
2955
+ // ==========================================================================
2956
+
2957
+ /** Current page state — drives which UI variant is rendered. */
2958
+ let status: 'verifying' | 'success' | 'error' | 'redirecting' | 'can_close' = 'verifying';
2959
+
2960
+ /** Human-readable error message when verification fails. */
2961
+ let errorMessage = '';
2962
+
2963
+ // ==========================================================================
2964
+ // CONSTANTS
2965
+ // ==========================================================================
2966
+
2967
+ /** BroadcastChannel name shared with the login page. */
2968
+ const CHANNEL_NAME = '${opts.prefix}-auth-channel';
2969
+
2970
+ // ==========================================================================
2971
+ // LIFECYCLE
2972
+ // ==========================================================================
2973
+
2974
+ onMount(async () => {
2975
+ /* ── Read Supabase callback params ── */
2976
+ const tokenHash = $page.url.searchParams.get('token_hash');
2977
+ const type = $page.url.searchParams.get('type');
2978
+
2979
+ /* ── Token present → verify it ── */
2980
+ if (tokenHash && type) {
2981
+ const result = await handleEmailConfirmation(
2982
+ tokenHash,
2983
+ type as 'signup' | 'email' | 'email_change' | 'magiclink'
2984
+ );
2985
+
2986
+ if (!result.success) {
2987
+ status = 'error';
2988
+ errorMessage = result.error || 'Unknown error';
2989
+ return;
2990
+ }
2991
+
2992
+ status = 'success';
2993
+ /* Brief pause so the user sees the success state */
2994
+ await new Promise((resolve) => setTimeout(resolve, 800));
2995
+ }
2996
+
2997
+ /* ── Notify the originating tab and decide next action ── */
2998
+ const tabResult = await broadcastAuthConfirmed(CHANNEL_NAME, type || 'signup');
2999
+ if (tabResult === 'can_close') {
3000
+ status = 'can_close';
3001
+ } else if (tabResult === 'no_broadcast') {
3002
+ focusOrRedirect();
3003
+ }
3004
+ });
3005
+
3006
+ // ==========================================================================
3007
+ // HELPERS
3008
+ // ==========================================================================
3009
+
3010
+ /**
3011
+ * Broadcast a confirmation event to any listening login tab, then
3012
+ * attempt to close this browser tab. Falls back to a static
3013
+ * "you can close this tab" message when \`window.close()\` is denied.
3014
+ */
3015
+ async function focusOrRedirect() {
3016
+ status = 'redirecting';
3017
+
3018
+ const type = $page.url.searchParams.get('type') || 'signup';
3019
+
3020
+ const result = await broadcastAuthConfirmed(CHANNEL_NAME, type);
3021
+
3022
+ if (result === 'no_broadcast') {
3023
+ /* BroadcastChannel unsupported — redirect to home directly */
3024
+ goto('/', { replaceState: true });
3025
+ } else {
3026
+ /* 'can_close' — window.close() was blocked by browser */
3027
+ setTimeout(() => {
3028
+ status = 'can_close';
3029
+ }, 200);
3030
+ }
3031
+ }
3032
+ </script>
3033
+
3034
+ <svelte:head>
3035
+ <title>Confirming... - ${opts.name}</title>
3036
+ </svelte:head>
1677
3037
 
1678
3038
  <!-- TODO: Add confirmation page template (verifying/success/error/can_close states) -->
1679
3039
  `;
@@ -1814,50 +3174,46 @@ export function load() {
1814
3174
  */
1815
3175
  function generateProtectedLayoutTs() {
1816
3176
  return `/**
1817
- * @fileoverview Auth Guard protected route group layout loader.
3177
+ * @fileoverview Protected Layout Load Function Auth Guard
1818
3178
  *
1819
- * Redirects unauthenticated users to \`/login\`, preserving the intended
1820
- * destination as a \`?redirect=\` query parameter so the login page can
1821
- * navigate back after successful authentication.
3179
+ * Runs on every navigation into the \`(protected)\` route group.
3180
+ * Resolves the current authentication state via \`stellar-engine\` and
3181
+ * redirects unauthenticated users to \`/login\` (preserving the intended
3182
+ * destination as a \`?redirect=\` query parameter).
3183
+ *
3184
+ * On the server (SSR), returns a neutral "unauthenticated" payload so
3185
+ * that the actual auth check happens exclusively in the browser where
3186
+ * cookies / local storage are available.
1822
3187
  */
1823
3188
 
1824
3189
  import { redirect } from '@sveltejs/kit';
1825
3190
  import { browser } from '$app/environment';
1826
- import { resolveAuthState } from '@prabhask5/stellar-engine/auth';
1827
- import type { AuthMode, OfflineCredentials, Session } from '@prabhask5/stellar-engine/types';
3191
+ import { resolveProtectedLayout } from '@prabhask5/stellar-engine/kit';
3192
+ import type { ProtectedLayoutData } from '@prabhask5/stellar-engine/kit';
1828
3193
  import type { LayoutLoad } from './$types';
1829
3194
 
1830
- /**
1831
- * Data returned by the protected layout load function.
1832
- */
1833
- export interface ProtectedLayoutData {
1834
- /** Active Supabase session, or \`null\` when offline. */
1835
- session: Session | null;
1836
- /** Current authentication mode (\`'online'\`, \`'offline'\`, or \`'none'\`). */
1837
- authMode: AuthMode;
1838
- /** Cached offline credentials when auth mode is offline. */
1839
- offlineProfile: OfflineCredentials | null;
1840
- }
3195
+ export type { ProtectedLayoutData };
1841
3196
 
1842
3197
  /**
1843
- * Enforce authentication redirect to login if the user has no session.
3198
+ * SvelteKit universal \`load\` function for the \`(protected)\` layout.
1844
3199
  *
1845
- * @param params - SvelteKit load params (provides the current URL).
1846
- * @returns Protected layout data with guaranteed auth state.
3200
+ * - **Browser**: resolves the auth state; redirects to \`/login\` if \`authMode\` is \`'none'\`.
3201
+ * - **Server**: short-circuits with a neutral payload (auth is client-side only).
3202
+ *
3203
+ * @param url — The current page URL, used to build the redirect target.
3204
+ * @returns Resolved \`ProtectedLayoutData\` for downstream pages and layouts.
1847
3205
  */
1848
3206
  export const load: LayoutLoad = async ({ url }): Promise<ProtectedLayoutData> => {
1849
3207
  if (browser) {
1850
- const result = await resolveAuthState();
1851
- if (result.authMode === 'none') {
1852
- const returnUrl = url.pathname + url.search;
1853
- const loginUrl =
1854
- returnUrl && returnUrl !== '/'
1855
- ? \`/login?redirect=\${encodeURIComponent(returnUrl)}\`
1856
- : '/login';
1857
- throw redirect(302, loginUrl);
3208
+ const { data, redirectUrl } = await resolveProtectedLayout(url);
3209
+
3210
+ if (redirectUrl) {
3211
+ throw redirect(302, redirectUrl);
1858
3212
  }
1859
- return result;
3213
+
3214
+ return data;
1860
3215
  }
3216
+
1861
3217
  /* SSR fallback — no auth info available on the server */
1862
3218
  return { session: null, authMode: 'none', offlineProfile: null };
1863
3219
  };
@@ -1898,6 +3254,7 @@ function generateProtectedLayoutSvelte() {
1898
3254
  // TODO: Add conditional page backgrounds or other protected-area chrome
1899
3255
  </script>
1900
3256
 
3257
+ <!-- Render child route content -->
1901
3258
  {@render children?.()}
1902
3259
  `;
1903
3260
  }
@@ -1907,9 +3264,9 @@ function generateProtectedLayoutSvelte() {
1907
3264
  *
1908
3265
  * @returns The Svelte component source for `src/routes/(protected)/profile/+page.svelte`.
1909
3266
  */
1910
- function generateProfilePage() {
3267
+ function generateProfilePage(opts) {
1911
3268
  return `<!--
1912
- @fileoverview Profile & administration page.
3269
+ @fileoverview Profile & settings page.
1913
3270
 
1914
3271
  Capabilities:
1915
3272
  - View / edit display name and avatar
@@ -1920,9 +3277,9 @@ function generateProfilePage() {
1920
3277
  - Reset local database (destructive — requires confirmation)
1921
3278
  -->
1922
3279
  <script lang="ts">
1923
- // ==========================================================================
1924
- // IMPORTS
1925
- // ==========================================================================
3280
+ // =============================================================================
3281
+ // IMPORTS
3282
+ // =============================================================================
1926
3283
 
1927
3284
  import { goto } from '$app/navigation';
1928
3285
  import {
@@ -1930,10 +3287,10 @@ function generateProfilePage() {
1930
3287
  updateSingleUserProfile,
1931
3288
  getSingleUserInfo,
1932
3289
  changeSingleUserEmail,
1933
- completeSingleUserEmailChange
3290
+ completeSingleUserEmailChange,
3291
+ resolveUserId,
3292
+ resolveAvatarInitial
1934
3293
  } from '@prabhask5/stellar-engine/auth';
1935
- // TODO: Import resolveFirstName, resolveUserId, resolveAvatarInitial
1936
- // from '@prabhask5/stellar-engine/auth' for display name and avatar logic
1937
3294
  import { authState } from '@prabhask5/stellar-engine/stores';
1938
3295
  import { isDebugMode, setDebugMode } from '@prabhask5/stellar-engine/utils';
1939
3296
  import {
@@ -1942,14 +3299,613 @@ function generateProfilePage() {
1942
3299
  removeTrustedDevice,
1943
3300
  getCurrentDeviceId
1944
3301
  } from '@prabhask5/stellar-engine';
3302
+ import type { TrustedDevice } from '@prabhask5/stellar-engine';
3303
+ import { onMount } from 'svelte';
1945
3304
 
1946
- // ==========================================================================
1947
- // COMPONENT STATE
1948
- // ==========================================================================
3305
+ // =============================================================================
3306
+ // COMPONENT STATE
3307
+ // =============================================================================
3308
+
3309
+ /* ── Profile form fields ──── */
3310
+ let firstName = $state('');
3311
+ let lastName = $state('');
3312
+
3313
+ /* ── Gate (6-digit code) change — digit-array approach ──── */
3314
+ let oldCodeDigits = $state(['', '', '', '', '', '']);
3315
+ let newCodeDigits = $state(['', '', '', '', '', '']);
3316
+ let confirmCodeDigits = $state(['', '', '', '', '', '']);
3317
+
3318
+ /** Concatenated old code string → derived from individual digit inputs */
3319
+ const oldCode = $derived(oldCodeDigits.join(''));
3320
+ /** Concatenated new code string → derived from individual digit inputs */
3321
+ const newCode = $derived(newCodeDigits.join(''));
3322
+ /** Concatenated confirm code string — must match \`newCode\` */
3323
+ const confirmNewCode = $derived(confirmCodeDigits.join(''));
3324
+
3325
+ /* ── Input element refs for auto-focus advancement ──── */
3326
+ let oldCodeInputs: HTMLInputElement[] = $state([]);
3327
+ let newCodeInputs: HTMLInputElement[] = $state([]);
3328
+ let confirmCodeInputs: HTMLInputElement[] = $state([]);
3329
+
3330
+ /* ── Email change fields ──── */
3331
+ let currentEmail = $state('');
3332
+ let newEmail = $state('');
3333
+ let emailLoading = $state(false);
3334
+ let emailError = $state<string | null>(null);
3335
+ let emailSuccess = $state<string | null>(null);
3336
+ /** Whether the email confirmation modal overlay is visible */
3337
+ let showEmailConfirmationModal = $state(false);
3338
+ /** Seconds remaining before the user can re-send the confirmation email */
3339
+ let emailResendCooldown = $state(0);
3340
+
3341
+ /* ── General UI / feedback state ──── */
3342
+ let profileLoading = $state(false);
3343
+ let codeLoading = $state(false);
3344
+ let profileError = $state<string | null>(null);
3345
+ let profileSuccess = $state<string | null>(null);
3346
+ let codeError = $state<string | null>(null);
3347
+ let codeSuccess = $state<string | null>(null);
3348
+ let debugMode = $state(isDebugMode());
3349
+ let resetting = $state(false);
3350
+
3351
+ /* ── Debug tools loading flags ──── */
3352
+ let forceSyncing = $state(false);
3353
+ let triggeringSyncManual = $state(false);
3354
+ let resettingCursor = $state(false);
3355
+ let checkingConnection = $state(false);
3356
+ let viewingTombstones = $state(false);
3357
+ let cleaningTombstones = $state(false);
3358
+
3359
+ /* ── Trusted devices ──── */
3360
+ let trustedDevices = $state<TrustedDevice[]>([]);
3361
+ let currentDeviceId = $state('');
3362
+ let devicesLoading = $state(true);
3363
+ /** ID of the device currently being removed — shows spinner on that row */
3364
+ let removingDeviceId = $state<string | null>(null);
3365
+
3366
+ // =============================================================================
3367
+ // LIFECYCLE
3368
+ // =============================================================================
3369
+
3370
+ /** Populate form fields from the engine and load trusted devices on mount. */
3371
+ onMount(async () => {
3372
+ const info = await getSingleUserInfo();
3373
+ if (info) {
3374
+ firstName = (info.profile.firstName as string) || '';
3375
+ lastName = (info.profile.lastName as string) || '';
3376
+ currentEmail = info.email || '';
3377
+ }
3378
+
3379
+ // Load trusted devices
3380
+ currentDeviceId = getCurrentDeviceId();
3381
+ try {
3382
+ const userId = resolveUserId($authState?.session, $authState?.offlineProfile);
3383
+ if (userId) {
3384
+ trustedDevices = await getTrustedDevices(userId);
3385
+ }
3386
+ } catch {
3387
+ // Ignore errors loading devices
3388
+ }
3389
+ devicesLoading = false;
3390
+ });
3391
+
3392
+ // =============================================================================
3393
+ // DIGIT INPUT HELPERS
3394
+ // =============================================================================
3395
+
3396
+ /**
3397
+ * Handle single-digit input in a code field.
3398
+ * Auto-advances focus to the next input when a digit is entered.
3399
+ * @param digits - Reactive digit array to mutate
3400
+ * @param index - Position in the 6-digit code (0–5)
3401
+ * @param event - Native input event
3402
+ * @param inputs - Array of \`<input>\` refs for focus management
3403
+ */
3404
+ function handleDigitInput(
3405
+ digits: string[],
3406
+ index: number,
3407
+ event: Event,
3408
+ inputs: HTMLInputElement[]
3409
+ ) {
3410
+ const input = event.target as HTMLInputElement;
3411
+ const value = input.value.replace(/[^0-9]/g, '');
3412
+ if (value.length > 0) {
3413
+ digits[index] = value.charAt(value.length - 1);
3414
+ input.value = digits[index];
3415
+ if (index < 5 && inputs[index + 1]) {
3416
+ inputs[index + 1].focus();
3417
+ }
3418
+ } else {
3419
+ digits[index] = '';
3420
+ }
3421
+ }
3422
+
3423
+ /**
3424
+ * Handle Backspace in a digit field — moves focus backward when the current
3425
+ * digit is already empty.
3426
+ * @param digits - Reactive digit array to mutate
3427
+ * @param index - Position in the 6-digit code (0–5)
3428
+ * @param event - Native keyboard event
3429
+ * @param inputs - Array of \`<input>\` refs for focus management
3430
+ */
3431
+ function handleDigitKeydown(
3432
+ digits: string[],
3433
+ index: number,
3434
+ event: KeyboardEvent,
3435
+ inputs: HTMLInputElement[]
3436
+ ) {
3437
+ if (event.key === 'Backspace') {
3438
+ if (digits[index] === '' && index > 0 && inputs[index - 1]) {
3439
+ inputs[index - 1].focus();
3440
+ digits[index - 1] = '';
3441
+ } else {
3442
+ digits[index] = '';
3443
+ }
3444
+ }
3445
+ }
3446
+
3447
+ /**
3448
+ * Handle paste into a digit field — distributes pasted digits across all 6 inputs.
3449
+ * @param digits - Reactive digit array to mutate
3450
+ * @param event - Native clipboard event
3451
+ * @param inputs - Array of \`<input>\` refs for focus management
3452
+ */
3453
+ function handleDigitPaste(digits: string[], event: ClipboardEvent, inputs: HTMLInputElement[]) {
3454
+ event.preventDefault();
3455
+ const pasted = (event.clipboardData?.getData('text') || '').replace(/[^0-9]/g, '');
3456
+ for (let i = 0; i < 6 && i < pasted.length; i++) {
3457
+ digits[i] = pasted[i];
3458
+ if (inputs[i]) inputs[i].value = pasted[i];
3459
+ }
3460
+ const focusIndex = Math.min(pasted.length, 5);
3461
+ if (inputs[focusIndex]) inputs[focusIndex].focus();
3462
+ }
3463
+
3464
+ // =============================================================================
3465
+ // FORM SUBMISSION HANDLERS
3466
+ // =============================================================================
3467
+
3468
+ /**
3469
+ * Submit profile name changes to the engine and update the auth store
3470
+ * so the navbar reflects changes immediately.
3471
+ * @param e - Form submit event
3472
+ */
3473
+ async function handleProfileSubmit(e: Event) {
3474
+ e.preventDefault();
3475
+ profileLoading = true;
3476
+ profileError = null;
3477
+ profileSuccess = null;
3478
+
3479
+ try {
3480
+ await updateSingleUserProfile({
3481
+ firstName: firstName.trim(),
3482
+ lastName: lastName.trim()
3483
+ });
3484
+ // Update auth state to immediately reflect changes in navbar
3485
+ authState.updateUserProfile({ first_name: firstName.trim(), last_name: lastName.trim() });
3486
+ profileSuccess = 'Profile updated successfully';
3487
+ setTimeout(() => (profileSuccess = null), 3000);
3488
+ } catch (err: unknown) {
3489
+ profileError = err instanceof Error ? err.message : 'Failed to update profile';
3490
+ }
3491
+
3492
+ profileLoading = false;
3493
+ }
3494
+
3495
+ /**
3496
+ * Validate and submit a 6-digit gate code change.
3497
+ * Resets all digit arrays on success.
3498
+ * @param e - Form submit event
3499
+ */
3500
+ async function handleCodeSubmit(e: Event) {
3501
+ e.preventDefault();
3502
+
3503
+ if (oldCode.length !== 6) {
3504
+ codeError = 'Please enter your current 6-digit code';
3505
+ return;
3506
+ }
3507
+
3508
+ if (newCode.length !== 6) {
3509
+ codeError = 'Please enter a new 6-digit code';
3510
+ return;
3511
+ }
3512
+
3513
+ if (newCode !== confirmNewCode) {
3514
+ codeError = 'New codes do not match';
3515
+ return;
3516
+ }
3517
+
3518
+ codeLoading = true;
3519
+ codeError = null;
3520
+ codeSuccess = null;
3521
+
3522
+ try {
3523
+ await changeSingleUserGate(oldCode, newCode);
3524
+ codeSuccess = 'Code changed successfully';
3525
+ oldCodeDigits = ['', '', '', '', '', ''];
3526
+ newCodeDigits = ['', '', '', '', '', ''];
3527
+ confirmCodeDigits = ['', '', '', '', '', ''];
3528
+ setTimeout(() => (codeSuccess = null), 3000);
3529
+ } catch (err: unknown) {
3530
+ codeError = err instanceof Error ? err.message : 'Failed to change code';
3531
+ }
3532
+
3533
+ codeLoading = false;
3534
+ }
3535
+
3536
+ // =============================================================================
3537
+ // EMAIL CHANGE FLOW
3538
+ // =============================================================================
3539
+
3540
+ /**
3541
+ * Initiate an email change — sends a confirmation link to the new address.
3542
+ * Opens the confirmation modal and starts listening for the cross-tab
3543
+ * \`BroadcastChannel\` auth event.
3544
+ * @param e - Form submit event
3545
+ */
3546
+ async function handleEmailSubmit(e: Event) {
3547
+ e.preventDefault();
3548
+ emailError = null;
3549
+ emailSuccess = null;
3550
+
3551
+ if (!newEmail.trim()) {
3552
+ emailError = 'Please enter a new email address';
3553
+ return;
3554
+ }
3555
+
3556
+ if (newEmail.trim() === currentEmail) {
3557
+ emailError = 'New email is the same as your current email';
3558
+ return;
3559
+ }
3560
+
3561
+ emailLoading = true;
3562
+
3563
+ try {
3564
+ const result = await changeSingleUserEmail(newEmail.trim());
3565
+ if (result.error) {
3566
+ emailError = result.error;
3567
+ } else if (result.confirmationRequired) {
3568
+ showEmailConfirmationModal = true;
3569
+ startResendCooldown();
3570
+ listenForEmailConfirmation();
3571
+ }
3572
+ } catch (err: unknown) {
3573
+ emailError = err instanceof Error ? err.message : 'Failed to change email';
3574
+ }
3575
+
3576
+ emailLoading = false;
3577
+ }
3578
+
3579
+ /** Start a 30-second countdown preventing repeated confirmation emails. */
3580
+ function startResendCooldown() {
3581
+ emailResendCooldown = 30;
3582
+ const interval = setInterval(() => {
3583
+ emailResendCooldown--;
3584
+ if (emailResendCooldown <= 0) clearInterval(interval);
3585
+ }, 1000);
3586
+ }
3587
+
3588
+ /** Re-send the email change confirmation (guarded by cooldown). */
3589
+ async function handleResendEmailChange() {
3590
+ if (emailResendCooldown > 0) return;
3591
+ try {
3592
+ await changeSingleUserEmail(newEmail.trim());
3593
+ startResendCooldown();
3594
+ } catch {
3595
+ // Ignore resend errors
3596
+ }
3597
+ }
1949
3598
 
1950
- // TODO: Add profile page state (form fields, device management, debug tools)
3599
+ /**
3600
+ * Listen on a \`BroadcastChannel\` for the confirmation tab to signal
3601
+ * that the user clicked the email-change link. Once received, complete
3602
+ * the email change server-side and update local state.
3603
+ */
3604
+ function listenForEmailConfirmation() {
3605
+ if (!('BroadcastChannel' in window)) return;
3606
+ const channel = new BroadcastChannel('stellar-auth-channel');
3607
+ channel.onmessage = async (event) => {
3608
+ if (
3609
+ event.data?.type === 'AUTH_CONFIRMED' &&
3610
+ event.data?.verificationType === 'email_change'
3611
+ ) {
3612
+ // Bring this tab to the foreground before the confirm tab closes
3613
+ window.focus();
3614
+ const result = await completeSingleUserEmailChange();
3615
+ if (!result.error && result.newEmail) {
3616
+ currentEmail = result.newEmail;
3617
+ emailSuccess = 'Email changed successfully';
3618
+ newEmail = '';
3619
+ setTimeout(() => (emailSuccess = null), 5000);
3620
+ } else {
3621
+ emailError = result.error || 'Failed to complete email change';
3622
+ }
3623
+ showEmailConfirmationModal = false;
3624
+ channel.close();
3625
+ }
3626
+ };
3627
+ }
3628
+
3629
+ /** Close the email confirmation modal without completing the change. */
3630
+ function dismissEmailModal() {
3631
+ showEmailConfirmationModal = false;
3632
+ }
3633
+
3634
+ // =============================================================================
3635
+ // ADMINISTRATION HANDLERS
3636
+ // =============================================================================
3637
+
3638
+ /** Toggle debug mode on/off — requires a page refresh to take full effect. */
3639
+ function toggleDebugMode() {
3640
+ debugMode = !debugMode;
3641
+ setDebugMode(debugMode);
3642
+ }
3643
+
3644
+ /** Navigate back to the main tasks view. */
3645
+ function goBack() {
3646
+ goto('/tasks');
3647
+ }
3648
+
3649
+ /**
3650
+ * Delete and recreate the local IndexedDB, then reload the page.
3651
+ * Session is preserved in localStorage so the app will re-hydrate.
3652
+ */
3653
+ async function handleResetDatabase() {
3654
+ if (
3655
+ !confirm(
3656
+ 'This will delete all local data and reload. Your data will be re-synced from the server. Continue?'
3657
+ )
3658
+ ) {
3659
+ return;
3660
+ }
3661
+ resetting = true;
3662
+ try {
3663
+ await resetDatabase();
3664
+ // Reload the page — session is preserved in localStorage, so the app
3665
+ // will re-create the DB, fetch config from Supabase, and re-hydrate.
3666
+ window.location.reload();
3667
+ } catch (err) {
3668
+ alert('Reset failed: ' + (err instanceof Error ? err.message : 'Unknown error'));
3669
+ resetting = false;
3670
+ }
3671
+ }
3672
+
3673
+ /**
3674
+ * Remove a trusted device by ID and update the local list.
3675
+ * @param id - Database ID of the trusted device row
3676
+ */
3677
+ async function handleRemoveDevice(id: string) {
3678
+ removingDeviceId = id;
3679
+ try {
3680
+ await removeTrustedDevice(id);
3681
+ trustedDevices = trustedDevices.filter((d) => d.id !== id);
3682
+ } catch {
3683
+ // Ignore errors
3684
+ }
3685
+ removingDeviceId = null;
3686
+ }
3687
+
3688
+ // =============================================================================
3689
+ // DEBUG TOOL HANDLERS
3690
+ // =============================================================================
3691
+
3692
+ /**
3693
+ * Cast \`window\` to an untyped record for accessing runtime-injected
3694
+ * debug helpers (e.g., \`__${opts.prefix}Sync\`, \`__${opts.prefix}SyncStats\`).
3695
+ * @returns The global \`window\` as a loose \`Record\`
3696
+ */
3697
+ function getDebugWindow(): Record<string, unknown> {
3698
+ return window as unknown as Record<string, unknown>;
3699
+ }
3700
+
3701
+ /** Resets the sync cursor and re-downloads all data from Supabase. */
3702
+ async function handleForceFullSync() {
3703
+ if (
3704
+ !confirm(
3705
+ 'This will reset the sync cursor and re-download all data from the server. Continue?'
3706
+ )
3707
+ )
3708
+ return;
3709
+ forceSyncing = true;
3710
+ try {
3711
+ const fn = getDebugWindow().__${opts.prefix}Sync as
3712
+ | { forceFullSync: () => Promise<void> }
3713
+ | undefined;
3714
+ if (fn?.forceFullSync) {
3715
+ await fn.forceFullSync();
3716
+ alert('Force full sync complete.');
3717
+ } else {
3718
+ alert('Debug mode must be enabled and the page refreshed to use this tool.');
3719
+ }
3720
+ } catch (err) {
3721
+ alert('Force full sync failed: ' + (err instanceof Error ? err.message : 'Unknown error'));
3722
+ }
3723
+ forceSyncing = false;
3724
+ }
3725
+
3726
+ /** Manually trigger a single push/pull sync cycle. */
3727
+ async function handleTriggerSync() {
3728
+ triggeringSyncManual = true;
3729
+ try {
3730
+ const fn = getDebugWindow().__${opts.prefix}Sync as { sync: () => Promise<void> } | undefined;
3731
+ if (fn?.sync) {
3732
+ await fn.sync();
3733
+ alert('Sync cycle complete.');
3734
+ } else {
3735
+ alert('Debug mode must be enabled and the page refreshed to use this tool.');
3736
+ }
3737
+ } catch (err) {
3738
+ alert('Sync failed: ' + (err instanceof Error ? err.message : 'Unknown error'));
3739
+ }
3740
+ triggeringSyncManual = false;
3741
+ }
3742
+
3743
+ /** Reset the sync cursor so the next cycle pulls all remote data. */
3744
+ async function handleResetSyncCursor() {
3745
+ resettingCursor = true;
3746
+ try {
3747
+ const fn = getDebugWindow().__${opts.prefix}Sync as
3748
+ | { resetSyncCursor: () => Promise<void> }
3749
+ | undefined;
3750
+ if (fn?.resetSyncCursor) {
3751
+ await fn.resetSyncCursor();
3752
+ alert('Sync cursor reset. The next sync will pull all data.');
3753
+ } else {
3754
+ alert('Debug mode must be enabled and the page refreshed to use this tool.');
3755
+ }
3756
+ } catch (err) {
3757
+ alert('Reset cursor failed: ' + (err instanceof Error ? err.message : 'Unknown error'));
3758
+ }
3759
+ resettingCursor = false;
3760
+ }
3761
+
3762
+ /** Test connectivity to Supabase and show the result in an alert. */
3763
+ async function handleCheckConnection() {
3764
+ checkingConnection = true;
3765
+ try {
3766
+ const fn = getDebugWindow().__${opts.prefix}Sync as
3767
+ | {
3768
+ checkConnection: () => Promise<{
3769
+ connected: boolean;
3770
+ error?: string;
3771
+ records?: number;
3772
+ }>;
3773
+ }
3774
+ | undefined;
3775
+ if (fn?.checkConnection) {
3776
+ const result = await fn.checkConnection();
3777
+ if (result.connected) {
3778
+ alert('Connection OK. Supabase is reachable.');
3779
+ } else {
3780
+ alert('Connection failed: ' + (result.error || 'Unknown error'));
3781
+ }
3782
+ } else {
3783
+ alert('Debug mode must be enabled and the page refreshed to use this tool.');
3784
+ }
3785
+ } catch (err) {
3786
+ alert('Connection check failed: ' + (err instanceof Error ? err.message : 'Unknown error'));
3787
+ }
3788
+ checkingConnection = false;
3789
+ }
3790
+
3791
+ /** Display current sync cursor and pending operations count in an alert. */
3792
+ function handleGetSyncStatus() {
3793
+ const fn = getDebugWindow().__${opts.prefix}Sync as
3794
+ | { getStatus: () => { cursor: unknown; pendingOps: Promise<number> } }
3795
+ | undefined;
3796
+ if (fn?.getStatus) {
3797
+ const status = fn.getStatus();
3798
+ const cursorDisplay =
3799
+ typeof status.cursor === 'object'
3800
+ ? JSON.stringify(status.cursor)
3801
+ : String(status.cursor || 'None');
3802
+ status.pendingOps.then((count: number) => {
3803
+ alert(\`Sync Status:\n\nCursor: \${cursorDisplay}\nPending operations: \${count}\`);
3804
+ });
3805
+ } else {
3806
+ alert('Debug mode must be enabled and the page refreshed to use this tool.');
3807
+ }
3808
+ }
3809
+
3810
+ /** Show the realtime WebSocket connection state and health. */
3811
+ function handleRealtimeStatus() {
3812
+ const fn = getDebugWindow().__${opts.prefix}Sync as
3813
+ | { realtimeStatus: () => { state: string; healthy: boolean } }
3814
+ | undefined;
3815
+ if (fn?.realtimeStatus) {
3816
+ const status = fn.realtimeStatus();
3817
+ alert(
3818
+ \`Realtime Status:\n\nState: \${status.state}\nHealthy: \${status.healthy ? 'Yes' : 'No'}\`
3819
+ );
3820
+ } else {
3821
+ alert('Debug mode must be enabled and the page refreshed to use this tool.');
3822
+ }
3823
+ }
3824
+
3825
+ /** Display sync cycle stats in an alert; full details logged to console. */
3826
+ function handleViewSyncStats() {
3827
+ const fn = getDebugWindow().__${opts.prefix}SyncStats as
3828
+ | (() => { totalSyncCycles: number; recentMinute: number; recent: unknown[] })
3829
+ | undefined;
3830
+ if (fn) {
3831
+ const stats = fn();
3832
+ alert(
3833
+ \`Sync Stats:\n\nTotal cycles: \${stats.totalSyncCycles}\nCycles in last minute: \${stats.recentMinute}\nRecent cycles logged to console.\`
3834
+ );
3835
+ } else {
3836
+ alert('Debug mode must be enabled and the page refreshed to use this tool.');
3837
+ }
3838
+ }
3839
+
3840
+ /** Display data-transfer / egress stats; per-table breakdown in console. */
3841
+ function handleViewEgress() {
3842
+ const fn = getDebugWindow().__${opts.prefix}Egress as
3843
+ | (() => { totalFormatted: string; totalRecords: number; sessionStart: string })
3844
+ | undefined;
3845
+ if (fn) {
3846
+ const stats = fn();
3847
+ alert(
3848
+ \`Egress Stats:\n\nTotal data transferred: \${stats.totalFormatted}\nTotal records: \${stats.totalRecords}\nSession started: \${new Date(stats.sessionStart).toLocaleString()}\n\nFull breakdown logged to console.\`
3849
+ );
3850
+ } else {
3851
+ alert('Debug mode must be enabled and the page refreshed to use this tool.');
3852
+ }
3853
+ }
3854
+
3855
+ /** Log soft-deleted record counts per table to the browser console. */
3856
+ async function handleViewTombstones() {
3857
+ viewingTombstones = true;
3858
+ try {
3859
+ const fn = getDebugWindow().__${opts.prefix}Tombstones as
3860
+ | ((opts?: { cleanup?: boolean; force?: boolean }) => Promise<void>)
3861
+ | undefined;
3862
+ if (fn) {
3863
+ await fn();
3864
+ alert('Tombstone details logged to console. Open DevTools to view.');
3865
+ } else {
3866
+ alert('Debug mode must be enabled and the page refreshed to use this tool.');
3867
+ }
3868
+ } catch (err) {
3869
+ alert('View tombstones failed: ' + (err instanceof Error ? err.message : 'Unknown error'));
3870
+ }
3871
+ viewingTombstones = false;
3872
+ }
3873
+
3874
+ /** Permanently remove old soft-deleted records from local + remote DBs. */
3875
+ async function handleCleanupTombstones() {
3876
+ if (
3877
+ !confirm(
3878
+ 'This will permanently remove old soft-deleted records from local and server databases. Continue?'
3879
+ )
3880
+ )
3881
+ return;
3882
+ cleaningTombstones = true;
3883
+ try {
3884
+ const fn = getDebugWindow().__${opts.prefix}Tombstones as
3885
+ | ((opts?: { cleanup?: boolean; force?: boolean }) => Promise<void>)
3886
+ | undefined;
3887
+ if (fn) {
3888
+ await fn({ cleanup: true });
3889
+ alert('Tombstone cleanup complete. Details logged to console.');
3890
+ } else {
3891
+ alert('Debug mode must be enabled and the page refreshed to use this tool.');
3892
+ }
3893
+ } catch (err) {
3894
+ alert('Tombstone cleanup failed: ' + (err instanceof Error ? err.message : 'Unknown error'));
3895
+ }
3896
+ cleaningTombstones = false;
3897
+ }
3898
+
3899
+ /** Dispatch a custom event that the app shell listens for to sign out on mobile. */
3900
+ function handleMobileSignOut() {
3901
+ window.dispatchEvent(new CustomEvent('${opts.prefix}:signout'));
3902
+ }
1951
3903
  </script>
1952
3904
 
3905
+ <svelte:head>
3906
+ <title>Profile - ${opts.name}</title>
3907
+ </svelte:head>
3908
+
1953
3909
  <!-- TODO: Add profile page template (forms, cards, device list, debug tools) -->
1954
3910
  `;
1955
3911
  }
@@ -2079,38 +4035,102 @@ export type { SyncStatus, AuthMode, OfflineCredentials } from '@prabhask5/stella
2079
4035
  `;
2080
4036
  }
2081
4037
  // =============================================================================
4038
+ // COMMAND ROUTING
4039
+ // =============================================================================
4040
+ /**
4041
+ * Available CLI commands. Add new entries here to register additional commands.
4042
+ */
4043
+ const COMMANDS = [
4044
+ {
4045
+ name: 'install pwa',
4046
+ usage: 'stellar-engine install pwa',
4047
+ description: 'Scaffold a complete offline-first SvelteKit PWA project'
4048
+ }
4049
+ ];
4050
+ /**
4051
+ * Print the help screen listing all available commands.
4052
+ */
4053
+ function printHelp() {
4054
+ console.log();
4055
+ console.log(doubleBoxWithHeader([` ${bold('\u2726 stellar-engine CLI \u2726')} `], ['Available commands: ']));
4056
+ console.log();
4057
+ for (const cmd of COMMANDS) {
4058
+ console.log(` ${cyan(cmd.usage)}`);
4059
+ console.log(` ${dim(cmd.description)}`);
4060
+ console.log();
4061
+ }
4062
+ console.log(` Run a command to get started.\n`);
4063
+ }
4064
+ /**
4065
+ * Route CLI arguments to the appropriate command handler.
4066
+ * Prints help and exits if the command is not recognised.
4067
+ */
4068
+ function routeCommand() {
4069
+ const args = process.argv.slice(2);
4070
+ const command = args.slice(0, 2).join(' ');
4071
+ if (command === 'install pwa') {
4072
+ main().catch((err) => {
4073
+ console.error('Error:', err);
4074
+ process.exit(1);
4075
+ });
4076
+ return;
4077
+ }
4078
+ /* Unrecognised command or no args — show help */
4079
+ printHelp();
4080
+ process.exit(args.length === 0 ? 0 : 1);
4081
+ }
4082
+ // =============================================================================
2082
4083
  // MAIN FUNCTION
2083
4084
  // =============================================================================
4085
+ /**
4086
+ * Write a group of files quietly and return the count written.
4087
+ *
4088
+ * @param entries - Array of `[relativePath, content]` pairs.
4089
+ * @param cwd - The current working directory.
4090
+ * @param createdFiles - Accumulator for newly-created file paths.
4091
+ * @param skippedFiles - Accumulator for skipped file paths.
4092
+ * @returns The number of files in the group.
4093
+ */
4094
+ function writeGroup(entries, cwd, createdFiles, skippedFiles) {
4095
+ for (const [rel, content] of entries) {
4096
+ writeIfMissing(join(cwd, rel), content, createdFiles, skippedFiles, true);
4097
+ }
4098
+ return entries.length;
4099
+ }
2084
4100
  /**
2085
4101
  * Main entry point for the CLI scaffolding tool.
2086
4102
  *
2087
4103
  * **Execution flow:**
2088
- * 1. Parse CLI arguments into {@link InstallOptions}.
4104
+ * 1. Run interactive walkthrough to collect {@link InstallOptions}.
2089
4105
  * 2. Write `package.json` (if missing).
2090
4106
  * 3. Run `npm install` to fetch dependencies.
2091
- * 4. Write all template files (config, routes, components, assets, docs).
4107
+ * 4. Write all template files by category with animated progress.
2092
4108
  * 5. Initialise Husky and write the pre-commit hook.
2093
- * 6. Print a summary of created/skipped files and next steps.
4109
+ * 6. Print a styled summary of created/skipped files and next steps.
2094
4110
  *
2095
4111
  * @returns A promise that resolves when scaffolding is complete.
2096
4112
  *
2097
4113
  * @throws {Error} If `npm install` or `npx husky init` fails.
2098
4114
  */
2099
4115
  async function main() {
2100
- const opts = parseArgs(process.argv);
4116
+ const opts = await runInteractiveSetup();
2101
4117
  const cwd = process.cwd();
2102
- console.log(`\n\u2728 stellar-engine install pwa\n Creating ${opts.name}...\n`);
2103
4118
  const createdFiles = [];
2104
4119
  const skippedFiles = [];
2105
4120
  // 1. Write package.json
2106
- writeIfMissing(join(cwd, 'package.json'), generatePackageJson(opts), createdFiles, skippedFiles);
4121
+ let sp = createSpinner('Writing package.json');
4122
+ writeIfMissing(join(cwd, 'package.json'), generatePackageJson(opts), createdFiles, skippedFiles, true);
4123
+ sp.succeed('Writing package.json');
2107
4124
  // 2. Run npm install
2108
- console.log('Installing dependencies...');
4125
+ sp = createSpinner('Installing dependencies...');
4126
+ sp.stop();
4127
+ console.log(` ${cyan(SPINNER_FRAMES[0])} Installing dependencies...\n`);
2109
4128
  execSync('npm install', { stdio: 'inherit', cwd });
2110
- // 3. Write all template files
4129
+ console.log(`\n ${green('\u2713')} Installing dependencies`);
4130
+ // 3. Write all template files by category
2111
4131
  const firstLetter = opts.shortName.charAt(0).toUpperCase();
2112
- const files = [
2113
- // Config files
4132
+ /* ── Config files ── */
4133
+ const configFiles = [
2114
4134
  ['vite.config.ts', generateViteConfig(opts)],
2115
4135
  ['tsconfig.json', generateTsconfig()],
2116
4136
  ['svelte.config.js', generateSvelteConfig(opts)],
@@ -2118,15 +4138,24 @@ async function main() {
2118
4138
  ['.prettierrc', generatePrettierrc()],
2119
4139
  ['.prettierignore', generatePrettierignore()],
2120
4140
  ['knip.json', generateKnipJson()],
2121
- ['.gitignore', generateGitignore()],
2122
- // Documentation
4141
+ ['.gitignore', generateGitignore()]
4142
+ ];
4143
+ sp = createSpinner('Config files');
4144
+ const configCount = writeGroup(configFiles, cwd, createdFiles, skippedFiles);
4145
+ sp.succeed(`Config files ${dim(`${configCount} files`)}`);
4146
+ /* ── Documentation ── */
4147
+ const docFiles = [
2123
4148
  ['README.md', generateReadme(opts)],
2124
4149
  ['ARCHITECTURE.md', generateArchitecture(opts)],
2125
- ['FRAMEWORKS.md', generateFrameworks()],
2126
- // Static assets
4150
+ ['FRAMEWORKS.md', generateFrameworks()]
4151
+ ];
4152
+ sp = createSpinner('Documentation');
4153
+ const docCount = writeGroup(docFiles, cwd, createdFiles, skippedFiles);
4154
+ sp.succeed(`Documentation ${dim(`${docCount} files`)}`);
4155
+ /* ── Static assets ── */
4156
+ const staticFiles = [
2127
4157
  ['static/manifest.json', generateManifest(opts)],
2128
4158
  ['static/offline.html', generateOfflineHtml(opts)],
2129
- // Placeholder icons
2130
4159
  ['static/icons/app.svg', generatePlaceholderSvg('#6c5ce7', firstLetter)],
2131
4160
  ['static/icons/app-dark.svg', generatePlaceholderSvg('#1a1a2e', firstLetter)],
2132
4161
  ['static/icons/maskable.svg', generatePlaceholderSvg('#6c5ce7', firstLetter)],
@@ -2134,64 +4163,75 @@ async function main() {
2134
4163
  ['static/icons/monochrome.svg', generateMonochromeSvg(firstLetter)],
2135
4164
  ['static/icons/splash.svg', generateSplashSvg(opts.shortName)],
2136
4165
  ['static/icons/apple-touch.svg', generatePlaceholderSvg('#6c5ce7', firstLetter)],
2137
- // Email placeholders
2138
4166
  ['static/change-email.html', generateEmailPlaceholder('Change Email')],
2139
4167
  ['static/device-verification-email.html', generateEmailPlaceholder('Device Verification')],
2140
4168
  ['static/signup-email.html', generateEmailPlaceholder('Signup Email')],
2141
- // Supabase schema
2142
- ['supabase-schema.sql', generateSupabaseSchema(opts)],
2143
- // Source files
4169
+ ['supabase-schema.sql', generateSupabaseSchema(opts)]
4170
+ ];
4171
+ sp = createSpinner('Static assets');
4172
+ const staticCount = writeGroup(staticFiles, cwd, createdFiles, skippedFiles);
4173
+ sp.succeed(`Static assets ${dim(`${staticCount} files`)}`);
4174
+ /* ── Source files ── */
4175
+ const sourceFiles = [
2144
4176
  ['src/app.html', generateAppHtml(opts)],
2145
- ['src/app.d.ts', generateAppDts(opts)],
2146
- // Route files
4177
+ ['src/app.d.ts', generateAppDts(opts)]
4178
+ ];
4179
+ sp = createSpinner('Source files');
4180
+ const sourceCount = writeGroup(sourceFiles, cwd, createdFiles, skippedFiles);
4181
+ sp.succeed(`Source files ${dim(`${sourceCount} files`)}`);
4182
+ /* ── Route files ── */
4183
+ const routeFiles = [
2147
4184
  ['src/routes/+layout.ts', generateRootLayoutTs(opts)],
2148
- ['src/routes/+layout.svelte', generateRootLayoutSvelte()],
2149
- ['src/routes/+page.svelte', generateHomePage()],
2150
- ['src/routes/+error.svelte', generateErrorPage()],
4185
+ ['src/routes/+layout.svelte', generateRootLayoutSvelte(opts)],
4186
+ ['src/routes/+page.svelte', generateHomePage(opts)],
4187
+ ['src/routes/+error.svelte', generateErrorPage(opts)],
2151
4188
  ['src/routes/setup/+page.ts', generateSetupPageTs()],
2152
- ['src/routes/setup/+page.svelte', generateSetupPageSvelte()],
2153
- ['src/routes/policy/+page.svelte', generatePolicyPage()],
2154
- ['src/routes/login/+page.svelte', generateLoginPage()],
2155
- ['src/routes/confirm/+page.svelte', generateConfirmPage()],
4189
+ ['src/routes/setup/+page.svelte', generateSetupPageSvelte(opts)],
4190
+ ['src/routes/policy/+page.svelte', generatePolicyPage(opts)],
4191
+ ['src/routes/login/+page.svelte', generateLoginPage(opts)],
4192
+ ['src/routes/confirm/+page.svelte', generateConfirmPage(opts)],
2156
4193
  ['src/routes/api/config/+server.ts', generateConfigServer()],
2157
4194
  ['src/routes/api/setup/deploy/+server.ts', generateDeployServer()],
2158
4195
  ['src/routes/api/setup/validate/+server.ts', generateValidateServer()],
2159
4196
  ['src/routes/[...catchall]/+page.ts', generateCatchallPage()],
2160
4197
  ['src/routes/(protected)/+layout.ts', generateProtectedLayoutTs()],
2161
4198
  ['src/routes/(protected)/+layout.svelte', generateProtectedLayoutSvelte()],
2162
- ['src/routes/(protected)/profile/+page.svelte', generateProfilePage()],
4199
+ ['src/routes/(protected)/profile/+page.svelte', generateProfilePage(opts)]
4200
+ ];
4201
+ sp = createSpinner('Route files');
4202
+ const routeCount = writeGroup(routeFiles, cwd, createdFiles, skippedFiles);
4203
+ sp.succeed(`Route files ${dim(`${routeCount} files`)}`);
4204
+ /* ── Library & components ── */
4205
+ const libFiles = [
2163
4206
  ['src/lib/types.ts', generateAppTypes()],
2164
- // Component files
2165
4207
  ['src/lib/components/UpdatePrompt.svelte', generateUpdatePromptComponent()]
2166
4208
  ];
2167
- for (const [relativePath, content] of files) {
2168
- writeIfMissing(join(cwd, relativePath), content, createdFiles, skippedFiles);
2169
- }
4209
+ sp = createSpinner('Library & components');
4210
+ const libCount = writeGroup(libFiles, cwd, createdFiles, skippedFiles);
4211
+ sp.succeed(`Library & components ${dim(`${libCount} files`)}`);
2170
4212
  // 4. Set up husky
2171
- console.log('Setting up husky...');
2172
- execSync('npx husky init', { stdio: 'inherit', cwd });
2173
- /* Overwrite the default pre-commit (husky init creates one with "npm test") */
4213
+ sp = createSpinner('Git hooks');
4214
+ execSync('npx husky init', { stdio: 'pipe', cwd });
2174
4215
  const preCommitPath = join(cwd, '.husky/pre-commit');
2175
4216
  writeFileSync(preCommitPath, generateHuskyPreCommit(), 'utf-8');
2176
4217
  createdFiles.push('.husky/pre-commit');
2177
- console.log(' [write] .husky/pre-commit');
2178
- // 5. Print summary
2179
- console.log(`\n\u2705 Done! Created ${createdFiles.length} files.`);
2180
- if (skippedFiles.length > 0) {
2181
- console.log(` Skipped ${skippedFiles.length} existing files.`);
2182
- }
4218
+ sp.succeed(`Git hooks ${dim('1 file')}`);
4219
+ // 5. Print final summary
4220
+ console.log();
4221
+ console.log(doubleBoxWithHeader([` ${green(bold('\u2713 Setup complete!'))} `], [
4222
+ `Created: ${bold(String(createdFiles.length))} files${' '.repeat(34 - String(createdFiles.length).length)}`,
4223
+ `Skipped: ${bold(String(skippedFiles.length))} files${' '.repeat(34 - String(skippedFiles.length).length)}`
4224
+ ]));
2183
4225
  console.log(`
2184
- Next steps:
2185
- 1. Set up Supabase and add .env with PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY and PUBLIC_SUPABASE_URL
2186
- 2. Run supabase-schema.sql in the Supabase SQL Editor
2187
- 3. Add your app icons: static/favicon.png, static/icon-192.png, static/icon-512.png
2188
- 4. Start building: npm run dev`);
4226
+ ${bold('Next steps:')}
4227
+ 1. Set up Supabase and add .env with your keys
4228
+ 2. Run supabase-schema.sql in Supabase SQL Editor
4229
+ 3. Add app icons in static/icons/
4230
+ 4. Start building: ${cyan('npm run dev')}
4231
+ `);
2189
4232
  }
2190
4233
  // =============================================================================
2191
4234
  // RUN
2192
4235
  // =============================================================================
2193
- main().catch((err) => {
2194
- console.error('Error:', err);
2195
- process.exit(1);
2196
- });
4236
+ routeCommand();
2197
4237
  //# sourceMappingURL=install-pwa.js.map