@prabhask5/stellar-engine 1.1.7 → 1.1.9

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 (191) hide show
  1. package/README.md +12 -18
  2. package/dist/actions/remoteChange.d.ts +143 -18
  3. package/dist/actions/remoteChange.d.ts.map +1 -1
  4. package/dist/actions/remoteChange.js +182 -58
  5. package/dist/actions/remoteChange.js.map +1 -1
  6. package/dist/actions/truncateTooltip.d.ts +26 -12
  7. package/dist/actions/truncateTooltip.d.ts.map +1 -1
  8. package/dist/actions/truncateTooltip.js +89 -34
  9. package/dist/actions/truncateTooltip.js.map +1 -1
  10. package/dist/auth/crypto.d.ts +35 -8
  11. package/dist/auth/crypto.d.ts.map +1 -1
  12. package/dist/auth/crypto.js +38 -10
  13. package/dist/auth/crypto.js.map +1 -1
  14. package/dist/auth/deviceVerification.d.ts +236 -20
  15. package/dist/auth/deviceVerification.d.ts.map +1 -1
  16. package/dist/auth/deviceVerification.js +293 -40
  17. package/dist/auth/deviceVerification.js.map +1 -1
  18. package/dist/auth/displayUtils.d.ts +98 -0
  19. package/dist/auth/displayUtils.d.ts.map +1 -0
  20. package/dist/auth/displayUtils.js +133 -0
  21. package/dist/auth/displayUtils.js.map +1 -0
  22. package/dist/auth/loginGuard.d.ts +103 -16
  23. package/dist/auth/loginGuard.d.ts.map +1 -1
  24. package/dist/auth/loginGuard.js +163 -76
  25. package/dist/auth/loginGuard.js.map +1 -1
  26. package/dist/auth/offlineCredentials.d.ts +88 -24
  27. package/dist/auth/offlineCredentials.d.ts.map +1 -1
  28. package/dist/auth/offlineCredentials.js +114 -73
  29. package/dist/auth/offlineCredentials.js.map +1 -1
  30. package/dist/auth/offlineSession.d.ts +83 -9
  31. package/dist/auth/offlineSession.d.ts.map +1 -1
  32. package/dist/auth/offlineSession.js +104 -13
  33. package/dist/auth/offlineSession.js.map +1 -1
  34. package/dist/auth/resolveAuthState.d.ts +67 -9
  35. package/dist/auth/resolveAuthState.d.ts.map +1 -1
  36. package/dist/auth/resolveAuthState.js +125 -71
  37. package/dist/auth/resolveAuthState.js.map +1 -1
  38. package/dist/auth/singleUser.d.ts +390 -37
  39. package/dist/auth/singleUser.d.ts.map +1 -1
  40. package/dist/auth/singleUser.js +504 -103
  41. package/dist/auth/singleUser.js.map +1 -1
  42. package/dist/bin/install-pwa.d.ts +18 -2
  43. package/dist/bin/install-pwa.d.ts.map +1 -1
  44. package/dist/bin/install-pwa.js +2624 -77
  45. package/dist/bin/install-pwa.js.map +1 -1
  46. package/dist/config.d.ts +131 -15
  47. package/dist/config.d.ts.map +1 -1
  48. package/dist/config.js +87 -9
  49. package/dist/config.js.map +1 -1
  50. package/dist/conflicts.d.ts +246 -23
  51. package/dist/conflicts.d.ts.map +1 -1
  52. package/dist/conflicts.js +495 -46
  53. package/dist/conflicts.js.map +1 -1
  54. package/dist/data.d.ts +338 -18
  55. package/dist/data.d.ts.map +1 -1
  56. package/dist/data.js +385 -34
  57. package/dist/data.js.map +1 -1
  58. package/dist/database.d.ts +72 -14
  59. package/dist/database.d.ts.map +1 -1
  60. package/dist/database.js +120 -29
  61. package/dist/database.js.map +1 -1
  62. package/dist/debug.d.ts +77 -1
  63. package/dist/debug.d.ts.map +1 -1
  64. package/dist/debug.js +88 -1
  65. package/dist/debug.js.map +1 -1
  66. package/dist/deviceId.d.ts +38 -7
  67. package/dist/deviceId.d.ts.map +1 -1
  68. package/dist/deviceId.js +68 -10
  69. package/dist/deviceId.js.map +1 -1
  70. package/dist/engine.d.ts +175 -3
  71. package/dist/engine.d.ts.map +1 -1
  72. package/dist/engine.js +756 -109
  73. package/dist/engine.js.map +1 -1
  74. package/dist/entries/actions.d.ts +13 -0
  75. package/dist/entries/actions.d.ts.map +1 -1
  76. package/dist/entries/actions.js +26 -1
  77. package/dist/entries/actions.js.map +1 -1
  78. package/dist/entries/auth.d.ts +15 -4
  79. package/dist/entries/auth.d.ts.map +1 -1
  80. package/dist/entries/auth.js +47 -4
  81. package/dist/entries/auth.js.map +1 -1
  82. package/dist/entries/config.d.ts +12 -0
  83. package/dist/entries/config.d.ts.map +1 -1
  84. package/dist/entries/config.js +18 -1
  85. package/dist/entries/config.js.map +1 -1
  86. package/dist/entries/kit.d.ts +11 -0
  87. package/dist/entries/kit.d.ts.map +1 -1
  88. package/dist/entries/kit.js +52 -2
  89. package/dist/entries/kit.js.map +1 -1
  90. package/dist/entries/stores.d.ts +11 -0
  91. package/dist/entries/stores.d.ts.map +1 -1
  92. package/dist/entries/stores.js +43 -2
  93. package/dist/entries/stores.js.map +1 -1
  94. package/dist/entries/types.d.ts +10 -1
  95. package/dist/entries/types.d.ts.map +1 -1
  96. package/dist/entries/types.js +10 -0
  97. package/dist/entries/types.js.map +1 -1
  98. package/dist/entries/utils.d.ts +6 -0
  99. package/dist/entries/utils.d.ts.map +1 -1
  100. package/dist/entries/utils.js +22 -1
  101. package/dist/entries/utils.js.map +1 -1
  102. package/dist/entries/vite.d.ts +17 -0
  103. package/dist/entries/vite.d.ts.map +1 -1
  104. package/dist/entries/vite.js +24 -1
  105. package/dist/entries/vite.js.map +1 -1
  106. package/dist/index.d.ts +32 -4
  107. package/dist/index.d.ts.map +1 -1
  108. package/dist/index.js +166 -23
  109. package/dist/index.js.map +1 -1
  110. package/dist/kit/auth.d.ts +60 -5
  111. package/dist/kit/auth.d.ts.map +1 -1
  112. package/dist/kit/auth.js +45 -4
  113. package/dist/kit/auth.js.map +1 -1
  114. package/dist/kit/confirm.d.ts +93 -12
  115. package/dist/kit/confirm.d.ts.map +1 -1
  116. package/dist/kit/confirm.js +103 -16
  117. package/dist/kit/confirm.js.map +1 -1
  118. package/dist/kit/loads.d.ts +148 -23
  119. package/dist/kit/loads.d.ts.map +1 -1
  120. package/dist/kit/loads.js +136 -28
  121. package/dist/kit/loads.js.map +1 -1
  122. package/dist/kit/server.d.ts +142 -10
  123. package/dist/kit/server.d.ts.map +1 -1
  124. package/dist/kit/server.js +158 -15
  125. package/dist/kit/server.js.map +1 -1
  126. package/dist/kit/sw.d.ts +152 -23
  127. package/dist/kit/sw.d.ts.map +1 -1
  128. package/dist/kit/sw.js +182 -26
  129. package/dist/kit/sw.js.map +1 -1
  130. package/dist/queue.d.ts +274 -0
  131. package/dist/queue.d.ts.map +1 -1
  132. package/dist/queue.js +556 -38
  133. package/dist/queue.js.map +1 -1
  134. package/dist/realtime.d.ts +241 -27
  135. package/dist/realtime.d.ts.map +1 -1
  136. package/dist/realtime.js +633 -109
  137. package/dist/realtime.js.map +1 -1
  138. package/dist/runtime/runtimeConfig.d.ts +91 -8
  139. package/dist/runtime/runtimeConfig.d.ts.map +1 -1
  140. package/dist/runtime/runtimeConfig.js +146 -19
  141. package/dist/runtime/runtimeConfig.js.map +1 -1
  142. package/dist/stores/authState.d.ts +150 -11
  143. package/dist/stores/authState.d.ts.map +1 -1
  144. package/dist/stores/authState.js +169 -17
  145. package/dist/stores/authState.js.map +1 -1
  146. package/dist/stores/network.d.ts +39 -0
  147. package/dist/stores/network.d.ts.map +1 -1
  148. package/dist/stores/network.js +169 -16
  149. package/dist/stores/network.js.map +1 -1
  150. package/dist/stores/remoteChanges.d.ts +327 -52
  151. package/dist/stores/remoteChanges.d.ts.map +1 -1
  152. package/dist/stores/remoteChanges.js +337 -75
  153. package/dist/stores/remoteChanges.js.map +1 -1
  154. package/dist/stores/sync.d.ts +130 -0
  155. package/dist/stores/sync.d.ts.map +1 -1
  156. package/dist/stores/sync.js +167 -7
  157. package/dist/stores/sync.js.map +1 -1
  158. package/dist/supabase/auth.d.ts +186 -46
  159. package/dist/supabase/auth.d.ts.map +1 -1
  160. package/dist/supabase/auth.js +238 -190
  161. package/dist/supabase/auth.js.map +1 -1
  162. package/dist/supabase/client.d.ts +79 -6
  163. package/dist/supabase/client.d.ts.map +1 -1
  164. package/dist/supabase/client.js +158 -15
  165. package/dist/supabase/client.js.map +1 -1
  166. package/dist/supabase/validate.d.ts +101 -7
  167. package/dist/supabase/validate.d.ts.map +1 -1
  168. package/dist/supabase/validate.js +117 -8
  169. package/dist/supabase/validate.js.map +1 -1
  170. package/dist/sw/build/vite-plugin.d.ts +55 -10
  171. package/dist/sw/build/vite-plugin.d.ts.map +1 -1
  172. package/dist/sw/build/vite-plugin.js +77 -18
  173. package/dist/sw/build/vite-plugin.js.map +1 -1
  174. package/dist/sw/sw.js +99 -44
  175. package/dist/types.d.ts +150 -26
  176. package/dist/types.d.ts.map +1 -1
  177. package/dist/types.js +12 -10
  178. package/dist/types.js.map +1 -1
  179. package/dist/utils.d.ts +55 -13
  180. package/dist/utils.d.ts.map +1 -1
  181. package/dist/utils.js +83 -22
  182. package/dist/utils.js.map +1 -1
  183. package/package.json +1 -1
  184. package/dist/auth/admin.d.ts +0 -12
  185. package/dist/auth/admin.d.ts.map +0 -1
  186. package/dist/auth/admin.js +0 -26
  187. package/dist/auth/admin.js.map +0 -1
  188. package/dist/auth/offlineLogin.d.ts +0 -34
  189. package/dist/auth/offlineLogin.d.ts.map +0 -1
  190. package/dist/auth/offlineLogin.js +0 -75
  191. package/dist/auth/offlineLogin.js.map +0 -1
@@ -2,8 +2,24 @@
2
2
  /**
3
3
  * @fileoverview CLI script that scaffolds a PWA SvelteKit project using stellar-engine.
4
4
  *
5
- * Usage:
6
- * stellar-engine install pwa --name "App Name" --short_name "Short" --prefix "myprefix" [--description "..."]
5
+ * Generates a complete project structure including:
6
+ * - Build configuration (Vite, TypeScript, SvelteKit, ESLint, Prettier, Knip)
7
+ * - PWA assets (manifest, offline page, placeholder icons)
8
+ * - SvelteKit routes (home, login, setup wizard, profile, error, confirm)
9
+ * - API endpoints (config, deploy, validate)
10
+ * - Supabase database schema
11
+ * - Git hooks via Husky
12
+ *
13
+ * Files are written non-destructively: existing files are skipped, not overwritten.
14
+ *
15
+ * @example
16
+ * ```bash
17
+ * stellar-engine install pwa --name "App Name" --short_name "Short" --prefix "myprefix" [--description "..."]
18
+ * ```
19
+ *
20
+ * @see {@link main} for the entry point
21
+ * @see {@link parseArgs} for CLI argument parsing
22
+ * @see {@link writeIfMissing} for the non-destructive file write strategy
7
23
  */
8
24
  import { writeFileSync, existsSync, mkdirSync } from 'fs';
9
25
  import { join } from 'path';
@@ -12,7 +28,15 @@ import { execSync } from 'child_process';
12
28
  // HELPERS
13
29
  // =============================================================================
14
30
  /**
15
- * Writes a file only if it doesn't already exist. Returns whether the file was created.
31
+ * Writes a file only if it doesn't already exist (non-destructive).
32
+ *
33
+ * Creates parent directories as needed. Tracks created and skipped files
34
+ * in the provided arrays for the final summary output.
35
+ *
36
+ * @param filePath - Absolute path to the target file.
37
+ * @param content - The file content to write.
38
+ * @param createdFiles - Accumulator for newly-created file paths (relative).
39
+ * @param skippedFiles - Accumulator for skipped file paths (relative).
16
40
  */
17
41
  function writeIfMissing(filePath, content, createdFiles, skippedFiles) {
18
42
  const relPath = filePath.replace(process.cwd() + '/', '');
@@ -33,6 +57,19 @@ function writeIfMissing(filePath, content, createdFiles, skippedFiles) {
33
57
  // =============================================================================
34
58
  // ARG PARSING
35
59
  // =============================================================================
60
+ /**
61
+ * Parse command-line arguments into an {@link InstallOptions} object.
62
+ *
63
+ * Expects the format:
64
+ * `stellar-engine install pwa --name "..." --short_name "..." --prefix "..." [--description "..."]`
65
+ *
66
+ * Exits the process with a usage message if required arguments are missing.
67
+ *
68
+ * @param argv - The raw `process.argv` array.
69
+ * @returns The parsed {@link InstallOptions}.
70
+ *
71
+ * @throws {SystemExit} Exits with code 1 if `--name`, `--short_name`, or `--prefix` are missing.
72
+ */
36
73
  function parseArgs(argv) {
37
74
  const args = argv.slice(2);
38
75
  if (args[0] !== 'install' || args[1] !== 'pwa') {
@@ -64,12 +101,26 @@ function parseArgs(argv) {
64
101
  'Usage: stellar-engine install pwa --name "App Name" --short_name "Short" --prefix "myprefix" [--description "..."]');
65
102
  process.exit(1);
66
103
  }
104
+ /* Derive kebab-case name for package.json from the full name */
67
105
  const kebabName = name.toLowerCase().replace(/\s+/g, '-');
68
106
  return { name, shortName, prefix, description, kebabName };
69
107
  }
70
108
  // =============================================================================
71
109
  // TEMPLATE GENERATORS
72
110
  // =============================================================================
111
+ // ---------------------------------------------------------------------------
112
+ // PACKAGE.JSON GENERATOR
113
+ // ---------------------------------------------------------------------------
114
+ /**
115
+ * Generate a `package.json` with all dependencies and scripts pre-configured
116
+ * for a stellar-engine PWA project.
117
+ *
118
+ * Includes dev tooling (ESLint, Prettier, Knip, Husky, svelte-check) and
119
+ * the `@prabhask5/stellar-engine` runtime dependency.
120
+ *
121
+ * @param opts - The install options containing the kebab-cased project name.
122
+ * @returns The JSON string for `package.json`.
123
+ */
73
124
  function generatePackageJson(opts) {
74
125
  return (JSON.stringify({
75
126
  name: opts.kebabName,
@@ -115,6 +166,16 @@ function generatePackageJson(opts) {
115
166
  type: 'module'
116
167
  }, null, 2) + '\n');
117
168
  }
169
+ // ---------------------------------------------------------------------------
170
+ // VITE CONFIG GENERATOR
171
+ // ---------------------------------------------------------------------------
172
+ /**
173
+ * Generate a Vite config with SvelteKit and stellarPWA plugins, plus
174
+ * manual chunk-splitting for heavy vendor libraries.
175
+ *
176
+ * @param opts - The install options containing `prefix` and `name`.
177
+ * @returns The TypeScript source for `vite.config.ts`.
178
+ */
118
179
  function generateViteConfig(opts) {
119
180
  return `/**
120
181
  * @fileoverview Vite build configuration for the ${opts.shortName} PWA.
@@ -128,10 +189,18 @@ function generateViteConfig(opts) {
128
189
  * into their own bundles for long-term caching
129
190
  */
130
191
 
192
+ // =============================================================================
193
+ // IMPORTS
194
+ // =============================================================================
195
+
131
196
  import { sveltekit } from '@sveltejs/kit/vite';
132
197
  import { stellarPWA } from '@prabhask5/stellar-engine/vite';
133
198
  import { defineConfig } from 'vite';
134
199
 
200
+ // =============================================================================
201
+ // VITE CONFIGURATION
202
+ // =============================================================================
203
+
135
204
  export default defineConfig({
136
205
  plugins: [
137
206
  sveltekit(),
@@ -140,21 +209,35 @@ export default defineConfig({
140
209
  build: {
141
210
  rollupOptions: {
142
211
  output: {
212
+ /* ── Vendor chunk isolation ── */
143
213
  manualChunks: (id) => {
144
214
  if (id.includes('node_modules')) {
215
+ /** Supabase auth + realtime — ~100 KB gzipped */
145
216
  if (id.includes('@supabase')) return 'vendor-supabase';
217
+ /** Dexie (IndexedDB wrapper) — offline-first storage layer */
146
218
  if (id.includes('dexie')) return 'vendor-dexie';
147
219
  }
148
220
  }
149
221
  }
150
222
  },
223
+ /** Reduce noise — only warn for chunks above 500 KB */
151
224
  chunkSizeWarningLimit: 500,
225
+ /** esbuild is faster than terser and produces comparable output */
152
226
  minify: 'esbuild',
227
+ /** Target modern browsers → enables smaller output (no legacy polyfills) */
153
228
  target: 'es2020'
154
229
  }
155
230
  });
156
231
  `;
157
232
  }
233
+ // ---------------------------------------------------------------------------
234
+ // TSCONFIG GENERATOR
235
+ // ---------------------------------------------------------------------------
236
+ /**
237
+ * Generate a `tsconfig.json` extending SvelteKit's generated config.
238
+ *
239
+ * @returns The JSON string for `tsconfig.json`.
240
+ */
158
241
  function generateTsconfig() {
159
242
  return (JSON.stringify({
160
243
  extends: './.svelte-kit/tsconfig.json',
@@ -171,6 +254,15 @@ function generateTsconfig() {
171
254
  }
172
255
  }, null, 2) + '\n');
173
256
  }
257
+ // ---------------------------------------------------------------------------
258
+ // SVELTE CONFIG GENERATOR
259
+ // ---------------------------------------------------------------------------
260
+ /**
261
+ * Generate a `svelte.config.js` with adapter-auto and vitePreprocess.
262
+ *
263
+ * @param opts - The install options containing `shortName`.
264
+ * @returns The JavaScript source for `svelte.config.js`.
265
+ */
174
266
  function generateSvelteConfig(opts) {
175
267
  return `/**
176
268
  * @fileoverview SvelteKit project configuration for ${opts.shortName}.
@@ -213,6 +305,15 @@ const config = {
213
305
  export default config;
214
306
  `;
215
307
  }
308
+ // ---------------------------------------------------------------------------
309
+ // MANIFEST GENERATOR
310
+ // ---------------------------------------------------------------------------
311
+ /**
312
+ * Generate a PWA `manifest.json` with icons, theme colours, and display settings.
313
+ *
314
+ * @param opts - The install options containing `name`, `shortName`, and `description`.
315
+ * @returns The JSON string for `static/manifest.json`.
316
+ */
216
317
  function generateManifest(opts) {
217
318
  return (JSON.stringify({
218
319
  name: opts.name,
@@ -243,6 +344,15 @@ function generateManifest(opts) {
243
344
  prefer_related_applications: false
244
345
  }, null, 2) + '\n');
245
346
  }
347
+ // ---------------------------------------------------------------------------
348
+ // APP.D.TS GENERATOR
349
+ // ---------------------------------------------------------------------------
350
+ /**
351
+ * Generate the SvelteKit ambient type declarations file (`src/app.d.ts`).
352
+ *
353
+ * @param opts - The install options containing `shortName`.
354
+ * @returns The TypeScript source for `src/app.d.ts`.
355
+ */
246
356
  function generateAppDts(opts) {
247
357
  return `/**
248
358
  * @fileoverview Ambient type declarations for the ${opts.shortName} SvelteKit application.
@@ -289,6 +399,17 @@ declare global {
289
399
  export {};
290
400
  `;
291
401
  }
402
+ // ---------------------------------------------------------------------------
403
+ // APP.HTML GENERATOR
404
+ // ---------------------------------------------------------------------------
405
+ /**
406
+ * Generate the root HTML shell (`src/app.html`) with PWA meta tags, iOS
407
+ * configuration, landscape blocker, gesture prevention, and deferred
408
+ * service worker registration.
409
+ *
410
+ * @param opts - The install options containing `name`, `shortName`, and `description`.
411
+ * @returns The HTML source for `src/app.html`.
412
+ */
292
413
  function generateAppHtml(opts) {
293
414
  return `<!doctype html>
294
415
  <html lang="en">
@@ -540,12 +661,50 @@ function generateAppHtml(opts) {
540
661
  </html>
541
662
  `;
542
663
  }
664
+ // ---------------------------------------------------------------------------
665
+ // README GENERATOR
666
+ // ---------------------------------------------------------------------------
667
+ /**
668
+ * Generate a minimal `README.md` with project name, links to architecture
669
+ * docs, and a quick-reference script table.
670
+ *
671
+ * @param opts - The install options containing `name`.
672
+ * @returns The Markdown source for `README.md`.
673
+ */
543
674
  function generateReadme(opts) {
544
675
  return `# ${opts.name}
545
676
 
546
677
  > See [ARCHITECTURE.md](./ARCHITECTURE.md) for project structure.
547
678
  > See [FRAMEWORKS.md](./FRAMEWORKS.md) for framework decisions.
548
679
 
680
+ ## Install as an App
681
+
682
+ This is a PWA (Progressive Web App) — install it on any device for quick access and an app-like experience.
683
+
684
+ ### iOS (Safari)
685
+
686
+ 1. Open the app in **Safari**.
687
+ 2. Tap the **Share** button (square with arrow).
688
+ 3. Scroll down and tap **Add to Home Screen**.
689
+ 4. Tap **Add**.
690
+
691
+ ### Android (Chrome)
692
+
693
+ 1. Open the app in **Chrome**.
694
+ 2. Tap the **three-dot menu** (top right).
695
+ 3. Tap **Add to Home screen** or **Install app**.
696
+ 4. Confirm the installation.
697
+
698
+ ### Desktop (Chrome / Edge)
699
+
700
+ 1. Open the app in your browser.
701
+ 2. Click the **install icon** in the address bar (or look for an install prompt).
702
+ 3. Click **Install**.
703
+
704
+ Once installed, the app runs as a standalone window with full offline support.
705
+
706
+ ---
707
+
549
708
  ## Getting Started
550
709
 
551
710
  \`\`\`bash
@@ -566,6 +725,15 @@ npm run dev
566
725
  | \`npm run validate\` | Full validation (check + lint + dead-code) |
567
726
  `;
568
727
  }
728
+ // ---------------------------------------------------------------------------
729
+ // ARCHITECTURE DOC GENERATOR
730
+ // ---------------------------------------------------------------------------
731
+ /**
732
+ * Generate an `ARCHITECTURE.md` describing the project stack and directory layout.
733
+ *
734
+ * @param opts - The install options containing `name`.
735
+ * @returns The Markdown source for `ARCHITECTURE.md`.
736
+ */
569
737
  function generateArchitecture(opts) {
570
738
  return `# Architecture
571
739
 
@@ -595,6 +763,14 @@ static/
595
763
  \`\`\`
596
764
  `;
597
765
  }
766
+ // ---------------------------------------------------------------------------
767
+ // FRAMEWORKS DOC GENERATOR
768
+ // ---------------------------------------------------------------------------
769
+ /**
770
+ * Generate a `FRAMEWORKS.md` documenting technology choices and rationale.
771
+ *
772
+ * @returns The Markdown source for `FRAMEWORKS.md`.
773
+ */
598
774
  function generateFrameworks() {
599
775
  return `# Framework Decisions
600
776
 
@@ -625,6 +801,15 @@ Service worker (generated by \`stellarPWA\` Vite plugin) with:
625
801
  - **Husky** — pre-commit hooks
626
802
  `;
627
803
  }
804
+ // ---------------------------------------------------------------------------
805
+ // GITIGNORE GENERATOR
806
+ // ---------------------------------------------------------------------------
807
+ /**
808
+ * Generate a `.gitignore` tailored for SvelteKit PWA projects.
809
+ * Excludes build artifacts, generated SW files, and environment secrets.
810
+ *
811
+ * @returns The gitignore content string.
812
+ */
628
813
  function generateGitignore() {
629
814
  return `node_modules
630
815
  .DS_Store
@@ -640,6 +825,16 @@ static/sw.js
640
825
  static/asset-manifest.json
641
826
  `;
642
827
  }
828
+ // ---------------------------------------------------------------------------
829
+ // OFFLINE HTML GENERATOR
830
+ // ---------------------------------------------------------------------------
831
+ /**
832
+ * Generate a placeholder offline fallback page (`static/offline.html`).
833
+ * The service worker serves this when no cached HTML is available.
834
+ *
835
+ * @param opts - The install options containing `name`.
836
+ * @returns The HTML source for `static/offline.html`.
837
+ */
643
838
  function generateOfflineHtml(opts) {
644
839
  return `<!-- TODO: Customize this offline fallback page with your app's branding.
645
840
  This page is served by the service worker when the app is offline and
@@ -661,6 +856,18 @@ function generateOfflineHtml(opts) {
661
856
  </html>
662
857
  `;
663
858
  }
859
+ // ---------------------------------------------------------------------------
860
+ // PLACEHOLDER SVG GENERATORS
861
+ // ---------------------------------------------------------------------------
862
+ /**
863
+ * Generate a placeholder app icon SVG with a coloured background and
864
+ * a centred text label (typically a single letter).
865
+ *
866
+ * @param color - The background fill colour (e.g., `'#6c5ce7'`).
867
+ * @param label - The text to display (e.g., `'M'` for "My App").
868
+ * @param fontSize - The font size for the label (default: 64).
869
+ * @returns The SVG markup string.
870
+ */
664
871
  function generatePlaceholderSvg(color, label, fontSize = 64) {
665
872
  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
666
873
  <rect width="512" height="512" rx="64" fill="${color}"/>
@@ -668,6 +875,13 @@ function generatePlaceholderSvg(color, label, fontSize = 64) {
668
875
  </svg>
669
876
  `;
670
877
  }
878
+ /**
879
+ * Generate a monochrome (white background, black text) icon SVG.
880
+ * Used for the `monochrome` icon variant in the PWA manifest.
881
+ *
882
+ * @param label - The text to display.
883
+ * @returns The SVG markup string.
884
+ */
671
885
  function generateMonochromeSvg(label) {
672
886
  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
673
887
  <rect width="512" height="512" rx="64" fill="#ffffff"/>
@@ -675,6 +889,12 @@ function generateMonochromeSvg(label) {
675
889
  </svg>
676
890
  `;
677
891
  }
892
+ /**
893
+ * Generate a splash screen SVG with a dark background and the app's short name.
894
+ *
895
+ * @param label - The text to display (typically `shortName`).
896
+ * @returns The SVG markup string.
897
+ */
678
898
  function generateSplashSvg(label) {
679
899
  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
680
900
  <rect width="512" height="512" rx="64" fill="#0f0f1a"/>
@@ -682,6 +902,15 @@ function generateSplashSvg(label) {
682
902
  </svg>
683
903
  `;
684
904
  }
905
+ // ---------------------------------------------------------------------------
906
+ // EMAIL TEMPLATE PLACEHOLDER
907
+ // ---------------------------------------------------------------------------
908
+ /**
909
+ * Generate a placeholder HTML email template with a TODO comment.
910
+ *
911
+ * @param title - The email template title (e.g., `"Change Email"`).
912
+ * @returns The HTML source for the email placeholder.
913
+ */
685
914
  function generateEmailPlaceholder(title) {
686
915
  return `<!-- TODO: ${title} email template -->
687
916
  <!-- See stellar-engine EMAIL_TEMPLATES.md for the full template format -->
@@ -691,6 +920,16 @@ function generateEmailPlaceholder(title) {
691
920
  </html>
692
921
  `;
693
922
  }
923
+ // ---------------------------------------------------------------------------
924
+ // SUPABASE SCHEMA GENERATOR
925
+ // ---------------------------------------------------------------------------
926
+ /**
927
+ * Generate the Supabase database schema SQL including helper functions,
928
+ * the `trusted_devices` table, and commented-out example table patterns.
929
+ *
930
+ * @param opts - The install options containing `name`.
931
+ * @returns The SQL source for `supabase-schema.sql`.
932
+ */
694
933
  function generateSupabaseSchema(opts) {
695
934
  return `-- ${opts.name} Database Schema for Supabase
696
935
  -- Copy and paste this entire file into your Supabase SQL Editor
@@ -783,6 +1022,14 @@ create index idx_trusted_devices_user_id on trusted_devices(user_id);
783
1022
  alter publication supabase_realtime add table trusted_devices;
784
1023
  `;
785
1024
  }
1025
+ // ---------------------------------------------------------------------------
1026
+ // ESLINT CONFIG GENERATOR
1027
+ // ---------------------------------------------------------------------------
1028
+ /**
1029
+ * Generate an ESLint flat config with TypeScript and Svelte support.
1030
+ *
1031
+ * @returns The JavaScript source for `eslint.config.js`.
1032
+ */
786
1033
  function generateEslintConfig() {
787
1034
  return `import js from '@eslint/js';
788
1035
  import ts from 'typescript-eslint';
@@ -856,6 +1103,14 @@ export default [
856
1103
  ];
857
1104
  `;
858
1105
  }
1106
+ // ---------------------------------------------------------------------------
1107
+ // PRETTIER CONFIG GENERATORS
1108
+ // ---------------------------------------------------------------------------
1109
+ /**
1110
+ * Generate a `.prettierrc` with SvelteKit-friendly defaults.
1111
+ *
1112
+ * @returns The JSON string for `.prettierrc`.
1113
+ */
859
1114
  function generatePrettierrc() {
860
1115
  return (JSON.stringify({
861
1116
  useTabs: false,
@@ -874,6 +1129,11 @@ function generatePrettierrc() {
874
1129
  ]
875
1130
  }, null, 2) + '\n');
876
1131
  }
1132
+ /**
1133
+ * Generate a `.prettierignore` excluding build artifacts and generated files.
1134
+ *
1135
+ * @returns The prettierignore content string.
1136
+ */
877
1137
  function generatePrettierignore() {
878
1138
  return `.svelte-kit
879
1139
  build
@@ -884,6 +1144,14 @@ static
884
1144
  package-lock.json
885
1145
  `;
886
1146
  }
1147
+ // ---------------------------------------------------------------------------
1148
+ // KNIP CONFIG GENERATOR
1149
+ // ---------------------------------------------------------------------------
1150
+ /**
1151
+ * Generate a `knip.json` for dead code detection in a SvelteKit project.
1152
+ *
1153
+ * @returns The JSON string for `knip.json`.
1154
+ */
887
1155
  function generateKnipJson() {
888
1156
  return (JSON.stringify({
889
1157
  $schema: 'https://unpkg.com/knip@latest/schema.json',
@@ -895,12 +1163,42 @@ function generateKnipJson() {
895
1163
  }
896
1164
  }, null, 2) + '\n');
897
1165
  }
1166
+ // ---------------------------------------------------------------------------
1167
+ // HUSKY PRE-COMMIT GENERATOR
1168
+ // ---------------------------------------------------------------------------
1169
+ /**
1170
+ * Generate the Husky pre-commit hook script that runs cleanup and validation.
1171
+ *
1172
+ * @returns The shell script content for `.husky/pre-commit`.
1173
+ */
898
1174
  function generateHuskyPreCommit() {
899
1175
  return `npm run cleanup && npm run validate && git add -u
900
1176
  `;
901
1177
  }
1178
+ // ---------------------------------------------------------------------------
1179
+ // ROOT LAYOUT GENERATORS
1180
+ // ---------------------------------------------------------------------------
1181
+ /**
1182
+ * Generate the root `+layout.ts` with runtime config initialisation,
1183
+ * auth state resolution, and sync engine startup.
1184
+ *
1185
+ * @param opts - The install options containing `name` and `prefix`.
1186
+ * @returns The TypeScript source for `src/routes/+layout.ts`.
1187
+ */
902
1188
  function generateRootLayoutTs(opts) {
903
- return `import { browser } from '$app/environment';
1189
+ return `/**
1190
+ * @fileoverview Root layout loader — engine bootstrap + auth resolution.
1191
+ *
1192
+ * Runs on every navigation. In the browser it initialises runtime config,
1193
+ * resolves the current auth state (online session or offline credentials),
1194
+ * and starts the sync engine when the user is authenticated.
1195
+ */
1196
+
1197
+ // =============================================================================
1198
+ // IMPORTS
1199
+ // =============================================================================
1200
+
1201
+ import { browser } from '$app/environment';
904
1202
  import { redirect } from '@sveltejs/kit';
905
1203
  import { goto } from '$app/navigation';
906
1204
  import { initEngine, startSyncEngine, supabase } from '@prabhask5/stellar-engine';
@@ -910,9 +1208,19 @@ import { resolveRootLayout } from '@prabhask5/stellar-engine/kit';
910
1208
  import type { AuthMode, OfflineCredentials, Session } from '@prabhask5/stellar-engine/types';
911
1209
  import type { LayoutLoad } from './$types';
912
1210
 
1211
+ // =============================================================================
1212
+ // SVELTEKIT ROUTE CONFIG
1213
+ // =============================================================================
1214
+
1215
+ /** Allow server-side rendering for initial page load performance. */
913
1216
  export const ssr = true;
1217
+ /** Disable prerendering — pages depend on runtime auth state. */
914
1218
  export const prerender = false;
915
1219
 
1220
+ // =============================================================================
1221
+ // ENGINE BOOTSTRAP
1222
+ // =============================================================================
1223
+
916
1224
  // TODO: Configure initEngine() with your app-specific database schema.
917
1225
  // Call initEngine({...}) at module scope (guarded by \`if (browser)\`).
918
1226
  // See the stellar-engine documentation for the full config interface.
@@ -931,21 +1239,43 @@ export const prerender = false;
931
1239
  // },
932
1240
  // supabase,
933
1241
  // prefix: '${opts.prefix}',
934
- // auth: { mode: 'single-user', singleUser: { gateType: 'code', codeLength: 6 } },
1242
+ // auth: { singleUser: { gateType: 'code', codeLength: 6 } },
935
1243
  // onAuthStateChange: (event, session) => { /* handle auth events */ },
936
1244
  // onAuthKicked: async () => { await lockSingleUser(); goto('/login'); }
937
1245
  // });
938
1246
  // }
939
1247
 
1248
+ // =============================================================================
1249
+ // LAYOUT DATA TYPE
1250
+ // =============================================================================
1251
+
1252
+ /**
1253
+ * Data returned by the root layout load function.
1254
+ */
940
1255
  export interface LayoutData {
1256
+ /** Active Supabase session, or \`null\` when offline / unauthenticated. */
941
1257
  session: Session | null;
1258
+ /** Current authentication mode (\`'online'\`, \`'offline'\`, or \`'none'\`). */
942
1259
  authMode: AuthMode;
1260
+ /** Cached offline credentials (display name, avatar) when auth is offline. */
943
1261
  offlineProfile: OfflineCredentials | null;
1262
+ /** Whether the single-user account has completed initial setup. */
944
1263
  singleUserSetUp?: boolean;
945
1264
  }
946
1265
 
1266
+ // =============================================================================
1267
+ // LOAD FUNCTION
1268
+ // =============================================================================
1269
+
1270
+ /**
1271
+ * Root layout load — initialises config, resolves auth, and starts sync.
1272
+ *
1273
+ * @param params - SvelteKit load params (provides the current URL).
1274
+ * @returns Layout data with session and auth state.
1275
+ */
947
1276
  export const load: LayoutLoad = async ({ url }): Promise<LayoutData> => {
948
1277
  if (browser) {
1278
+ /* Fetch runtime config from /api/config (cached after first call) */
949
1279
  const config = await initConfig();
950
1280
  if (!config && url.pathname !== '/setup') {
951
1281
  redirect(307, '/setup');
@@ -953,71 +1283,456 @@ export const load: LayoutLoad = async ({ url }): Promise<LayoutData> => {
953
1283
  if (!config) {
954
1284
  return { session: null, authMode: 'none', offlineProfile: null, singleUserSetUp: false };
955
1285
  }
1286
+ /* Determine whether the user is online-authenticated, offline, or none */
956
1287
  const result = await resolveAuthState();
957
1288
  if (result.authMode !== 'none') {
1289
+ /* Kick off background sync (Supabase realtime + IndexedDB) */
958
1290
  await startSyncEngine();
959
1291
  }
960
1292
  return result;
961
1293
  }
1294
+ /* SSR fallback — no auth info available on the server */
962
1295
  return { session: null, authMode: 'none', offlineProfile: null, singleUserSetUp: false };
963
1296
  };
964
1297
  `;
965
1298
  }
966
- function generateRootLayoutSvelte() {
967
- return `<script lang="ts">
1299
+ /**
1300
+ * Generate the root `+layout.svelte` with auth state hydration and TODO stubs.
1301
+ *
1302
+ * @returns The Svelte component source for `src/routes/+layout.svelte`.
1303
+ */
1304
+ function generateRootLayoutSvelte(opts) {
1305
+ return `<!--
1306
+ @fileoverview Root layout component — app shell, auth hydration,
1307
+ navigation chrome, overlays, and PWA lifecycle.
1308
+
1309
+ This is the outermost Svelte component. It wraps every page and is
1310
+ responsible for hydrating auth state from the load function, rendering
1311
+ the navigation bar / tab bar, and mounting global overlays like the
1312
+ service-worker update prompt.
1313
+ -->
1314
+ <script lang="ts">
1315
+ /**
1316
+ * @fileoverview Root layout script — auth state management, navigation logic,
1317
+ * service worker communication, and global event handlers.
1318
+ */
1319
+
1320
+ // =============================================================================
1321
+ // Imports
1322
+ // =============================================================================
1323
+
1324
+ /* ── Svelte Lifecycle & Transitions ── */
1325
+ import { onMount, onDestroy } from 'svelte';
1326
+
1327
+ /* ── SvelteKit Utilities ── */
1328
+ import { page } from '$app/stores';
1329
+ import { browser } from '$app/environment';
1330
+
1331
+ /* ── Stellar Engine — Auth & Stores ── */
1332
+ import { lockSingleUser } from '@prabhask5/stellar-engine/auth';
1333
+ import { debug } from '@prabhask5/stellar-engine/utils';
968
1334
  import { hydrateAuthState } from '@prabhask5/stellar-engine/kit';
969
- import { authState } from '@prabhask5/stellar-engine/stores';
1335
+
1336
+ /* ── Types ── */
970
1337
  import type { LayoutData } from './+layout';
971
1338
 
1339
+ // =============================================================================
1340
+ // Props
1341
+ // =============================================================================
1342
+
972
1343
  interface Props {
1344
+ /** Default slot content — the matched page component. */
973
1345
  children?: import('svelte').Snippet;
1346
+
1347
+ /** Layout data from \`+layout.ts\` — session, auth mode, offline profile. */
974
1348
  data: LayoutData;
975
1349
  }
976
1350
 
977
1351
  let { children, data }: Props = $props();
978
1352
 
1353
+ // =============================================================================
1354
+ // Component State
1355
+ // =============================================================================
1356
+
1357
+ /* ── Toast Notification ── */
1358
+ /** Whether the toast notification is currently visible. */
1359
+ let showToast = $state(false);
1360
+
1361
+ /** The text content of the current toast notification. */
1362
+ let toastMessage = $state('');
1363
+
1364
+ /** The visual style of the toast — \`'info'\` (purple) or \`'error'\` (pink). */
1365
+ let toastType = $state<'info' | 'error'>('info');
1366
+
1367
+ /* ── Sign-Out ── */
1368
+ /** When \`true\`, a full-screen overlay is shown to mask the sign-out transition. */
1369
+ let isSigningOut = $state(false);
1370
+
1371
+ /* ── Cleanup References ── */
1372
+ /** Stored reference to the chunk error handler so we can remove it on destroy. */
1373
+ let chunkErrorHandler: ((event: PromiseRejectionEvent) => void) | null = null;
1374
+
1375
+ // =============================================================================
1376
+ // Reactive Effects
1377
+ // =============================================================================
1378
+
1379
+ /**
1380
+ * Effect: hydrate the global \`authState\` store from layout load data.
1381
+ *
1382
+ * Runs whenever \`data\` changes (e.g. after navigation or revalidation).
1383
+ * Maps the three possible auth modes to the corresponding store setter:
1384
+ * - \`'supabase'\` + session → \`setSupabaseAuth\`
1385
+ * - \`'offline'\` + cached profile → \`setOfflineAuth\`
1386
+ * - anything else → \`setNoAuth\`
1387
+ */
979
1388
  $effect(() => {
980
1389
  hydrateAuthState(data);
981
1390
  });
982
1391
 
983
- // TODO: Add app shell (navbar, tab bar, overlays, sign-out logic, etc.)
984
- // TODO: Import and use UpdatePrompt from '$lib/components/UpdatePrompt.svelte'
985
- // TODO: Import and use SyncStatus from '@prabhask5/stellar-engine/components/SyncStatus'
1392
+ // =============================================================================
1393
+ // Lifecycle Mount
1394
+ // =============================================================================
1395
+
1396
+ onMount(() => {
1397
+ // ── Chunk Error Handler ────────────────────────────────────────────────
1398
+ // When navigating offline to a page whose JS chunks aren't cached,
1399
+ // the dynamic import fails and shows a cryptic error. Catch and show a friendly message.
1400
+ chunkErrorHandler = (event: PromiseRejectionEvent) => {
1401
+ const error = event.reason;
1402
+ // Check if this is a chunk loading error (fetch failed or syntax error from 503 response)
1403
+ const isChunkError =
1404
+ error?.message?.includes('Failed to fetch dynamically imported module') ||
1405
+ error?.message?.includes('error loading dynamically imported module') ||
1406
+ error?.message?.includes('Importing a module script failed') ||
1407
+ error?.name === 'ChunkLoadError' ||
1408
+ (error?.message?.includes('Loading chunk') && error?.message?.includes('failed'));
1409
+
1410
+ if (isChunkError && !navigator.onLine) {
1411
+ event.preventDefault(); // Prevent default error handling
1412
+ // Show offline navigation toast
1413
+ toastMessage = "This page isn't available offline. Please reconnect or go back.";
1414
+ toastType = 'info';
1415
+ showToast = true;
1416
+ setTimeout(() => {
1417
+ showToast = false;
1418
+ }, 5000);
1419
+ }
1420
+ };
1421
+
1422
+ window.addEventListener('unhandledrejection', chunkErrorHandler);
1423
+
1424
+ // ── Sign-Out Event Listener ───────────────────────────────────────────
1425
+ // Listen for sign out requests from child pages (e.g. mobile profile page)
1426
+ window.addEventListener('${opts.prefix}:signout', handleSignOut);
1427
+
1428
+ // ── Service Worker — Background Precaching ────────────────────────────
1429
+ // Proactively cache all app chunks for full offline support.
1430
+ // This runs in the background after page load, so it doesn't affect Lighthouse scores.
1431
+ if ('serviceWorker' in navigator) {
1432
+ // Listen for precache completion messages from service worker
1433
+ navigator.serviceWorker.addEventListener('message', (event) => {
1434
+ if (event.data?.type === 'PRECACHE_COMPLETE') {
1435
+ const { cached, total } = event.data;
1436
+ debug('log', \`[PWA] Background precaching complete: \${cached}/\${total} assets cached\`);
1437
+ if (cached === total) {
1438
+ debug('log', '[PWA] Full offline support ready - all pages accessible offline');
1439
+ } else {
1440
+ debug('warn', \`[PWA] Some assets failed to cache: \${total - cached} missing\`);
1441
+ }
1442
+ }
1443
+ });
1444
+
1445
+ // Wait for service worker to be ready (handles first load case)
1446
+ navigator.serviceWorker.ready.then((registration) => {
1447
+ debug('log', '[PWA] Service worker ready, scheduling background precache...');
1448
+
1449
+ // Give the page time to fully load, then trigger background precaching
1450
+ setTimeout(() => {
1451
+ const controller = navigator.serviceWorker.controller || registration.active;
1452
+ if (!controller) {
1453
+ debug('warn', '[PWA] No service worker controller available');
1454
+ return;
1455
+ }
1456
+
1457
+ // First, cache current page's assets (scripts + stylesheets)
1458
+ const scripts = Array.from(document.querySelectorAll('script[src]'))
1459
+ .map((el) => (el as HTMLScriptElement).src)
1460
+ .filter((src) => src.startsWith(location.origin));
1461
+
1462
+ const styles = Array.from(document.querySelectorAll('link[rel="stylesheet"]'))
1463
+ .map((el) => (el as HTMLLinkElement).href)
1464
+ .filter((href) => href.startsWith(location.origin));
1465
+
1466
+ const urls = [...scripts, ...styles];
1467
+
1468
+ if (urls.length > 0) {
1469
+ debug('log', \`[PWA] Caching \${urls.length} current page assets...\`);
1470
+ controller.postMessage({
1471
+ type: 'CACHE_URLS',
1472
+ urls
1473
+ });
1474
+ }
1475
+
1476
+ // Then trigger full background precaching for all app chunks.
1477
+ // This ensures offline support for all pages, not just visited ones.
1478
+ debug('log', '[PWA] Triggering background precache of all app chunks...');
1479
+ controller.postMessage({
1480
+ type: 'PRECACHE_ALL'
1481
+ });
1482
+ }, 500); // Cache assets quickly to reduce window for uncached refreshes
1483
+ });
1484
+ }
1485
+ });
1486
+
1487
+ // =============================================================================
1488
+ // Lifecycle — Destroy
1489
+ // =============================================================================
1490
+
1491
+ onDestroy(() => {
1492
+ if (browser) {
1493
+ // Cleanup chunk error handler
1494
+ if (chunkErrorHandler) {
1495
+ window.removeEventListener('unhandledrejection', chunkErrorHandler);
1496
+ }
1497
+ // Cleanup sign out listener
1498
+ window.removeEventListener('${opts.prefix}:signout', handleSignOut);
1499
+ }
1500
+ });
1501
+
1502
+ // =============================================================================
1503
+ // Event Handlers
1504
+ // =============================================================================
1505
+
1506
+ /**
1507
+ * Handles the sign-out flow with a visual transition.
1508
+ *
1509
+ * 1. Shows a full-screen "Locking..." overlay immediately.
1510
+ * 2. Waits 250ms for the overlay fade-in to complete.
1511
+ * 3. Calls \`lockSingleUser()\` to stop the engine and clear the session
1512
+ * (but NOT destroy user data).
1513
+ * 4. Hard-navigates to \`/login\` (full page reload to reset all state).
1514
+ */
1515
+ async function handleSignOut() {
1516
+ // Show full-screen overlay immediately
1517
+ isSigningOut = true;
1518
+
1519
+ // Wait for overlay to fully appear
1520
+ await new Promise((resolve) => setTimeout(resolve, 250));
1521
+
1522
+ // Lock the single-user session (stops engine, resets auth state, does NOT destroy data)
1523
+ await lockSingleUser();
1524
+
1525
+ // Navigate to login
1526
+ window.location.href = '/login';
1527
+ }
1528
+
1529
+ /**
1530
+ * Checks whether a given route \`href\` matches the current page path.
1531
+ * Used to highlight the active nav item.
1532
+ *
1533
+ * @param href - The route path to check (e.g. \`'/agenda'\`)
1534
+ * @returns \`true\` if the current path starts with \`href\`
1535
+ */
1536
+ function isActive(href: string): boolean {
1537
+ return $page.url.pathname.startsWith(href);
1538
+ }
1539
+
1540
+ /**
1541
+ * Dismisses the currently visible toast notification.
1542
+ */
1543
+ function dismissToast() {
1544
+ showToast = false;
1545
+ }
986
1546
  </script>
987
1547
 
988
1548
  <!-- TODO: Add your app shell template (navbar, tab bar, page transitions, etc.) -->
989
1549
  {@render children?.()}
990
1550
  `;
991
1551
  }
992
- function generateHomePage() {
993
- return `<script lang="ts">
994
- import { getUserProfile } from '@prabhask5/stellar-engine/auth';
1552
+ // ---------------------------------------------------------------------------
1553
+ // PAGE GENERATORS
1554
+ // ---------------------------------------------------------------------------
1555
+ /**
1556
+ * Generate a minimal home page component with TODO stubs.
1557
+ *
1558
+ * @returns The Svelte component source for `src/routes/+page.svelte`.
1559
+ */
1560
+ function generateHomePage(opts) {
1561
+ return `<!--
1562
+ @fileoverview Home / landing page — welcome screen and primary content.
1563
+
1564
+ This is the default route (\`/\`). It renders the main content area
1565
+ the user sees after authentication.
1566
+ -->
1567
+ <script lang="ts">
1568
+ /**
1569
+ * @fileoverview Home page script — data access and component state.
1570
+ */
1571
+
1572
+ // ==========================================================================
1573
+ // IMPORTS
1574
+ // ==========================================================================
1575
+
1576
+ /* ── Stellar Engine — Auth & Stores ── */
1577
+ import { resolveFirstName } from '@prabhask5/stellar-engine/auth';
995
1578
  import { onSyncComplete, authState } from '@prabhask5/stellar-engine/stores';
996
1579
 
1580
+ // ==========================================================================
1581
+ // COMPONENT STATE
1582
+ // ==========================================================================
1583
+
1584
+ /**
1585
+ * Derive the user's first name for the greeting display.
1586
+ * Falls back through session profile → email username → offline profile → 'Explorer'.
1587
+ */
1588
+ const firstName = $derived(
1589
+ resolveFirstName($authState.session, $authState.offlineProfile)
1590
+ );
1591
+
1592
+ // =============================================================================
1593
+ // Reactive Effects
1594
+ // =============================================================================
1595
+
1596
+ /**
1597
+ * Effect: auth redirect guard.
1598
+ *
1599
+ * Once the auth store finishes loading and resolves to \`'none'\` (no session),
1600
+ * redirect to \`/login\` with a \`redirect\` query param so the login page knows
1601
+ * this was an automatic redirect rather than direct navigation.
1602
+ */
1603
+ $effect(() => {
1604
+ if (!$authState.isLoading && $authState.mode === 'none') {
1605
+ // Include redirect param so login page knows this was a redirect, not direct navigation
1606
+ goto('/login?redirect=%2F', { replaceState: true });
1607
+ }
1608
+ });
1609
+
997
1610
  // TODO: Add home page state and logic
998
1611
  </script>
999
1612
 
1613
+ <svelte:head>
1614
+ <title>Home - ${opts.name}</title>
1615
+ </svelte:head>
1616
+
1000
1617
  <!-- TODO: Add home page template -->
1001
1618
  `;
1002
1619
  }
1003
- function generateErrorPage() {
1004
- return `<script lang="ts">
1620
+ /**
1621
+ * Generate a minimal error page component.
1622
+ *
1623
+ * @returns The Svelte component source for `src/routes/+error.svelte`.
1624
+ */
1625
+ function generateErrorPage(opts) {
1626
+ return `<!--
1627
+ @fileoverview Error boundary — handles three scenarios:
1628
+ 1. **Offline** — device has no connectivity, show a friendly offline message
1629
+ 2. **404** — page not found, offer navigation back to home
1630
+ 3. **Generic** — unexpected error, display status code and retry option
1631
+ -->
1632
+ <script lang="ts">
1633
+ /**
1634
+ * @fileoverview Error page script — status detection and recovery actions.
1635
+ */
1636
+
1637
+ // ==========================================================================
1638
+ // IMPORTS
1639
+ // ==========================================================================
1640
+
1005
1641
  import { page } from '$app/stores';
1006
1642
 
1007
- // TODO: Add error page logic (offline detection, retry handlers, etc.)
1643
+ // ==========================================================================
1644
+ // STATE
1645
+ // ==========================================================================
1646
+
1647
+ /** Whether the user is currently offline — drives which error variant is shown. */
1648
+ let isOffline = $state(false);
1649
+
1650
+ // ==========================================================================
1651
+ // REACTIVE EFFECTS
1652
+ // ==========================================================================
1653
+
1654
+ /**
1655
+ * Effect: tracks the browser's online/offline status in real time.
1656
+ * Sets \`isOffline\` on mount and attaches \`online\` / \`offline\` event listeners.
1657
+ * Returns a cleanup function that removes the listeners on destroy.
1658
+ */
1659
+ $effect(() => {
1660
+ if (browser) {
1661
+ isOffline = !navigator.onLine;
1662
+
1663
+ const handleOnline = () => {
1664
+ isOffline = false;
1665
+ };
1666
+ const handleOffline = () => {
1667
+ isOffline = true;
1668
+ };
1669
+
1670
+ window.addEventListener('online', handleOnline);
1671
+ window.addEventListener('offline', handleOffline);
1672
+
1673
+ return () => {
1674
+ window.removeEventListener('online', handleOnline);
1675
+ window.removeEventListener('offline', handleOffline);
1676
+ };
1677
+ }
1678
+ });
1679
+
1680
+ // ==========================================================================
1681
+ // EVENT HANDLERS
1682
+ // ==========================================================================
1683
+
1684
+ /**
1685
+ * Reload the current page — useful when the user regains connectivity or
1686
+ * wants to retry after a transient server error.
1687
+ */
1688
+ function handleRetry() {
1689
+ window.location.reload();
1690
+ }
1691
+
1692
+ /**
1693
+ * Navigate back to the home page via SvelteKit client-side routing.
1694
+ */
1695
+ function handleGoHome() {
1696
+ goto('/');
1697
+ }
1008
1698
  </script>
1009
1699
 
1700
+ <svelte:head>
1701
+ <title>Error - ${opts.name}</title>
1702
+ </svelte:head>
1703
+
1010
1704
  <!-- TODO: Add error page template (status code display, retry button, go home button) -->
1011
1705
  `;
1012
1706
  }
1707
+ /**
1708
+ * Generate the setup page load function with first-setup / auth guard.
1709
+ *
1710
+ * @returns The TypeScript source for `src/routes/setup/+page.ts`.
1711
+ */
1013
1712
  function generateSetupPageTs() {
1014
- return `import { browser } from '$app/environment';
1713
+ return `/**
1714
+ * @fileoverview Setup page access control gate.
1715
+ *
1716
+ * Two modes:
1717
+ * - **Unconfigured** — no runtime config exists yet; anyone can access the
1718
+ * setup wizard to perform first-time Supabase configuration.
1719
+ * - **Configured** — config already saved; only authenticated users may
1720
+ * revisit the setup page to update credentials or redeploy.
1721
+ */
1722
+
1723
+ import { browser } from '$app/environment';
1015
1724
  import { redirect } from '@sveltejs/kit';
1016
1725
  import { getConfig } from '@prabhask5/stellar-engine/config';
1017
- import { getValidSession, isAdmin } from '@prabhask5/stellar-engine/auth';
1726
+ import { getValidSession } from '@prabhask5/stellar-engine/auth';
1018
1727
  import type { PageLoad } from './$types';
1019
1728
 
1729
+ /**
1730
+ * Guard the setup route — allow first-time setup or authenticated access.
1731
+ *
1732
+ * @returns Page data with an \`isFirstSetup\` flag.
1733
+ */
1020
1734
  export const load: PageLoad = async () => {
1735
+ /* Config and session helpers rely on browser APIs */
1021
1736
  if (!browser) return {};
1022
1737
  if (!getConfig()) {
1023
1738
  return { isFirstSetup: true };
@@ -1026,35 +1741,280 @@ export const load: PageLoad = async () => {
1026
1741
  if (!session?.user) {
1027
1742
  redirect(307, '/login');
1028
1743
  }
1029
- if (!isAdmin(session.user)) {
1030
- redirect(307, '/');
1031
- }
1032
1744
  return { isFirstSetup: false };
1033
1745
  };
1034
1746
  `;
1035
1747
  }
1036
- function generateSetupPageSvelte() {
1037
- return `<script lang="ts">
1038
- import { setConfig } from '@prabhask5/stellar-engine/config';
1039
- import { isOnline } from '@prabhask5/stellar-engine/stores';
1040
- import { pollForNewServiceWorker } from '@prabhask5/stellar-engine/kit';
1041
-
1042
- // TODO: Add setup wizard state (steps, form fields, validation, deployment)
1748
+ /**
1749
+ * Generate the setup wizard page component with TODO stubs.
1750
+ *
1751
+ * @returns The Svelte component source for `src/routes/setup/+page.svelte`.
1752
+ */
1753
+ function generateSetupPageSvelte(opts) {
1754
+ return `<!--
1755
+ @fileoverview Five-step Supabase configuration wizard.
1756
+
1757
+ Guides the user through entering Supabase credentials, validating them
1758
+ against the server, optionally deploying environment variables to Vercel,
1759
+ and reloading the app with the new config active.
1760
+ -->
1761
+ <script lang="ts">
1762
+ /**
1763
+ * @fileoverview Setup wizard page — first-time Supabase configuration.
1764
+ *
1765
+ * Guides the user through a five-step process to connect their own
1766
+ * Supabase backend to Stellar:
1767
+ *
1768
+ * 1. Create a Supabase project (instructions only).
1769
+ * 2. Configure authentication (enable anonymous sign-ins).
1770
+ * 3. Initialize the database by running the schema SQL.
1771
+ * 4. Enter and validate Supabase credentials (URL + anon key).
1772
+ * 5. Persist configuration via Vercel API (set env vars + redeploy).
1773
+ *
1774
+ * After a successful deploy the page polls for a new service-worker
1775
+ * version — once detected the user is prompted to refresh.
1776
+ *
1777
+ * Access is controlled by the companion \`+page.ts\` load function:
1778
+ * - Unconfigured → anyone can reach this page (\`isFirstSetup: true\`).
1779
+ * - Configured → authenticated users only (\`isFirstSetup: false\`).
1780
+ */
1781
+
1782
+ import { page } from '$app/stores';
1783
+ import { setConfig } from '@prabhask5/stellar-engine/config';
1784
+ import { isOnline } from '@prabhask5/stellar-engine/stores';
1785
+ import { pollForNewServiceWorker } from '@prabhask5/stellar-engine/kit';
1786
+
1787
+ // =============================================================================
1788
+ // Form State — Supabase + Vercel credentials
1789
+ // =============================================================================
1790
+
1791
+ /** Supabase project URL entered by the user */
1792
+ let supabaseUrl = $state('');
1793
+
1794
+ /** Supabase public anon key entered by the user */
1795
+ let supabaseAnonKey = $state('');
1796
+
1797
+ /** One-time Vercel API token for setting env vars */
1798
+ let vercelToken = $state('');
1799
+
1800
+ // =============================================================================
1801
+ // UI State — Validation & Deployment feedback
1802
+ // =============================================================================
1803
+
1804
+ /** Whether the "Test Connection" request is in-flight */
1805
+ let validating = $state(false);
1806
+
1807
+ /** Whether the deploy/redeploy flow is in-flight */
1808
+ let deploying = $state(false);
1809
+
1810
+ /** Error from credential validation, if any */
1811
+ let validateError = $state<string | null>(null);
1812
+
1813
+ /** \`true\` after credentials have been successfully validated */
1814
+ let validateSuccess = $state(false);
1815
+
1816
+ /** Error from the deployment step, if any */
1817
+ let deployError = $state<string | null>(null);
1818
+
1819
+ /** Current deployment pipeline stage — drives the progress UI */
1820
+ let deployStage = $state<'idle' | 'setting-env' | 'deploying' | 'ready'>('idle');
1821
+
1822
+ /** URL returned by Vercel for the triggered deployment (informational) */
1823
+ let _deploymentUrl = $state('');
1824
+
1825
+ // =============================================================================
1826
+ // Derived State
1827
+ // =============================================================================
1828
+
1829
+ /** Whether this is a first-time setup (public) or reconfiguration */
1830
+ const isFirstSetup = $derived(($page.data as { isFirstSetup?: boolean }).isFirstSetup ?? false);
1831
+
1832
+ /**
1833
+ * Snapshot of the credentials at validation time — used to detect
1834
+ * if the user edits the inputs *after* a successful validation.
1835
+ */
1836
+ let validatedUrl = $state('');
1837
+ let validatedKey = $state('');
1838
+
1839
+ /**
1840
+ * \`true\` when the user changes credentials after a successful
1841
+ * validation — the "Continue" button should be re-disabled.
1842
+ */
1843
+ const credentialsChanged = $derived(
1844
+ validateSuccess && (supabaseUrl !== validatedUrl || supabaseAnonKey !== validatedKey)
1845
+ );
1846
+
1847
+ // =============================================================================
1848
+ // Effects
1849
+ // =============================================================================
1850
+
1851
+ /**
1852
+ * Auto-reset validation state when the user modifies credentials
1853
+ * after they were already validated — forces re-validation.
1854
+ */
1855
+ $effect(() => {
1856
+ if (credentialsChanged) {
1857
+ validateSuccess = false;
1858
+ validateError = null;
1859
+ }
1860
+ });
1861
+
1862
+ // =============================================================================
1863
+ // Validation — "Test Connection"
1864
+ // =============================================================================
1865
+
1866
+ /**
1867
+ * Send the entered Supabase credentials to \`/api/setup/validate\`
1868
+ * and update UI state based on the result. On success, also
1869
+ * cache the config locally via \`setConfig\` so the app is usable
1870
+ * immediately after the deployment finishes.
1871
+ */
1872
+ async function handleValidate() {
1873
+ validateError = null;
1874
+ validateSuccess = false;
1875
+ validating = true;
1876
+
1877
+ try {
1878
+ const res = await fetch('/api/setup/validate', {
1879
+ method: 'POST',
1880
+ headers: { 'Content-Type': 'application/json' },
1881
+ body: JSON.stringify({ supabaseUrl, supabaseAnonKey })
1882
+ });
1883
+
1884
+ const data = await res.json();
1885
+
1886
+ if (data.valid) {
1887
+ validateSuccess = true;
1888
+ validatedUrl = supabaseUrl;
1889
+ validatedKey = supabaseAnonKey;
1890
+ /* Cache config locally so the app works immediately after deploy */
1891
+ setConfig({
1892
+ supabaseUrl,
1893
+ supabaseAnonKey,
1894
+ configured: true
1895
+ });
1896
+ } else {
1897
+ validateError = data.error || 'Validation failed';
1898
+ }
1899
+ } catch (e) {
1900
+ validateError = e instanceof Error ? e.message : 'Network error';
1901
+ }
1902
+
1903
+ validating = false;
1904
+ }
1905
+
1906
+ // =============================================================================
1907
+ // Deployment Polling
1908
+ // =============================================================================
1909
+
1910
+ /**
1911
+ * Poll for a new service-worker version to detect when the Vercel
1912
+ * redeployment has finished. Uses the engine's \`pollForNewServiceWorker\`
1913
+ * helper which checks \`registration.update()\` at regular intervals.
1914
+ *
1915
+ * Resolves a Promise when a new SW is detected in the waiting state.
1916
+ */
1917
+ function pollForDeployment(): Promise<void> {
1918
+ return new Promise((resolve) => {
1919
+ pollForNewServiceWorker({
1920
+ intervalMs: 3000,
1921
+ maxAttempts: 200,
1922
+ onFound: () => {
1923
+ deployStage = 'ready';
1924
+ resolve();
1925
+ }
1926
+ });
1927
+ });
1928
+ }
1929
+
1930
+ // =============================================================================
1931
+ // Deployment — Set env vars + trigger Vercel redeploy
1932
+ // =============================================================================
1933
+
1934
+ /**
1935
+ * Send credentials and the Vercel token to \`/api/setup/deploy\`,
1936
+ * which sets the environment variables on the Vercel project and
1937
+ * triggers a fresh deployment. Then poll until the new build is live.
1938
+ */
1939
+ async function handleDeploy() {
1940
+ deployError = null;
1941
+ deploying = true;
1942
+ deployStage = 'setting-env';
1943
+
1944
+ try {
1945
+ const res = await fetch('/api/setup/deploy', {
1946
+ method: 'POST',
1947
+ headers: { 'Content-Type': 'application/json' },
1948
+ body: JSON.stringify({ supabaseUrl, supabaseAnonKey, vercelToken })
1949
+ });
1950
+
1951
+ const data = await res.json();
1952
+
1953
+ if (data.success) {
1954
+ deployStage = 'deploying';
1955
+ _deploymentUrl = data.deploymentUrl || '';
1956
+ /* Poll for the new SW version → marks \`deployStage = 'ready'\` */
1957
+ await pollForDeployment();
1958
+ } else {
1959
+ deployError = data.error || 'Deployment failed';
1960
+ deployStage = 'idle';
1961
+ }
1962
+ } catch (e) {
1963
+ deployError = e instanceof Error ? e.message : 'Network error';
1964
+ deployStage = 'idle';
1965
+ }
1966
+
1967
+ deploying = false;
1968
+ }
1043
1969
  </script>
1044
1970
 
1971
+ <svelte:head>
1972
+ <title>Setup - ${opts.name}</title>
1973
+ </svelte:head>
1974
+
1045
1975
  <!-- TODO: Add setup wizard template (Supabase credentials form, validation, Vercel deployment) -->
1046
1976
  `;
1047
1977
  }
1048
- function generatePolicyPage() {
1049
- return `<script lang="ts">
1978
+ /**
1979
+ * Generate a minimal privacy policy page component.
1980
+ *
1981
+ * @returns The Svelte component source for `src/routes/policy/+page.svelte`.
1982
+ */
1983
+ function generatePolicyPage(opts) {
1984
+ return `<!--
1985
+ @fileoverview Privacy policy page.
1986
+
1987
+ Static content page that displays the application's privacy policy.
1988
+ Required by app stores and good practice for any app handling user data.
1989
+ -->
1990
+ <script lang="ts">
1050
1991
  // TODO: Add any needed imports
1051
1992
  </script>
1052
1993
 
1994
+ <svelte:head>
1995
+ <title>Privacy Policy - ${opts.name}</title>
1996
+ </svelte:head>
1997
+
1053
1998
  <!-- TODO: Add privacy policy page content -->
1054
1999
  `;
1055
2000
  }
1056
- function generateLoginPage() {
1057
- return `<script lang="ts">
2001
+ /**
2002
+ * Generate the login page component with single-user auth, device
2003
+ * verification, and PIN input TODO stubs.
2004
+ *
2005
+ * @returns The Svelte component source for `src/routes/login/+page.svelte`.
2006
+ */
2007
+ function generateLoginPage(opts) {
2008
+ return `<!--
2009
+ @fileoverview Login page — three modes:
2010
+ 1. **Setup** — first-time account creation (email + PIN)
2011
+ 2. **Unlock** — returning user enters PIN to unlock
2012
+ 3. **Link Device** — new device links to an existing account via email verification
2013
+
2014
+ Uses BroadcastChannel (\`auth-channel\`) for cross-tab communication with
2015
+ the /confirm page so email verification results propagate instantly.
2016
+ -->
2017
+ <script lang="ts">
1058
2018
  import { onMount, onDestroy } from 'svelte';
1059
2019
  import { goto, invalidateAll } from '$app/navigation';
1060
2020
  import { page } from '$app/stores';
@@ -1070,29 +2030,768 @@ function generateLoginPage() {
1070
2030
  } from '@prabhask5/stellar-engine/auth';
1071
2031
  import { sendDeviceVerification } from '@prabhask5/stellar-engine';
1072
2032
 
1073
- // TODO: Add login page state (setup/unlock/link-device modes, PIN inputs, modals)
1074
- // TODO: Add BroadcastChannel listener for auth-confirmed events from /confirm
2033
+ // ==========================================================================
2034
+ // LAYOUT / PAGE DATA
2035
+ // ==========================================================================
2036
+
2037
+ /** Whether the single-user account has already been set up on this device */
2038
+ const singleUserSetUp = $derived($page.data.singleUserSetUp);
2039
+
2040
+ /** Post-login redirect URL extracted from \`?redirect=\` query param */
2041
+ const redirectUrl = $derived($page.url.searchParams.get('redirect') || '/');
2042
+
2043
+ // ==========================================================================
2044
+ // SHARED UI STATE
2045
+ // ==========================================================================
2046
+
2047
+ /** \`true\` while any async auth operation is in-flight */
2048
+ let loading = $state(false);
2049
+
2050
+ /** Current error message shown to the user (null = no error) */
2051
+ let error = $state<string | null>(null);
2052
+
2053
+ /** Triggers the CSS shake animation on the login card */
2054
+ let shaking = $state(false);
2055
+
2056
+ /** Set to \`true\` after the component mounts — enables entrance animation */
2057
+ let mounted = $state(false);
2058
+
2059
+ // =============================================================================
2060
+ // Setup Mode State (step 1 → email/name, step 2 → PIN creation)
2061
+ // =============================================================================
2062
+
2063
+ /** User's email address for account creation */
2064
+ let email = $state('');
2065
+
2066
+ /** User's first name */
2067
+ let firstName = $state('');
2068
+
2069
+ /** User's last name (optional) */
2070
+ let lastName = $state('');
2071
+
2072
+ /** Individual digit values for the 6-digit PIN code */
2073
+ let codeDigits = $state(['', '', '', '', '', '']);
2074
+
2075
+ /** Individual digit values for the PIN confirmation */
2076
+ let confirmDigits = $state(['', '', '', '', '', '']);
2077
+
2078
+ /** Concatenated PIN code — derived from \`codeDigits\` */
2079
+ const code = $derived(codeDigits.join(''));
2080
+
2081
+ /** Concatenated confirmation code — derived from \`confirmDigits\` */
2082
+ const confirmCode = $derived(confirmDigits.join(''));
2083
+
2084
+ /** Current setup wizard step: 1 = email + name, 2 = PIN creation */
2085
+ let setupStep = $state(1); // 1 = email + name, 2 = code
2086
+
2087
+ // =============================================================================
2088
+ // Unlock Mode State (returning user on this device)
2089
+ // =============================================================================
2090
+
2091
+ /** Individual digit values for the unlock PIN */
2092
+ let unlockDigits = $state(['', '', '', '', '', '']);
2093
+
2094
+ /** Concatenated unlock code — derived from \`unlockDigits\` */
2095
+ const unlockCode = $derived(unlockDigits.join(''));
2096
+
2097
+ /** Cached user profile info (first/last name) for the welcome message */
2098
+ let userInfo = $state<{ firstName: string; lastName: string } | null>(null);
2099
+
2100
+ // =============================================================================
2101
+ // Link Device Mode State (new device, existing remote user)
2102
+ // =============================================================================
2103
+
2104
+ /** Individual digit values for the device-linking PIN */
2105
+ let linkDigits = $state(['', '', '', '', '', '']);
2106
+
2107
+ /** Concatenated link code — derived from \`linkDigits\` */
2108
+ const linkCode = $derived(linkDigits.join(''));
2109
+
2110
+ /**
2111
+ * Remote user info fetched from the gate config — contains email,
2112
+ * gate type, code length, and profile data for the welcome message.
2113
+ */
2114
+ let remoteUser = $state<{
2115
+ email: string;
2116
+ gateType: string;
2117
+ codeLength: number;
2118
+ profile: Record<string, unknown>;
2119
+ } | null>(null);
2120
+
2121
+ /** \`true\` when we detected a remote user and entered link-device mode */
2122
+ let linkMode = $state(false);
2123
+
2124
+ /** Loading state specific to the link-device flow */
2125
+ let linkLoading = $state(false);
2126
+
2127
+ /** \`true\` when offline and no local setup exists — shows offline card */
2128
+ let offlineNoSetup = $state(false);
2129
+
2130
+ // =============================================================================
2131
+ // Rate-Limit Countdown State
2132
+ // =============================================================================
2133
+
2134
+ /** Seconds remaining before the user can retry after a rate-limit */
2135
+ let retryCountdown = $state(0);
2136
+
2137
+ /** Interval handle for the retry countdown timer */
2138
+ let retryTimer: ReturnType<typeof setInterval> | null = null;
2139
+
2140
+ // =============================================================================
2141
+ // Modal State — Email Confirmation & Device Verification
2142
+ // =============================================================================
2143
+
2144
+ /** Show the "check your email" modal after initial signup */
2145
+ let showConfirmationModal = $state(false);
2146
+
2147
+ /** Show the "new device detected" verification modal */
2148
+ let showDeviceVerificationModal = $state(false);
2149
+
2150
+ /** Masked email address displayed in the device-verification modal */
2151
+ let maskedEmail = $state('');
2152
+
2153
+ /** Seconds remaining before the "resend" button re-enables */
2154
+ let resendCooldown = $state(0);
2155
+
2156
+ /** Interval handle for the resend cooldown timer */
2157
+ let resendTimer: ReturnType<typeof setInterval> | null = null;
2158
+
2159
+ /** Interval handle for polling device verification status */
2160
+ let verificationPollTimer: ReturnType<typeof setInterval> | null = null;
2161
+
2162
+ /** Guard flag to prevent double-execution of verification completion */
2163
+ let verificationCompleting = false; // guard against double execution
2164
+
2165
+ // =============================================================================
2166
+ // Input Refs — DOM references for focus management
2167
+ // =============================================================================
2168
+
2169
+ /** References to the 6 setup-code \`<input>\` elements */
2170
+ let codeInputs: HTMLInputElement[] = $state([]);
2171
+
2172
+ /** References to the 6 confirm-code \`<input>\` elements */
2173
+ let confirmInputs: HTMLInputElement[] = $state([]);
2174
+
2175
+ /** References to the 6 unlock-code \`<input>\` elements */
2176
+ let unlockInputs: HTMLInputElement[] = $state([]);
2177
+
2178
+ /** References to the link-code \`<input>\` elements */
2179
+ let linkInputs: HTMLInputElement[] = $state([]);
2180
+
2181
+ // =============================================================================
2182
+ // Cross-Tab Communication
2183
+ // =============================================================================
2184
+
2185
+ /** BroadcastChannel instance for receiving \`AUTH_CONFIRMED\` from \`/confirm\` */
2186
+ let authChannel: BroadcastChannel | null = null;
2187
+
2188
+ // =============================================================================
2189
+ // Lifecycle — onMount
2190
+ // =============================================================================
2191
+
2192
+ onMount(async () => {
2193
+ mounted = true;
2194
+
2195
+ /* ── Existing local account → fetch user info for the welcome card ──── */
2196
+ if (singleUserSetUp) {
2197
+ const info = await getSingleUserInfo();
2198
+ if (info) {
2199
+ userInfo = {
2200
+ firstName: (info.profile.firstName as string) || '',
2201
+ lastName: (info.profile.lastName as string) || ''
2202
+ };
2203
+ }
2204
+ } else {
2205
+ /* ── No local setup → check for a remote user to link to ──── */
2206
+ const isOffline = typeof navigator !== 'undefined' && !navigator.onLine;
2207
+ if (isOffline) {
2208
+ offlineNoSetup = true;
2209
+ } else {
2210
+ try {
2211
+ const remote = await fetchRemoteGateConfig();
2212
+ if (remote) {
2213
+ remoteUser = remote;
2214
+ linkMode = true;
2215
+ }
2216
+ } catch {
2217
+ /* No remote user found — fall through to normal setup */
2218
+ }
2219
+ }
2220
+ }
2221
+
2222
+ /* ── Listen for auth confirmation from the \`/confirm\` page ──── */
2223
+ try {
2224
+ authChannel = new BroadcastChannel('stellar-auth-channel');
2225
+ authChannel.onmessage = async (event) => {
2226
+ if (event.data?.type === 'AUTH_CONFIRMED') {
2227
+ /* Bring this tab to the foreground before the confirm tab closes */
2228
+ window.focus();
2229
+ if (showConfirmationModal) {
2230
+ /* Setup confirmation complete → finalize account */
2231
+ const result = await completeSingleUserSetup();
2232
+ if (!result.error) {
2233
+ showConfirmationModal = false;
2234
+ await invalidateAll();
2235
+ goto('/');
2236
+ } else {
2237
+ error = result.error;
2238
+ showConfirmationModal = false;
2239
+ }
2240
+ } else if (showDeviceVerificationModal) {
2241
+ /* Device verification complete (same-browser broadcast) */
2242
+ await handleVerificationComplete();
2243
+ }
2244
+ }
2245
+ };
2246
+ } catch {
2247
+ /* BroadcastChannel not supported — user must manually refresh */
2248
+ }
2249
+ });
2250
+
2251
+ // =============================================================================
2252
+ // Lifecycle — onDestroy (cleanup timers & channels)
2253
+ // =============================================================================
2254
+
2255
+ onDestroy(() => {
2256
+ authChannel?.close();
2257
+ if (resendTimer) clearInterval(resendTimer);
2258
+ if (retryTimer) clearInterval(retryTimer);
2259
+ stopVerificationPolling();
2260
+ });
2261
+
2262
+ // =============================================================================
2263
+ // Device Verification Polling
2264
+ // =============================================================================
2265
+
2266
+ /**
2267
+ * Start polling the engine every 3 seconds to check whether the
2268
+ * device has been trusted (the user clicked the email link on
2269
+ * another device/browser).
2270
+ */
2271
+ function startVerificationPolling() {
2272
+ stopVerificationPolling();
2273
+ verificationPollTimer = setInterval(async () => {
2274
+ if (verificationCompleting) return;
2275
+ const trusted = await pollDeviceVerification();
2276
+ if (trusted) {
2277
+ await handleVerificationComplete();
2278
+ }
2279
+ }, 3000);
2280
+ }
2281
+
2282
+ /**
2283
+ * Stop the verification polling interval and clear the handle.
2284
+ */
2285
+ function stopVerificationPolling() {
2286
+ if (verificationPollTimer) {
2287
+ clearInterval(verificationPollTimer);
2288
+ verificationPollTimer = null;
2289
+ }
2290
+ }
2291
+
2292
+ /**
2293
+ * Finalize device verification — calls \`completeDeviceVerification\`
2294
+ * and redirects on success. Guarded by \`verificationCompleting\` to
2295
+ * prevent double-execution from both polling and BroadcastChannel.
2296
+ */
2297
+ async function handleVerificationComplete() {
2298
+ if (verificationCompleting) return;
2299
+ verificationCompleting = true;
2300
+ stopVerificationPolling();
2301
+
2302
+ const result = await completeDeviceVerification();
2303
+ if (!result.error) {
2304
+ showDeviceVerificationModal = false;
2305
+ await invalidateAll();
2306
+ goto(redirectUrl);
2307
+ } else {
2308
+ error = result.error;
2309
+ showDeviceVerificationModal = false;
2310
+ verificationCompleting = false;
2311
+ }
2312
+ }
2313
+
2314
+ // =============================================================================
2315
+ // Resend & Retry Cooldowns
2316
+ // =============================================================================
2317
+
2318
+ /**
2319
+ * Start a 30-second cooldown on the "Resend email" button to
2320
+ * prevent spamming the email service.
2321
+ */
2322
+ function startResendCooldown() {
2323
+ resendCooldown = 30;
2324
+ if (resendTimer) clearInterval(resendTimer);
2325
+ resendTimer = setInterval(() => {
2326
+ resendCooldown--;
2327
+ if (resendCooldown <= 0 && resendTimer) {
2328
+ clearInterval(resendTimer);
2329
+ resendTimer = null;
2330
+ }
2331
+ }, 1000);
2332
+ }
2333
+
2334
+ /**
2335
+ * Start a countdown after receiving a rate-limit response from the
2336
+ * server. Disables the code inputs and auto-clears the error when
2337
+ * the countdown reaches zero.
2338
+ *
2339
+ * @param ms - The \`retryAfterMs\` value from the server response
2340
+ */
2341
+ function startRetryCountdown(ms: number) {
2342
+ retryCountdown = Math.ceil(ms / 1000);
2343
+ if (retryTimer) clearInterval(retryTimer);
2344
+ retryTimer = setInterval(() => {
2345
+ retryCountdown--;
2346
+ if (retryCountdown <= 0) {
2347
+ retryCountdown = 0;
2348
+ error = null;
2349
+ if (retryTimer) {
2350
+ clearInterval(retryTimer);
2351
+ retryTimer = null;
2352
+ }
2353
+ }
2354
+ }, 1000);
2355
+ }
2356
+
2357
+ // =============================================================================
2358
+ // Email Resend Handler
2359
+ // =============================================================================
2360
+
2361
+ /**
2362
+ * Resend the confirmation or verification email depending on
2363
+ * which modal is currently visible. Respects the resend cooldown.
2364
+ */
2365
+ async function handleResendEmail() {
2366
+ if (resendCooldown > 0) return;
2367
+ startResendCooldown();
2368
+ /* For setup confirmation → resend the signup email */
2369
+ if (showConfirmationModal) {
2370
+ const { resendConfirmationEmail } = await import('@prabhask5/stellar-engine');
2371
+ await resendConfirmationEmail(email);
2372
+ }
2373
+ /* For device verification → resend the OTP email */
2374
+ if (showDeviceVerificationModal) {
2375
+ const info = await getSingleUserInfo();
2376
+ if (info?.email) {
2377
+ await sendDeviceVerification(info.email);
2378
+ }
2379
+ }
2380
+ }
2381
+
2382
+ // =============================================================================
2383
+ // Digit Input Handlers — Shared across all PIN-code fields
2384
+ // =============================================================================
2385
+
2386
+ /**
2387
+ * Handle a single digit being typed into a PIN input box. Filters
2388
+ * non-numeric characters, auto-advances focus, and triggers
2389
+ * \`onComplete\` when the last digit is filled.
2390
+ *
2391
+ * @param digits - The reactive digit array being edited
2392
+ * @param index - Which position in the array this input represents
2393
+ * @param event - The native \`input\` DOM event
2394
+ * @param inputs - Array of \`HTMLInputElement\` refs for focus management
2395
+ * @param onComplete - Optional callback invoked when all digits are filled
2396
+ */
2397
+ function handleDigitInput(
2398
+ digits: string[],
2399
+ index: number,
2400
+ event: Event,
2401
+ inputs: HTMLInputElement[],
2402
+ onComplete?: () => void
2403
+ ) {
2404
+ const input = event.target as HTMLInputElement;
2405
+ const value = input.value.replace(/[^0-9]/g, '');
2406
+
2407
+ if (value.length > 0) {
2408
+ digits[index] = value.charAt(value.length - 1);
2409
+ input.value = digits[index];
2410
+ /* Auto-focus the next input box */
2411
+ if (index < digits.length - 1 && inputs[index + 1]) {
2412
+ inputs[index + 1].focus();
2413
+ }
2414
+ /* Auto-submit when the last digit is entered (brief delay for UX) */
2415
+ if (index === digits.length - 1 && onComplete && digits.every((d) => d !== '')) {
2416
+ setTimeout(() => onComplete(), 300);
2417
+ }
2418
+ } else {
2419
+ digits[index] = '';
2420
+ }
2421
+ }
2422
+
2423
+ /**
2424
+ * Handle backspace in a PIN input — moves focus to the previous
2425
+ * input when the current one is already empty.
2426
+ *
2427
+ * @param digits - The reactive digit array
2428
+ * @param index - Current position index
2429
+ * @param event - The native \`keydown\` event
2430
+ * @param inputs - Array of \`HTMLInputElement\` refs
2431
+ */
2432
+ function handleDigitKeydown(
2433
+ digits: string[],
2434
+ index: number,
2435
+ event: KeyboardEvent,
2436
+ inputs: HTMLInputElement[]
2437
+ ) {
2438
+ if (event.key === 'Backspace') {
2439
+ if (digits[index] === '' && index > 0 && inputs[index - 1]) {
2440
+ inputs[index - 1].focus();
2441
+ digits[index - 1] = '';
2442
+ } else {
2443
+ digits[index] = '';
2444
+ }
2445
+ }
2446
+ }
2447
+
2448
+ /**
2449
+ * Handle paste into a PIN input — distributes pasted digits across
2450
+ * all input boxes and auto-submits if the full code was pasted.
2451
+ *
2452
+ * @param digits - The reactive digit array
2453
+ * @param event - The native \`paste\` clipboard event
2454
+ * @param inputs - Array of \`HTMLInputElement\` refs
2455
+ * @param onComplete - Optional callback invoked when all digits are filled
2456
+ */
2457
+ function handleDigitPaste(
2458
+ digits: string[],
2459
+ event: ClipboardEvent,
2460
+ inputs: HTMLInputElement[],
2461
+ onComplete?: () => void
2462
+ ) {
2463
+ event.preventDefault();
2464
+ const pasted = (event.clipboardData?.getData('text') || '').replace(/[^0-9]/g, '');
2465
+ for (let i = 0; i < digits.length && i < pasted.length; i++) {
2466
+ digits[i] = pasted[i];
2467
+ if (inputs[i]) inputs[i].value = pasted[i];
2468
+ }
2469
+ const focusIndex = Math.min(pasted.length, digits.length - 1);
2470
+ if (inputs[focusIndex]) inputs[focusIndex].focus();
2471
+ /* Auto-submit if the full code was pasted at once */
2472
+ if (pasted.length >= digits.length && onComplete && digits.every((d) => d !== '')) {
2473
+ onComplete();
2474
+ }
2475
+ }
2476
+
2477
+ // =============================================================================
2478
+ // Setup Mode — Step Navigation
2479
+ // =============================================================================
2480
+
2481
+ /**
2482
+ * Validate email and first name, then advance to the PIN-creation
2483
+ * step (step 2). Shows an error if validation fails.
2484
+ */
2485
+ function goToCodeStep() {
2486
+ if (!email.trim() || !/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(email.trim())) {
2487
+ error = 'Please enter a valid email address';
2488
+ return;
2489
+ }
2490
+ if (!firstName.trim()) {
2491
+ error = 'First name is required';
2492
+ return;
2493
+ }
2494
+ error = null;
2495
+ setupStep = 2;
2496
+ }
2497
+
2498
+ /**
2499
+ * Navigate back from step 2 (PIN creation) to step 1 (email/name).
2500
+ */
2501
+ function goBackToNameStep() {
2502
+ setupStep = 1;
2503
+ error = null;
2504
+ }
2505
+
2506
+ /**
2507
+ * Auto-focus the first confirm-code input when the primary code
2508
+ * is fully entered.
2509
+ */
2510
+ function autoFocusConfirm() {
2511
+ if (confirmInputs[0]) confirmInputs[0].focus();
2512
+ }
2513
+
2514
+ /**
2515
+ * Trigger setup submission when the confirm-code auto-completes.
2516
+ */
2517
+ function autoSubmitSetup() {
2518
+ if (confirmDigits.every((d) => d !== '')) {
2519
+ handleSetup();
2520
+ }
2521
+ }
2522
+
2523
+ /**
2524
+ * Trigger unlock submission when the unlock-code auto-completes.
2525
+ */
2526
+ function autoSubmitUnlock() {
2527
+ handleUnlock();
2528
+ }
2529
+
2530
+ // =============================================================================
2531
+ // Setup Mode — Account Creation
2532
+ // =============================================================================
2533
+
2534
+ /**
2535
+ * Handle the full setup flow: validate the code matches its
2536
+ * confirmation, call \`setupSingleUser\`, and handle the response
2537
+ * (which may require email confirmation or succeed immediately).
2538
+ */
2539
+ async function handleSetup() {
2540
+ if (loading) return;
2541
+
2542
+ error = null;
2543
+
2544
+ if (code.length !== 6) {
2545
+ error = 'Please enter a 6-digit code';
2546
+ return;
2547
+ }
2548
+
2549
+ /* Verify code and confirmation match */
2550
+ if (code !== confirmCode) {
2551
+ error = 'Codes do not match';
2552
+ shaking = true;
2553
+ setTimeout(() => {
2554
+ shaking = false;
2555
+ }, 500);
2556
+ /* Clear confirm digits and refocus the first confirm input */
2557
+ confirmDigits = ['', '', '', '', '', ''];
2558
+ if (confirmInputs[0]) confirmInputs[0].focus();
2559
+ return;
2560
+ }
2561
+
2562
+ loading = true;
2563
+
2564
+ try {
2565
+ const result = await setupSingleUser(
2566
+ code,
2567
+ {
2568
+ firstName: firstName.trim(),
2569
+ lastName: lastName.trim()
2570
+ },
2571
+ email.trim()
2572
+ );
2573
+ if (result.error) {
2574
+ error = result.error;
2575
+ shaking = true;
2576
+ setTimeout(() => {
2577
+ shaking = false;
2578
+ }, 500);
2579
+ codeDigits = ['', '', '', '', '', ''];
2580
+ confirmDigits = ['', '', '', '', '', ''];
2581
+ if (codeInputs[0]) codeInputs[0].focus();
2582
+ return;
2583
+ }
2584
+ if (result.confirmationRequired) {
2585
+ /* Email confirmation needed → show the "check your email" modal */
2586
+ showConfirmationModal = true;
2587
+ startResendCooldown();
2588
+ return;
2589
+ }
2590
+ /* No confirmation needed → go straight to the app */
2591
+ await invalidateAll();
2592
+ goto('/');
2593
+ } catch (err: unknown) {
2594
+ error = err instanceof Error ? err.message : 'Setup failed. Please try again.';
2595
+ shaking = true;
2596
+ setTimeout(() => {
2597
+ shaking = false;
2598
+ }, 500);
2599
+ codeDigits = ['', '', '', '', '', ''];
2600
+ confirmDigits = ['', '', '', '', '', ''];
2601
+ if (codeInputs[0]) codeInputs[0].focus();
2602
+ } finally {
2603
+ loading = false;
2604
+ }
2605
+ }
2606
+
2607
+ // =============================================================================
2608
+ // Unlock Mode — PIN Entry for Returning Users
2609
+ // =============================================================================
2610
+
2611
+ /**
2612
+ * Attempt to unlock the local account with the entered 6-digit PIN.
2613
+ * Handles rate-limiting, device verification requirements, and
2614
+ * error feedback with shake animation.
2615
+ */
2616
+ async function handleUnlock() {
2617
+ if (loading || retryCountdown > 0) return;
2618
+
2619
+ error = null;
2620
+
2621
+ if (unlockCode.length !== 6) {
2622
+ error = 'Please enter your 6-digit code';
2623
+ return;
2624
+ }
2625
+
2626
+ loading = true;
2627
+
2628
+ try {
2629
+ const result = await unlockSingleUser(unlockCode);
2630
+ if (result.error) {
2631
+ error = result.error;
2632
+ if (result.retryAfterMs) {
2633
+ startRetryCountdown(result.retryAfterMs);
2634
+ }
2635
+ shaking = true;
2636
+ setTimeout(() => {
2637
+ shaking = false;
2638
+ }, 500);
2639
+ unlockDigits = ['', '', '', '', '', ''];
2640
+ return;
2641
+ }
2642
+ if (result.deviceVerificationRequired) {
2643
+ /* Untrusted device → show verification modal + start polling */
2644
+ maskedEmail = result.maskedEmail || '';
2645
+ showDeviceVerificationModal = true;
2646
+ startResendCooldown();
2647
+ startVerificationPolling();
2648
+ return;
2649
+ }
2650
+ /* Success → navigate to the redirect target */
2651
+ await invalidateAll();
2652
+ goto(redirectUrl);
2653
+ } catch (err: unknown) {
2654
+ error = err instanceof Error ? err.message : 'Incorrect code';
2655
+ shaking = true;
2656
+ setTimeout(() => {
2657
+ shaking = false;
2658
+ }, 500);
2659
+ unlockDigits = ['', '', '', '', '', ''];
2660
+ } finally {
2661
+ loading = false;
2662
+ if (error) {
2663
+ await tick();
2664
+ if (unlockInputs[0]) unlockInputs[0].focus();
2665
+ }
2666
+ }
2667
+ }
2668
+
2669
+ // =============================================================================
2670
+ // Link Device Mode — Connect a New Device to an Existing Account
2671
+ // =============================================================================
2672
+
2673
+ /**
2674
+ * Trigger link submission when the link-code auto-completes.
2675
+ */
2676
+ function autoSubmitLink() {
2677
+ if (linkDigits.every((d) => d !== '')) {
2678
+ handleLink();
2679
+ }
2680
+ }
2681
+
2682
+ /**
2683
+ * Attempt to link this device to the remote user account by
2684
+ * submitting the PIN. Similar flow to unlock — may require device
2685
+ * verification or trigger rate-limiting.
2686
+ */
2687
+ async function handleLink() {
2688
+ if (linkLoading || !remoteUser || retryCountdown > 0) return;
2689
+
2690
+ error = null;
2691
+
2692
+ if (linkCode.length !== remoteUser.codeLength) {
2693
+ error = \`Please enter a \${remoteUser.codeLength}-digit code\`;
2694
+ return;
2695
+ }
2696
+
2697
+ linkLoading = true;
2698
+ try {
2699
+ const result = await linkSingleUserDevice(remoteUser.email, linkCode);
2700
+ if (result.error) {
2701
+ error = result.error;
2702
+ if (result.retryAfterMs) {
2703
+ startRetryCountdown(result.retryAfterMs);
2704
+ }
2705
+ shaking = true;
2706
+ setTimeout(() => {
2707
+ shaking = false;
2708
+ }, 500);
2709
+ linkDigits = Array(remoteUser.codeLength).fill('');
2710
+ return;
2711
+ }
2712
+ if (result.deviceVerificationRequired) {
2713
+ maskedEmail = result.maskedEmail || '';
2714
+ showDeviceVerificationModal = true;
2715
+ startResendCooldown();
2716
+ startVerificationPolling();
2717
+ return;
2718
+ }
2719
+ /* Success → navigate to the redirect target */
2720
+ await invalidateAll();
2721
+ goto(redirectUrl);
2722
+ } catch (err: unknown) {
2723
+ error = err instanceof Error ? err.message : 'Incorrect code';
2724
+ shaking = true;
2725
+ setTimeout(() => {
2726
+ shaking = false;
2727
+ }, 500);
2728
+ linkDigits = Array(remoteUser.codeLength).fill('');
2729
+ } finally {
2730
+ linkLoading = false;
2731
+ if (error) {
2732
+ await tick();
2733
+ if (linkInputs[0]) linkInputs[0].focus();
2734
+ }
2735
+ }
2736
+ }
1075
2737
  </script>
1076
2738
 
2739
+ <svelte:head>
2740
+ <title>Login - ${opts.name}</title>
2741
+ </svelte:head>
2742
+
1077
2743
  <!-- TODO: Add login page template (PIN inputs, setup wizard, device verification modal) -->
1078
2744
  `;
1079
2745
  }
1080
- function generateConfirmPage() {
1081
- return `<script lang="ts">
2746
+ /**
2747
+ * Generate the email confirmation page component that handles token
2748
+ * verification and cross-tab broadcast.
2749
+ *
2750
+ * @returns The Svelte component source for `src/routes/confirm/+page.svelte`.
2751
+ */
2752
+ function generateConfirmPage(opts) {
2753
+ return `<!--
2754
+ @fileoverview Email confirmation page — token verification, BroadcastChannel
2755
+ relay, and close/redirect flow.
2756
+
2757
+ Supabase email links land here with \`?token_hash=...&type=...\` query
2758
+ params. The page verifies the token, broadcasts the result to the
2759
+ originating tab via BroadcastChannel, and either tells the user they
2760
+ can close the tab or redirects them to the app root.
2761
+ -->
2762
+ <script lang="ts">
1082
2763
  import { onMount } from 'svelte';
1083
2764
  import { goto } from '$app/navigation';
1084
2765
  import { page } from '$app/stores';
1085
2766
  import { handleEmailConfirmation, broadcastAuthConfirmed } from '@prabhask5/stellar-engine/kit';
1086
2767
 
2768
+ // ==========================================================================
2769
+ // STATE
2770
+ // ==========================================================================
2771
+
2772
+ /** Current page state — drives which UI variant is rendered. */
1087
2773
  let status: 'verifying' | 'success' | 'error' | 'redirecting' | 'can_close' = 'verifying';
2774
+
2775
+ /** Human-readable error message when verification fails. */
1088
2776
  let errorMessage = '';
1089
2777
 
1090
- const CHANNEL_NAME = 'auth-channel'; // TODO: Customize channel name
2778
+ // ==========================================================================
2779
+ // CONSTANTS
2780
+ // ==========================================================================
2781
+
2782
+ /** BroadcastChannel name shared with the login page. */
2783
+ const CHANNEL_NAME = '${opts.prefix}-auth-channel';
2784
+
2785
+ // ==========================================================================
2786
+ // LIFECYCLE
2787
+ // ==========================================================================
1091
2788
 
1092
2789
  onMount(async () => {
2790
+ /* ── Read Supabase callback params ── */
1093
2791
  const tokenHash = $page.url.searchParams.get('token_hash');
1094
2792
  const type = $page.url.searchParams.get('type');
1095
2793
 
2794
+ /* ── Token present → verify it ── */
1096
2795
  if (tokenHash && type) {
1097
2796
  const result = await handleEmailConfirmation(
1098
2797
  tokenHash,
@@ -1106,37 +2805,110 @@ function generateConfirmPage() {
1106
2805
  }
1107
2806
 
1108
2807
  status = 'success';
2808
+ /* Brief pause so the user sees the success state */
1109
2809
  await new Promise((resolve) => setTimeout(resolve, 800));
1110
2810
  }
1111
2811
 
2812
+ /* ── Notify the originating tab and decide next action ── */
1112
2813
  const tabResult = await broadcastAuthConfirmed(CHANNEL_NAME, type || 'signup');
1113
2814
  if (tabResult === 'can_close') {
1114
2815
  status = 'can_close';
1115
2816
  } else if (tabResult === 'no_broadcast') {
1116
- goto('/', { replaceState: true });
2817
+ focusOrRedirect();
1117
2818
  }
1118
2819
  });
2820
+
2821
+ // ==========================================================================
2822
+ // HELPERS
2823
+ // ==========================================================================
2824
+
2825
+ /**
2826
+ * Broadcast a confirmation event to any listening login tab, then
2827
+ * attempt to close this browser tab. Falls back to a static
2828
+ * "you can close this tab" message when \`window.close()\` is denied.
2829
+ */
2830
+ async function focusOrRedirect() {
2831
+ status = 'redirecting';
2832
+
2833
+ const type = $page.url.searchParams.get('type') || 'signup';
2834
+
2835
+ const result = await broadcastAuthConfirmed(CHANNEL_NAME, type);
2836
+
2837
+ if (result === 'no_broadcast') {
2838
+ /* BroadcastChannel unsupported — redirect to home directly */
2839
+ goto('/', { replaceState: true });
2840
+ } else {
2841
+ /* 'can_close' — window.close() was blocked by browser */
2842
+ setTimeout(() => {
2843
+ status = 'can_close';
2844
+ }, 200);
2845
+ }
2846
+ }
1119
2847
  </script>
1120
2848
 
2849
+ <svelte:head>
2850
+ <title>Confirming... - ${opts.name}</title>
2851
+ </svelte:head>
2852
+
1121
2853
  <!-- TODO: Add confirmation page template (verifying/success/error/can_close states) -->
1122
2854
  `;
1123
2855
  }
2856
+ // ---------------------------------------------------------------------------
2857
+ // API ENDPOINT GENERATORS
2858
+ // ---------------------------------------------------------------------------
2859
+ /**
2860
+ * Generate the `/api/config` server endpoint that returns runtime config.
2861
+ *
2862
+ * @returns The TypeScript source for `src/routes/api/config/+server.ts`.
2863
+ */
1124
2864
  function generateConfigServer() {
1125
- return `import { json } from '@sveltejs/kit';
2865
+ return `/**
2866
+ * Config API Endpoint — \`GET /api/config\`
2867
+ *
2868
+ * Returns the runtime configuration object (Supabase URL, anon key, app
2869
+ * settings) that the client fetches on first load via \`initConfig()\`.
2870
+ */
2871
+
2872
+ import { json } from '@sveltejs/kit';
1126
2873
  import { getServerConfig } from '@prabhask5/stellar-engine/kit';
1127
2874
  import type { RequestHandler } from './$types';
1128
2875
 
2876
+ /**
2877
+ * Serve the runtime config as JSON.
2878
+ *
2879
+ * @returns A JSON response containing the server-side config object.
2880
+ */
1129
2881
  export const GET: RequestHandler = async () => {
1130
2882
  return json(getServerConfig());
1131
2883
  };
1132
2884
  `;
1133
2885
  }
2886
+ /**
2887
+ * Generate the `/api/setup/deploy` server endpoint for Vercel deployment.
2888
+ *
2889
+ * @returns The TypeScript source for `src/routes/api/setup/deploy/+server.ts`.
2890
+ */
1134
2891
  function generateDeployServer() {
1135
- return `import { json } from '@sveltejs/kit';
2892
+ return `/**
2893
+ * Vercel Deploy API Endpoint — \`POST /api/setup/deploy\`
2894
+ *
2895
+ * Accepts Supabase credentials and a Vercel token, then sets the
2896
+ * corresponding environment variables on the Vercel project and triggers
2897
+ * a redeployment so the new config takes effect.
2898
+ */
2899
+
2900
+ import { json } from '@sveltejs/kit';
1136
2901
  import { deployToVercel } from '@prabhask5/stellar-engine/kit';
1137
2902
  import type { RequestHandler } from './$types';
1138
2903
 
2904
+ /**
2905
+ * Deploy Supabase credentials to Vercel environment variables.
2906
+ *
2907
+ * @param params - SvelteKit request event.
2908
+ * @returns JSON result with success/failure and optional error message.
2909
+ */
1139
2910
  export const POST: RequestHandler = async ({ request }) => {
2911
+ /* ── Parse and validate request body ── */
1140
2912
  const { supabaseUrl, supabaseAnonKey, vercelToken } = await request.json();
1141
2913
 
1142
2914
  if (!supabaseUrl || !supabaseAnonKey || !vercelToken) {
@@ -1146,6 +2918,7 @@ export const POST: RequestHandler = async ({ request }) => {
1146
2918
  );
1147
2919
  }
1148
2920
 
2921
+ /* ── Ensure we're running on Vercel ── */
1149
2922
  const projectId = process.env.VERCEL_PROJECT_ID;
1150
2923
  if (!projectId) {
1151
2924
  return json(
@@ -1154,59 +2927,140 @@ export const POST: RequestHandler = async ({ request }) => {
1154
2927
  );
1155
2928
  }
1156
2929
 
2930
+ /* ── Delegate to engine ── */
1157
2931
  const result = await deployToVercel({ vercelToken, projectId, supabaseUrl, supabaseAnonKey });
1158
2932
  return json(result);
1159
2933
  };
1160
2934
  `;
1161
2935
  }
2936
+ /**
2937
+ * Generate the `/api/setup/validate` server endpoint for Supabase credential validation.
2938
+ *
2939
+ * @returns The TypeScript source for `src/routes/api/setup/validate/+server.ts`.
2940
+ */
1162
2941
  function generateValidateServer() {
1163
- return `import { createValidateHandler } from '@prabhask5/stellar-engine/kit';
2942
+ return `/**
2943
+ * Supabase Credential Validation Endpoint — \`POST /api/setup/validate\`
2944
+ *
2945
+ * Accepts a Supabase URL and anon key, attempts a lightweight query
2946
+ * against the project, and returns whether the credentials are valid.
2947
+ * Used by the setup wizard before saving config.
2948
+ */
2949
+
2950
+ import { createValidateHandler } from '@prabhask5/stellar-engine/kit';
1164
2951
  import type { RequestHandler } from './$types';
1165
2952
 
2953
+ /** Validate Supabase credentials — delegates to stellar-engine's handler factory. */
1166
2954
  export const POST: RequestHandler = createValidateHandler();
1167
2955
  `;
1168
2956
  }
2957
+ // ---------------------------------------------------------------------------
2958
+ // CATCHALL & PROTECTED LAYOUT GENERATORS
2959
+ // ---------------------------------------------------------------------------
2960
+ /**
2961
+ * Generate a catch-all route that redirects unknown paths to the home page.
2962
+ *
2963
+ * @returns The TypeScript source for `src/routes/[...catchall]/+page.ts`.
2964
+ */
1169
2965
  function generateCatchallPage() {
1170
- return `import { redirect } from '@sveltejs/kit';
2966
+ return `/**
2967
+ * Catch-All Route Handler — \`[...catchall]/+page.ts\`
2968
+ *
2969
+ * Matches any URL that doesn't correspond to a defined route and
2970
+ * redirects the user back to the home page. Prevents 404 errors
2971
+ * for deep links that no longer exist.
2972
+ */
2973
+
2974
+ import { redirect } from '@sveltejs/kit';
1171
2975
 
2976
+ /**
2977
+ * Redirect unknown paths to the app root.
2978
+ */
1172
2979
  export function load() {
1173
2980
  redirect(302, '/');
1174
2981
  }
1175
2982
  `;
1176
2983
  }
2984
+ /**
2985
+ * Generate the protected route group's `+layout.ts` with auth guards
2986
+ * that redirect unauthenticated users to `/login`.
2987
+ *
2988
+ * @returns The TypeScript source for `src/routes/(protected)/+layout.ts`.
2989
+ */
1177
2990
  function generateProtectedLayoutTs() {
1178
- return `import { redirect } from '@sveltejs/kit';
2991
+ return `/**
2992
+ * @fileoverview Protected Layout Load Function — Auth Guard
2993
+ *
2994
+ * Runs on every navigation into the \`(protected)\` route group.
2995
+ * Resolves the current authentication state via \`stellar-engine\` and
2996
+ * redirects unauthenticated users to \`/login\` (preserving the intended
2997
+ * destination as a \`?redirect=\` query parameter).
2998
+ *
2999
+ * On the server (SSR), returns a neutral "unauthenticated" payload so
3000
+ * that the actual auth check happens exclusively in the browser where
3001
+ * cookies / local storage are available.
3002
+ */
3003
+
3004
+ import { redirect } from '@sveltejs/kit';
1179
3005
  import { browser } from '$app/environment';
1180
- import { resolveAuthState } from '@prabhask5/stellar-engine/auth';
1181
- import type { AuthMode, OfflineCredentials, Session } from '@prabhask5/stellar-engine/types';
3006
+ import { resolveProtectedLayout } from '@prabhask5/stellar-engine/kit';
3007
+ import type { ProtectedLayoutData } from '@prabhask5/stellar-engine/kit';
1182
3008
  import type { LayoutLoad } from './$types';
1183
3009
 
1184
- export interface ProtectedLayoutData {
1185
- session: Session | null;
1186
- authMode: AuthMode;
1187
- offlineProfile: OfflineCredentials | null;
1188
- }
3010
+ export type { ProtectedLayoutData };
1189
3011
 
3012
+ /**
3013
+ * SvelteKit universal \`load\` function for the \`(protected)\` layout.
3014
+ *
3015
+ * - **Browser**: resolves the auth state; redirects to \`/login\` if \`authMode\` is \`'none'\`.
3016
+ * - **Server**: short-circuits with a neutral payload (auth is client-side only).
3017
+ *
3018
+ * @param url — The current page URL, used to build the redirect target.
3019
+ * @returns Resolved \`ProtectedLayoutData\` for downstream pages and layouts.
3020
+ */
1190
3021
  export const load: LayoutLoad = async ({ url }): Promise<ProtectedLayoutData> => {
1191
3022
  if (browser) {
1192
- const result = await resolveAuthState();
1193
- if (result.authMode === 'none') {
1194
- const returnUrl = url.pathname + url.search;
1195
- const loginUrl =
1196
- returnUrl && returnUrl !== '/'
1197
- ? \`/login?redirect=\${encodeURIComponent(returnUrl)}\`
1198
- : '/login';
1199
- throw redirect(302, loginUrl);
3023
+ const { data, redirectUrl } = await resolveProtectedLayout(url);
3024
+
3025
+ if (redirectUrl) {
3026
+ throw redirect(302, redirectUrl);
1200
3027
  }
1201
- return result;
3028
+
3029
+ return data;
1202
3030
  }
3031
+
3032
+ /* SSR fallback — no auth info available on the server */
1203
3033
  return { session: null, authMode: 'none', offlineProfile: null };
1204
3034
  };
1205
3035
  `;
1206
3036
  }
3037
+ /**
3038
+ * Generate the protected route group's `+layout.svelte` pass-through component.
3039
+ *
3040
+ * @returns The Svelte component source for `src/routes/(protected)/+layout.svelte`.
3041
+ */
1207
3042
  function generateProtectedLayoutSvelte() {
1208
- return `<script lang="ts">
3043
+ return `<!--
3044
+ @fileoverview Protected Layout Component — wraps the \`(protected)\` route group.
3045
+
3046
+ Every page inside \`src/routes/(protected)/\` inherits this layout. The auth
3047
+ guard lives in \`+layout.ts\`; this component is a pass-through that renders
3048
+ the child page and provides a hook for protected-area chrome (backgrounds,
3049
+ breadcrumbs, etc.).
3050
+ -->
3051
+ <script lang="ts">
3052
+ // ==========================================================================
3053
+ // IMPORTS
3054
+ // ==========================================================================
3055
+
3056
+ // (no additional imports needed for the pass-through layout)
3057
+
3058
+ // ==========================================================================
3059
+ // PROPS
3060
+ // ==========================================================================
3061
+
1209
3062
  interface Props {
3063
+ /** Default slot content (the routed page component). */
1210
3064
  children?: import('svelte').Snippet;
1211
3065
  }
1212
3066
 
@@ -1215,18 +3069,42 @@ function generateProtectedLayoutSvelte() {
1215
3069
  // TODO: Add conditional page backgrounds or other protected-area chrome
1216
3070
  </script>
1217
3071
 
3072
+ <!-- Render child route content -->
1218
3073
  {@render children?.()}
1219
3074
  `;
1220
3075
  }
1221
- function generateProfilePage() {
1222
- return `<script lang="ts">
3076
+ /**
3077
+ * Generate the profile page component with TODO stubs for user settings,
3078
+ * device management, and debug tools.
3079
+ *
3080
+ * @returns The Svelte component source for `src/routes/(protected)/profile/+page.svelte`.
3081
+ */
3082
+ function generateProfilePage(opts) {
3083
+ return `<!--
3084
+ @fileoverview Profile & settings page.
3085
+
3086
+ Capabilities:
3087
+ - View / edit display name and avatar
3088
+ - Change email address (with re-verification)
3089
+ - Change unlock gate type (PIN length, pattern, etc.)
3090
+ - Manage trusted devices (view, revoke)
3091
+ - Toggle debug mode
3092
+ - Reset local database (destructive — requires confirmation)
3093
+ -->
3094
+ <script lang="ts">
3095
+ // =============================================================================
3096
+ // IMPORTS
3097
+ // =============================================================================
3098
+
1223
3099
  import { goto } from '$app/navigation';
1224
3100
  import {
1225
3101
  changeSingleUserGate,
1226
3102
  updateSingleUserProfile,
1227
3103
  getSingleUserInfo,
1228
3104
  changeSingleUserEmail,
1229
- completeSingleUserEmailChange
3105
+ completeSingleUserEmailChange,
3106
+ resolveUserId,
3107
+ resolveAvatarInitial
1230
3108
  } from '@prabhask5/stellar-engine/auth';
1231
3109
  import { authState } from '@prabhask5/stellar-engine/stores';
1232
3110
  import { isDebugMode, setDebugMode } from '@prabhask5/stellar-engine/utils';
@@ -1236,33 +3114,667 @@ function generateProfilePage() {
1236
3114
  removeTrustedDevice,
1237
3115
  getCurrentDeviceId
1238
3116
  } from '@prabhask5/stellar-engine';
3117
+ import type { TrustedDevice } from '@prabhask5/stellar-engine';
3118
+ import { onMount } from 'svelte';
3119
+
3120
+ // =============================================================================
3121
+ // COMPONENT STATE
3122
+ // =============================================================================
3123
+
3124
+ /* ── Profile form fields ──── */
3125
+ let firstName = $state('');
3126
+ let lastName = $state('');
3127
+
3128
+ /* ── Gate (6-digit code) change — digit-array approach ──── */
3129
+ let oldCodeDigits = $state(['', '', '', '', '', '']);
3130
+ let newCodeDigits = $state(['', '', '', '', '', '']);
3131
+ let confirmCodeDigits = $state(['', '', '', '', '', '']);
3132
+
3133
+ /** Concatenated old code string → derived from individual digit inputs */
3134
+ const oldCode = $derived(oldCodeDigits.join(''));
3135
+ /** Concatenated new code string → derived from individual digit inputs */
3136
+ const newCode = $derived(newCodeDigits.join(''));
3137
+ /** Concatenated confirm code string — must match \`newCode\` */
3138
+ const confirmNewCode = $derived(confirmCodeDigits.join(''));
3139
+
3140
+ /* ── Input element refs for auto-focus advancement ──── */
3141
+ let oldCodeInputs: HTMLInputElement[] = $state([]);
3142
+ let newCodeInputs: HTMLInputElement[] = $state([]);
3143
+ let confirmCodeInputs: HTMLInputElement[] = $state([]);
3144
+
3145
+ /* ── Email change fields ──── */
3146
+ let currentEmail = $state('');
3147
+ let newEmail = $state('');
3148
+ let emailLoading = $state(false);
3149
+ let emailError = $state<string | null>(null);
3150
+ let emailSuccess = $state<string | null>(null);
3151
+ /** Whether the email confirmation modal overlay is visible */
3152
+ let showEmailConfirmationModal = $state(false);
3153
+ /** Seconds remaining before the user can re-send the confirmation email */
3154
+ let emailResendCooldown = $state(0);
3155
+
3156
+ /* ── General UI / feedback state ──── */
3157
+ let profileLoading = $state(false);
3158
+ let codeLoading = $state(false);
3159
+ let profileError = $state<string | null>(null);
3160
+ let profileSuccess = $state<string | null>(null);
3161
+ let codeError = $state<string | null>(null);
3162
+ let codeSuccess = $state<string | null>(null);
3163
+ let debugMode = $state(isDebugMode());
3164
+ let resetting = $state(false);
3165
+
3166
+ /* ── Debug tools loading flags ──── */
3167
+ let forceSyncing = $state(false);
3168
+ let triggeringSyncManual = $state(false);
3169
+ let resettingCursor = $state(false);
3170
+ let checkingConnection = $state(false);
3171
+ let viewingTombstones = $state(false);
3172
+ let cleaningTombstones = $state(false);
3173
+
3174
+ /* ── Trusted devices ──── */
3175
+ let trustedDevices = $state<TrustedDevice[]>([]);
3176
+ let currentDeviceId = $state('');
3177
+ let devicesLoading = $state(true);
3178
+ /** ID of the device currently being removed — shows spinner on that row */
3179
+ let removingDeviceId = $state<string | null>(null);
3180
+
3181
+ // =============================================================================
3182
+ // LIFECYCLE
3183
+ // =============================================================================
3184
+
3185
+ /** Populate form fields from the engine and load trusted devices on mount. */
3186
+ onMount(async () => {
3187
+ const info = await getSingleUserInfo();
3188
+ if (info) {
3189
+ firstName = (info.profile.firstName as string) || '';
3190
+ lastName = (info.profile.lastName as string) || '';
3191
+ currentEmail = info.email || '';
3192
+ }
3193
+
3194
+ // Load trusted devices
3195
+ currentDeviceId = getCurrentDeviceId();
3196
+ try {
3197
+ const userId = resolveUserId($authState?.session, $authState?.offlineProfile);
3198
+ if (userId) {
3199
+ trustedDevices = await getTrustedDevices(userId);
3200
+ }
3201
+ } catch {
3202
+ // Ignore errors loading devices
3203
+ }
3204
+ devicesLoading = false;
3205
+ });
3206
+
3207
+ // =============================================================================
3208
+ // DIGIT INPUT HELPERS
3209
+ // =============================================================================
3210
+
3211
+ /**
3212
+ * Handle single-digit input in a code field.
3213
+ * Auto-advances focus to the next input when a digit is entered.
3214
+ * @param digits - Reactive digit array to mutate
3215
+ * @param index - Position in the 6-digit code (0–5)
3216
+ * @param event - Native input event
3217
+ * @param inputs - Array of \`<input>\` refs for focus management
3218
+ */
3219
+ function handleDigitInput(
3220
+ digits: string[],
3221
+ index: number,
3222
+ event: Event,
3223
+ inputs: HTMLInputElement[]
3224
+ ) {
3225
+ const input = event.target as HTMLInputElement;
3226
+ const value = input.value.replace(/[^0-9]/g, '');
3227
+ if (value.length > 0) {
3228
+ digits[index] = value.charAt(value.length - 1);
3229
+ input.value = digits[index];
3230
+ if (index < 5 && inputs[index + 1]) {
3231
+ inputs[index + 1].focus();
3232
+ }
3233
+ } else {
3234
+ digits[index] = '';
3235
+ }
3236
+ }
3237
+
3238
+ /**
3239
+ * Handle Backspace in a digit field — moves focus backward when the current
3240
+ * digit is already empty.
3241
+ * @param digits - Reactive digit array to mutate
3242
+ * @param index - Position in the 6-digit code (0–5)
3243
+ * @param event - Native keyboard event
3244
+ * @param inputs - Array of \`<input>\` refs for focus management
3245
+ */
3246
+ function handleDigitKeydown(
3247
+ digits: string[],
3248
+ index: number,
3249
+ event: KeyboardEvent,
3250
+ inputs: HTMLInputElement[]
3251
+ ) {
3252
+ if (event.key === 'Backspace') {
3253
+ if (digits[index] === '' && index > 0 && inputs[index - 1]) {
3254
+ inputs[index - 1].focus();
3255
+ digits[index - 1] = '';
3256
+ } else {
3257
+ digits[index] = '';
3258
+ }
3259
+ }
3260
+ }
3261
+
3262
+ /**
3263
+ * Handle paste into a digit field — distributes pasted digits across all 6 inputs.
3264
+ * @param digits - Reactive digit array to mutate
3265
+ * @param event - Native clipboard event
3266
+ * @param inputs - Array of \`<input>\` refs for focus management
3267
+ */
3268
+ function handleDigitPaste(digits: string[], event: ClipboardEvent, inputs: HTMLInputElement[]) {
3269
+ event.preventDefault();
3270
+ const pasted = (event.clipboardData?.getData('text') || '').replace(/[^0-9]/g, '');
3271
+ for (let i = 0; i < 6 && i < pasted.length; i++) {
3272
+ digits[i] = pasted[i];
3273
+ if (inputs[i]) inputs[i].value = pasted[i];
3274
+ }
3275
+ const focusIndex = Math.min(pasted.length, 5);
3276
+ if (inputs[focusIndex]) inputs[focusIndex].focus();
3277
+ }
3278
+
3279
+ // =============================================================================
3280
+ // FORM SUBMISSION HANDLERS
3281
+ // =============================================================================
3282
+
3283
+ /**
3284
+ * Submit profile name changes to the engine and update the auth store
3285
+ * so the navbar reflects changes immediately.
3286
+ * @param e - Form submit event
3287
+ */
3288
+ async function handleProfileSubmit(e: Event) {
3289
+ e.preventDefault();
3290
+ profileLoading = true;
3291
+ profileError = null;
3292
+ profileSuccess = null;
3293
+
3294
+ try {
3295
+ await updateSingleUserProfile({
3296
+ firstName: firstName.trim(),
3297
+ lastName: lastName.trim()
3298
+ });
3299
+ // Update auth state to immediately reflect changes in navbar
3300
+ authState.updateUserProfile({ first_name: firstName.trim(), last_name: lastName.trim() });
3301
+ profileSuccess = 'Profile updated successfully';
3302
+ setTimeout(() => (profileSuccess = null), 3000);
3303
+ } catch (err: unknown) {
3304
+ profileError = err instanceof Error ? err.message : 'Failed to update profile';
3305
+ }
3306
+
3307
+ profileLoading = false;
3308
+ }
3309
+
3310
+ /**
3311
+ * Validate and submit a 6-digit gate code change.
3312
+ * Resets all digit arrays on success.
3313
+ * @param e - Form submit event
3314
+ */
3315
+ async function handleCodeSubmit(e: Event) {
3316
+ e.preventDefault();
3317
+
3318
+ if (oldCode.length !== 6) {
3319
+ codeError = 'Please enter your current 6-digit code';
3320
+ return;
3321
+ }
3322
+
3323
+ if (newCode.length !== 6) {
3324
+ codeError = 'Please enter a new 6-digit code';
3325
+ return;
3326
+ }
3327
+
3328
+ if (newCode !== confirmNewCode) {
3329
+ codeError = 'New codes do not match';
3330
+ return;
3331
+ }
3332
+
3333
+ codeLoading = true;
3334
+ codeError = null;
3335
+ codeSuccess = null;
3336
+
3337
+ try {
3338
+ await changeSingleUserGate(oldCode, newCode);
3339
+ codeSuccess = 'Code changed successfully';
3340
+ oldCodeDigits = ['', '', '', '', '', ''];
3341
+ newCodeDigits = ['', '', '', '', '', ''];
3342
+ confirmCodeDigits = ['', '', '', '', '', ''];
3343
+ setTimeout(() => (codeSuccess = null), 3000);
3344
+ } catch (err: unknown) {
3345
+ codeError = err instanceof Error ? err.message : 'Failed to change code';
3346
+ }
3347
+
3348
+ codeLoading = false;
3349
+ }
3350
+
3351
+ // =============================================================================
3352
+ // EMAIL CHANGE FLOW
3353
+ // =============================================================================
3354
+
3355
+ /**
3356
+ * Initiate an email change — sends a confirmation link to the new address.
3357
+ * Opens the confirmation modal and starts listening for the cross-tab
3358
+ * \`BroadcastChannel\` auth event.
3359
+ * @param e - Form submit event
3360
+ */
3361
+ async function handleEmailSubmit(e: Event) {
3362
+ e.preventDefault();
3363
+ emailError = null;
3364
+ emailSuccess = null;
3365
+
3366
+ if (!newEmail.trim()) {
3367
+ emailError = 'Please enter a new email address';
3368
+ return;
3369
+ }
3370
+
3371
+ if (newEmail.trim() === currentEmail) {
3372
+ emailError = 'New email is the same as your current email';
3373
+ return;
3374
+ }
3375
+
3376
+ emailLoading = true;
3377
+
3378
+ try {
3379
+ const result = await changeSingleUserEmail(newEmail.trim());
3380
+ if (result.error) {
3381
+ emailError = result.error;
3382
+ } else if (result.confirmationRequired) {
3383
+ showEmailConfirmationModal = true;
3384
+ startResendCooldown();
3385
+ listenForEmailConfirmation();
3386
+ }
3387
+ } catch (err: unknown) {
3388
+ emailError = err instanceof Error ? err.message : 'Failed to change email';
3389
+ }
3390
+
3391
+ emailLoading = false;
3392
+ }
3393
+
3394
+ /** Start a 30-second countdown preventing repeated confirmation emails. */
3395
+ function startResendCooldown() {
3396
+ emailResendCooldown = 30;
3397
+ const interval = setInterval(() => {
3398
+ emailResendCooldown--;
3399
+ if (emailResendCooldown <= 0) clearInterval(interval);
3400
+ }, 1000);
3401
+ }
3402
+
3403
+ /** Re-send the email change confirmation (guarded by cooldown). */
3404
+ async function handleResendEmailChange() {
3405
+ if (emailResendCooldown > 0) return;
3406
+ try {
3407
+ await changeSingleUserEmail(newEmail.trim());
3408
+ startResendCooldown();
3409
+ } catch {
3410
+ // Ignore resend errors
3411
+ }
3412
+ }
3413
+
3414
+ /**
3415
+ * Listen on a \`BroadcastChannel\` for the confirmation tab to signal
3416
+ * that the user clicked the email-change link. Once received, complete
3417
+ * the email change server-side and update local state.
3418
+ */
3419
+ function listenForEmailConfirmation() {
3420
+ if (!('BroadcastChannel' in window)) return;
3421
+ const channel = new BroadcastChannel('stellar-auth-channel');
3422
+ channel.onmessage = async (event) => {
3423
+ if (
3424
+ event.data?.type === 'AUTH_CONFIRMED' &&
3425
+ event.data?.verificationType === 'email_change'
3426
+ ) {
3427
+ // Bring this tab to the foreground before the confirm tab closes
3428
+ window.focus();
3429
+ const result = await completeSingleUserEmailChange();
3430
+ if (!result.error && result.newEmail) {
3431
+ currentEmail = result.newEmail;
3432
+ emailSuccess = 'Email changed successfully';
3433
+ newEmail = '';
3434
+ setTimeout(() => (emailSuccess = null), 5000);
3435
+ } else {
3436
+ emailError = result.error || 'Failed to complete email change';
3437
+ }
3438
+ showEmailConfirmationModal = false;
3439
+ channel.close();
3440
+ }
3441
+ };
3442
+ }
3443
+
3444
+ /** Close the email confirmation modal without completing the change. */
3445
+ function dismissEmailModal() {
3446
+ showEmailConfirmationModal = false;
3447
+ }
3448
+
3449
+ // =============================================================================
3450
+ // ADMINISTRATION HANDLERS
3451
+ // =============================================================================
3452
+
3453
+ /** Toggle debug mode on/off — requires a page refresh to take full effect. */
3454
+ function toggleDebugMode() {
3455
+ debugMode = !debugMode;
3456
+ setDebugMode(debugMode);
3457
+ }
3458
+
3459
+ /** Navigate back to the main tasks view. */
3460
+ function goBack() {
3461
+ goto('/tasks');
3462
+ }
3463
+
3464
+ /**
3465
+ * Delete and recreate the local IndexedDB, then reload the page.
3466
+ * Session is preserved in localStorage so the app will re-hydrate.
3467
+ */
3468
+ async function handleResetDatabase() {
3469
+ if (
3470
+ !confirm(
3471
+ 'This will delete all local data and reload. Your data will be re-synced from the server. Continue?'
3472
+ )
3473
+ ) {
3474
+ return;
3475
+ }
3476
+ resetting = true;
3477
+ try {
3478
+ await resetDatabase();
3479
+ // Reload the page — session is preserved in localStorage, so the app
3480
+ // will re-create the DB, fetch config from Supabase, and re-hydrate.
3481
+ window.location.reload();
3482
+ } catch (err) {
3483
+ alert('Reset failed: ' + (err instanceof Error ? err.message : 'Unknown error'));
3484
+ resetting = false;
3485
+ }
3486
+ }
3487
+
3488
+ /**
3489
+ * Remove a trusted device by ID and update the local list.
3490
+ * @param id - Database ID of the trusted device row
3491
+ */
3492
+ async function handleRemoveDevice(id: string) {
3493
+ removingDeviceId = id;
3494
+ try {
3495
+ await removeTrustedDevice(id);
3496
+ trustedDevices = trustedDevices.filter((d) => d.id !== id);
3497
+ } catch {
3498
+ // Ignore errors
3499
+ }
3500
+ removingDeviceId = null;
3501
+ }
3502
+
3503
+ // =============================================================================
3504
+ // DEBUG TOOL HANDLERS
3505
+ // =============================================================================
3506
+
3507
+ /**
3508
+ * Cast \`window\` to an untyped record for accessing runtime-injected
3509
+ * debug helpers (e.g., \`__${opts.prefix}Sync\`, \`__${opts.prefix}SyncStats\`).
3510
+ * @returns The global \`window\` as a loose \`Record\`
3511
+ */
3512
+ function getDebugWindow(): Record<string, unknown> {
3513
+ return window as unknown as Record<string, unknown>;
3514
+ }
3515
+
3516
+ /** Resets the sync cursor and re-downloads all data from Supabase. */
3517
+ async function handleForceFullSync() {
3518
+ if (
3519
+ !confirm(
3520
+ 'This will reset the sync cursor and re-download all data from the server. Continue?'
3521
+ )
3522
+ )
3523
+ return;
3524
+ forceSyncing = true;
3525
+ try {
3526
+ const fn = getDebugWindow().__${opts.prefix}Sync as
3527
+ | { forceFullSync: () => Promise<void> }
3528
+ | undefined;
3529
+ if (fn?.forceFullSync) {
3530
+ await fn.forceFullSync();
3531
+ alert('Force full sync complete.');
3532
+ } else {
3533
+ alert('Debug mode must be enabled and the page refreshed to use this tool.');
3534
+ }
3535
+ } catch (err) {
3536
+ alert('Force full sync failed: ' + (err instanceof Error ? err.message : 'Unknown error'));
3537
+ }
3538
+ forceSyncing = false;
3539
+ }
3540
+
3541
+ /** Manually trigger a single push/pull sync cycle. */
3542
+ async function handleTriggerSync() {
3543
+ triggeringSyncManual = true;
3544
+ try {
3545
+ const fn = getDebugWindow().__${opts.prefix}Sync as { sync: () => Promise<void> } | undefined;
3546
+ if (fn?.sync) {
3547
+ await fn.sync();
3548
+ alert('Sync cycle complete.');
3549
+ } else {
3550
+ alert('Debug mode must be enabled and the page refreshed to use this tool.');
3551
+ }
3552
+ } catch (err) {
3553
+ alert('Sync failed: ' + (err instanceof Error ? err.message : 'Unknown error'));
3554
+ }
3555
+ triggeringSyncManual = false;
3556
+ }
3557
+
3558
+ /** Reset the sync cursor so the next cycle pulls all remote data. */
3559
+ async function handleResetSyncCursor() {
3560
+ resettingCursor = true;
3561
+ try {
3562
+ const fn = getDebugWindow().__${opts.prefix}Sync as
3563
+ | { resetSyncCursor: () => Promise<void> }
3564
+ | undefined;
3565
+ if (fn?.resetSyncCursor) {
3566
+ await fn.resetSyncCursor();
3567
+ alert('Sync cursor reset. The next sync will pull all data.');
3568
+ } else {
3569
+ alert('Debug mode must be enabled and the page refreshed to use this tool.');
3570
+ }
3571
+ } catch (err) {
3572
+ alert('Reset cursor failed: ' + (err instanceof Error ? err.message : 'Unknown error'));
3573
+ }
3574
+ resettingCursor = false;
3575
+ }
3576
+
3577
+ /** Test connectivity to Supabase and show the result in an alert. */
3578
+ async function handleCheckConnection() {
3579
+ checkingConnection = true;
3580
+ try {
3581
+ const fn = getDebugWindow().__${opts.prefix}Sync as
3582
+ | {
3583
+ checkConnection: () => Promise<{
3584
+ connected: boolean;
3585
+ error?: string;
3586
+ records?: number;
3587
+ }>;
3588
+ }
3589
+ | undefined;
3590
+ if (fn?.checkConnection) {
3591
+ const result = await fn.checkConnection();
3592
+ if (result.connected) {
3593
+ alert('Connection OK. Supabase is reachable.');
3594
+ } else {
3595
+ alert('Connection failed: ' + (result.error || 'Unknown error'));
3596
+ }
3597
+ } else {
3598
+ alert('Debug mode must be enabled and the page refreshed to use this tool.');
3599
+ }
3600
+ } catch (err) {
3601
+ alert('Connection check failed: ' + (err instanceof Error ? err.message : 'Unknown error'));
3602
+ }
3603
+ checkingConnection = false;
3604
+ }
3605
+
3606
+ /** Display current sync cursor and pending operations count in an alert. */
3607
+ function handleGetSyncStatus() {
3608
+ const fn = getDebugWindow().__${opts.prefix}Sync as
3609
+ | { getStatus: () => { cursor: unknown; pendingOps: Promise<number> } }
3610
+ | undefined;
3611
+ if (fn?.getStatus) {
3612
+ const status = fn.getStatus();
3613
+ const cursorDisplay =
3614
+ typeof status.cursor === 'object'
3615
+ ? JSON.stringify(status.cursor)
3616
+ : String(status.cursor || 'None');
3617
+ status.pendingOps.then((count: number) => {
3618
+ alert(\`Sync Status:\n\nCursor: \${cursorDisplay}\nPending operations: \${count}\`);
3619
+ });
3620
+ } else {
3621
+ alert('Debug mode must be enabled and the page refreshed to use this tool.');
3622
+ }
3623
+ }
3624
+
3625
+ /** Show the realtime WebSocket connection state and health. */
3626
+ function handleRealtimeStatus() {
3627
+ const fn = getDebugWindow().__${opts.prefix}Sync as
3628
+ | { realtimeStatus: () => { state: string; healthy: boolean } }
3629
+ | undefined;
3630
+ if (fn?.realtimeStatus) {
3631
+ const status = fn.realtimeStatus();
3632
+ alert(
3633
+ \`Realtime Status:\n\nState: \${status.state}\nHealthy: \${status.healthy ? 'Yes' : 'No'}\`
3634
+ );
3635
+ } else {
3636
+ alert('Debug mode must be enabled and the page refreshed to use this tool.');
3637
+ }
3638
+ }
3639
+
3640
+ /** Display sync cycle stats in an alert; full details logged to console. */
3641
+ function handleViewSyncStats() {
3642
+ const fn = getDebugWindow().__${opts.prefix}SyncStats as
3643
+ | (() => { totalSyncCycles: number; recentMinute: number; recent: unknown[] })
3644
+ | undefined;
3645
+ if (fn) {
3646
+ const stats = fn();
3647
+ alert(
3648
+ \`Sync Stats:\n\nTotal cycles: \${stats.totalSyncCycles}\nCycles in last minute: \${stats.recentMinute}\nRecent cycles logged to console.\`
3649
+ );
3650
+ } else {
3651
+ alert('Debug mode must be enabled and the page refreshed to use this tool.');
3652
+ }
3653
+ }
3654
+
3655
+ /** Display data-transfer / egress stats; per-table breakdown in console. */
3656
+ function handleViewEgress() {
3657
+ const fn = getDebugWindow().__${opts.prefix}Egress as
3658
+ | (() => { totalFormatted: string; totalRecords: number; sessionStart: string })
3659
+ | undefined;
3660
+ if (fn) {
3661
+ const stats = fn();
3662
+ alert(
3663
+ \`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.\`
3664
+ );
3665
+ } else {
3666
+ alert('Debug mode must be enabled and the page refreshed to use this tool.');
3667
+ }
3668
+ }
3669
+
3670
+ /** Log soft-deleted record counts per table to the browser console. */
3671
+ async function handleViewTombstones() {
3672
+ viewingTombstones = true;
3673
+ try {
3674
+ const fn = getDebugWindow().__${opts.prefix}Tombstones as
3675
+ | ((opts?: { cleanup?: boolean; force?: boolean }) => Promise<void>)
3676
+ | undefined;
3677
+ if (fn) {
3678
+ await fn();
3679
+ alert('Tombstone details logged to console. Open DevTools to view.');
3680
+ } else {
3681
+ alert('Debug mode must be enabled and the page refreshed to use this tool.');
3682
+ }
3683
+ } catch (err) {
3684
+ alert('View tombstones failed: ' + (err instanceof Error ? err.message : 'Unknown error'));
3685
+ }
3686
+ viewingTombstones = false;
3687
+ }
3688
+
3689
+ /** Permanently remove old soft-deleted records from local + remote DBs. */
3690
+ async function handleCleanupTombstones() {
3691
+ if (
3692
+ !confirm(
3693
+ 'This will permanently remove old soft-deleted records from local and server databases. Continue?'
3694
+ )
3695
+ )
3696
+ return;
3697
+ cleaningTombstones = true;
3698
+ try {
3699
+ const fn = getDebugWindow().__${opts.prefix}Tombstones as
3700
+ | ((opts?: { cleanup?: boolean; force?: boolean }) => Promise<void>)
3701
+ | undefined;
3702
+ if (fn) {
3703
+ await fn({ cleanup: true });
3704
+ alert('Tombstone cleanup complete. Details logged to console.');
3705
+ } else {
3706
+ alert('Debug mode must be enabled and the page refreshed to use this tool.');
3707
+ }
3708
+ } catch (err) {
3709
+ alert('Tombstone cleanup failed: ' + (err instanceof Error ? err.message : 'Unknown error'));
3710
+ }
3711
+ cleaningTombstones = false;
3712
+ }
1239
3713
 
1240
- // TODO: Add profile page state (form fields, device management, debug tools)
3714
+ /** Dispatch a custom event that the app shell listens for to sign out on mobile. */
3715
+ function handleMobileSignOut() {
3716
+ window.dispatchEvent(new CustomEvent('${opts.prefix}:signout'));
3717
+ }
1241
3718
  </script>
1242
3719
 
3720
+ <svelte:head>
3721
+ <title>Profile - ${opts.name}</title>
3722
+ </svelte:head>
3723
+
1243
3724
  <!-- TODO: Add profile page template (forms, cards, device list, debug tools) -->
1244
3725
  `;
1245
3726
  }
3727
+ // ---------------------------------------------------------------------------
3728
+ // COMPONENT GENERATORS
3729
+ // ---------------------------------------------------------------------------
3730
+ /**
3731
+ * Generate the UpdatePrompt component that monitors the service worker
3732
+ * lifecycle and shows an "update available" notification.
3733
+ *
3734
+ * @returns The Svelte component source for `src/lib/components/UpdatePrompt.svelte`.
3735
+ */
1246
3736
  function generateUpdatePromptComponent() {
1247
3737
  return `<script lang="ts">
1248
3738
  /**
1249
3739
  * @fileoverview UpdatePrompt — service-worker update notification.
1250
3740
  *
1251
- * Uses monitorSwLifecycle() from stellar-engine to detect when a new
1252
- * version is waiting to activate, and handleSwUpdate() to apply it.
3741
+ * Detects when a new service worker version is waiting to activate and
3742
+ * shows an "update available" prompt. Detection relies on six signals:
3743
+ * 1. \`statechange\` on the installing SW → catches updates during the visit
3744
+ * 2. \`updatefound\` on the registration → catches background installs
3745
+ * 3. \`visibilitychange\` → re-checks when the tab becomes visible
3746
+ * 4. \`online\` event → re-checks when connectivity is restored
3747
+ * 5. Periodic interval → fallback for iOS standalone mode
3748
+ * 6. Initial check on mount → catches SWs that installed before this component
3749
+ *
3750
+ * Uses \`monitorSwLifecycle()\` from stellar-engine to wire up all six, and
3751
+ * \`handleSwUpdate()\` to send SKIP_WAITING + reload on user confirmation.
1253
3752
  */
1254
3753
 
3754
+ // ==========================================================================
3755
+ // IMPORTS
3756
+ // ==========================================================================
3757
+
1255
3758
  import { onMount, onDestroy } from 'svelte';
1256
3759
  import { monitorSwLifecycle, handleSwUpdate } from '@prabhask5/stellar-engine/kit';
1257
3760
 
3761
+ // ==========================================================================
3762
+ // COMPONENT STATE
3763
+ // ==========================================================================
3764
+
1258
3765
  /** Whether the update prompt is visible */
1259
3766
  let showPrompt = $state(false);
1260
3767
 
1261
3768
  /** Guard flag to prevent double-reload */
1262
3769
  let reloading = false;
1263
3770
 
3771
+ /** Cleanup function returned by monitorSwLifecycle */
1264
3772
  let cleanup: (() => void) | null = null;
1265
3773
 
3774
+ // ==========================================================================
3775
+ // SERVICE WORKER MONITORING
3776
+ // ==========================================================================
3777
+
1266
3778
  onMount(() => {
1267
3779
  cleanup = monitorSwLifecycle({
1268
3780
  onUpdateAvailable: () => {
@@ -1275,6 +3787,10 @@ function generateUpdatePromptComponent() {
1275
3787
  cleanup?.();
1276
3788
  });
1277
3789
 
3790
+ // ==========================================================================
3791
+ // ACTION HANDLERS
3792
+ // ==========================================================================
3793
+
1278
3794
  /**
1279
3795
  * Apply the update: sends SKIP_WAITING to the waiting SW,
1280
3796
  * waits for controllerchange, then reloads the page.
@@ -1310,8 +3826,24 @@ function generateUpdatePromptComponent() {
1310
3826
  -->
1311
3827
  `;
1312
3828
  }
3829
+ // ---------------------------------------------------------------------------
3830
+ // TYPE RE-EXPORT GENERATOR
3831
+ // ---------------------------------------------------------------------------
3832
+ /**
3833
+ * Generate the app types barrel file that re-exports stellar-engine types
3834
+ * and provides a location for app-specific type definitions.
3835
+ *
3836
+ * @returns The TypeScript source for `src/lib/types.ts`.
3837
+ */
1313
3838
  function generateAppTypes() {
1314
- return `// App types barrel — re-exports from stellar-engine plus app-specific types
3839
+ return `/**
3840
+ * @fileoverview Type barrel — re-exports from stellar-engine plus app-specific types.
3841
+ *
3842
+ * Conventions used by stellar-engine tables:
3843
+ * - \`deleted\` — soft-delete flag (boolean, default \`false\`)
3844
+ * - \`_version\` — optimistic concurrency counter (integer, starts at 1)
3845
+ * - \`device_id\` — originating device identifier for conflict resolution
3846
+ */
1315
3847
  export type { SyncStatus, AuthMode, OfflineCredentials } from '@prabhask5/stellar-engine/types';
1316
3848
 
1317
3849
  // TODO: Add app-specific type definitions below
@@ -1320,6 +3852,21 @@ export type { SyncStatus, AuthMode, OfflineCredentials } from '@prabhask5/stella
1320
3852
  // =============================================================================
1321
3853
  // MAIN FUNCTION
1322
3854
  // =============================================================================
3855
+ /**
3856
+ * Main entry point for the CLI scaffolding tool.
3857
+ *
3858
+ * **Execution flow:**
3859
+ * 1. Parse CLI arguments into {@link InstallOptions}.
3860
+ * 2. Write `package.json` (if missing).
3861
+ * 3. Run `npm install` to fetch dependencies.
3862
+ * 4. Write all template files (config, routes, components, assets, docs).
3863
+ * 5. Initialise Husky and write the pre-commit hook.
3864
+ * 6. Print a summary of created/skipped files and next steps.
3865
+ *
3866
+ * @returns A promise that resolves when scaffolding is complete.
3867
+ *
3868
+ * @throws {Error} If `npm install` or `npx husky init` fails.
3869
+ */
1323
3870
  async function main() {
1324
3871
  const opts = parseArgs(process.argv);
1325
3872
  const cwd = process.cwd();
@@ -1369,21 +3916,21 @@ async function main() {
1369
3916
  ['src/app.d.ts', generateAppDts(opts)],
1370
3917
  // Route files
1371
3918
  ['src/routes/+layout.ts', generateRootLayoutTs(opts)],
1372
- ['src/routes/+layout.svelte', generateRootLayoutSvelte()],
1373
- ['src/routes/+page.svelte', generateHomePage()],
1374
- ['src/routes/+error.svelte', generateErrorPage()],
3919
+ ['src/routes/+layout.svelte', generateRootLayoutSvelte(opts)],
3920
+ ['src/routes/+page.svelte', generateHomePage(opts)],
3921
+ ['src/routes/+error.svelte', generateErrorPage(opts)],
1375
3922
  ['src/routes/setup/+page.ts', generateSetupPageTs()],
1376
- ['src/routes/setup/+page.svelte', generateSetupPageSvelte()],
1377
- ['src/routes/policy/+page.svelte', generatePolicyPage()],
1378
- ['src/routes/login/+page.svelte', generateLoginPage()],
1379
- ['src/routes/confirm/+page.svelte', generateConfirmPage()],
3923
+ ['src/routes/setup/+page.svelte', generateSetupPageSvelte(opts)],
3924
+ ['src/routes/policy/+page.svelte', generatePolicyPage(opts)],
3925
+ ['src/routes/login/+page.svelte', generateLoginPage(opts)],
3926
+ ['src/routes/confirm/+page.svelte', generateConfirmPage(opts)],
1380
3927
  ['src/routes/api/config/+server.ts', generateConfigServer()],
1381
3928
  ['src/routes/api/setup/deploy/+server.ts', generateDeployServer()],
1382
3929
  ['src/routes/api/setup/validate/+server.ts', generateValidateServer()],
1383
3930
  ['src/routes/[...catchall]/+page.ts', generateCatchallPage()],
1384
3931
  ['src/routes/(protected)/+layout.ts', generateProtectedLayoutTs()],
1385
3932
  ['src/routes/(protected)/+layout.svelte', generateProtectedLayoutSvelte()],
1386
- ['src/routes/(protected)/profile/+page.svelte', generateProfilePage()],
3933
+ ['src/routes/(protected)/profile/+page.svelte', generateProfilePage(opts)],
1387
3934
  ['src/lib/types.ts', generateAppTypes()],
1388
3935
  // Component files
1389
3936
  ['src/lib/components/UpdatePrompt.svelte', generateUpdatePromptComponent()]
@@ -1394,7 +3941,7 @@ async function main() {
1394
3941
  // 4. Set up husky
1395
3942
  console.log('Setting up husky...');
1396
3943
  execSync('npx husky init', { stdio: 'inherit', cwd });
1397
- // Overwrite the default pre-commit (husky init creates one with "npm test")
3944
+ /* Overwrite the default pre-commit (husky init creates one with "npm test") */
1398
3945
  const preCommitPath = join(cwd, '.husky/pre-commit');
1399
3946
  writeFileSync(preCommitPath, generateHuskyPreCommit(), 'utf-8');
1400
3947
  createdFiles.push('.husky/pre-commit');