@cavulsqa/create 2.9.2 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (209) hide show
  1. package/README.md +12 -1
  2. package/bin/create.mjs +18 -25
  3. package/lib/templates.mjs +56 -0
  4. package/package.json +13 -5
  5. package/templates/f7-app/CLAUDE.md +3 -0
  6. package/templates/f7-app/auto-imports.d.ts +15 -0
  7. package/templates/f7-app/package.json +6 -3
  8. package/templates/f7-app/src/App.vue +1 -0
  9. package/templates/f7-app/src/env.d.ts +1 -0
  10. package/templates/f7-app/src/main.ts +3 -0
  11. package/templates/f7-app/src/modules/demo/router/routes/demo.routes.ts +4 -16
  12. package/templates/f7-app/src/modules/home/router/routes/home.routes.ts +3 -11
  13. package/templates/f7-app/src/modules/settings/router/routes/settings.routes.ts +2 -6
  14. package/templates/f7-app/src/plugins/bootstrapError.ts +2 -0
  15. package/templates/f7-app/src/plugins/recorder.plugin.ts +155 -0
  16. package/templates/f7-app/src/router/global/global.routes.ts +2 -6
  17. package/templates/f7-app/src/shared/composables/useNavigationGuard.ts +48 -0
  18. package/templates/f7-app/src/shared/recorder/capacitorSink.ts +124 -0
  19. package/templates/f7-app/src/shared/utils/lazyRoute.ts +25 -0
  20. package/templates/f7-app/tests/lazyRoute.test.ts +30 -0
  21. package/templates/f7-app/tests/navigationGuard.test.ts +82 -0
  22. package/templates/f7-app/vite.config.ts +1 -0
  23. package/templates/m3e-app/.claude/rules/data-fetching.md +68 -0
  24. package/templates/m3e-app/.claude/rules/database.md +106 -0
  25. package/templates/m3e-app/.claude/rules/m3e-ui.md +48 -0
  26. package/templates/m3e-app/.claude/rules/modules.md +43 -0
  27. package/templates/m3e-app/.claude/rules/native.md +60 -0
  28. package/templates/m3e-app/.claude/skills/m3-expressive/SKILL.md +68 -0
  29. package/templates/m3e-app/.claude/skills/m3-expressive/color.md +44 -0
  30. package/templates/m3e-app/.claude/skills/m3-expressive/components.md +325 -0
  31. package/templates/m3e-app/.claude/skills/m3-expressive/layout.md +42 -0
  32. package/templates/m3e-app/.claude/skills/m3-expressive/motion.md +51 -0
  33. package/templates/m3e-app/.claude/skills/m3-expressive/shapes-type.md +48 -0
  34. package/templates/m3e-app/.claude/skills/m3-expressive/sources.md +53 -0
  35. package/templates/m3e-app/.claude/skills/module-architecture/SKILL.md +65 -0
  36. package/templates/m3e-app/.claude/skills/module-architecture/file-templates.md +178 -0
  37. package/templates/m3e-app/.claude/skills/reactive-data/SKILL.md +90 -0
  38. package/templates/m3e-app/.claude/skills/reactive-data/testing.md +55 -0
  39. package/templates/m3e-app/.env.example +32 -0
  40. package/templates/m3e-app/CLAUDE.md +93 -0
  41. package/templates/m3e-app/auto-imports.d.ts +851 -0
  42. package/templates/m3e-app/capacitor.config.ts +44 -0
  43. package/templates/m3e-app/components.d.ts +252 -0
  44. package/templates/m3e-app/index.html +16 -0
  45. package/templates/m3e-app/package.json +107 -0
  46. package/templates/m3e-app/src/App.vue +129 -0
  47. package/templates/m3e-app/src/app/pragmas.config.ts +35 -0
  48. package/templates/m3e-app/src/app/scroll.config.ts +20 -0
  49. package/templates/m3e-app/src/app/storage.config.ts +65 -0
  50. package/templates/m3e-app/src/app/tabs.ts +33 -0
  51. package/templates/m3e-app/src/app/theme.config.ts +51 -0
  52. package/templates/m3e-app/src/assets/css/app.css +18 -0
  53. package/templates/m3e-app/src/assets/css/base.css +53 -0
  54. package/templates/m3e-app/src/assets/css/layout/container-transform.css +113 -0
  55. package/templates/m3e-app/src/assets/css/layout/shell.css +69 -0
  56. package/templates/m3e-app/src/assets/css/layout/transitions.css +121 -0
  57. package/templates/m3e-app/src/assets/css/m3e.css +6 -0
  58. package/templates/m3e-app/src/assets/css/theme/framework7.css +18 -0
  59. package/templates/m3e-app/src/assets/css/theme/tailwind.css +365 -0
  60. package/templates/m3e-app/src/domains/benchmark/benchmark.dataset.ts +209 -0
  61. package/templates/m3e-app/src/domains/benchmark/benchmark.suite.ts +662 -0
  62. package/templates/m3e-app/src/domains/sales/sales.repository.ts +458 -0
  63. package/templates/m3e-app/src/env.d.ts +40 -0
  64. package/templates/m3e-app/src/locales/ar.json +1260 -0
  65. package/templates/m3e-app/src/locales/en.json +1260 -0
  66. package/templates/m3e-app/src/locales/fr.json +1260 -0
  67. package/templates/m3e-app/src/main.ts +41 -0
  68. package/templates/m3e-app/src/modules/demo/components/DemoBenchmark.vue +119 -0
  69. package/templates/m3e-app/src/modules/demo/components/DemoBusLog.vue +63 -0
  70. package/templates/m3e-app/src/modules/demo/components/DemoCreateOrderSheet.vue +203 -0
  71. package/templates/m3e-app/src/modules/demo/components/DemoMetricsSheet.vue +75 -0
  72. package/templates/m3e-app/src/modules/demo/components/DemoOrderList.vue +75 -0
  73. package/templates/m3e-app/src/modules/demo/components/DemoPipelineBenchmark.vue +65 -0
  74. package/templates/m3e-app/src/modules/demo/components/DemoStatCards.vue +71 -0
  75. package/templates/m3e-app/src/modules/demo/composables/useBenchmark.ts +139 -0
  76. package/templates/m3e-app/src/modules/demo/composables/useOrderStatus.ts +64 -0
  77. package/templates/m3e-app/src/modules/demo/composables/useReactiveDemo.ts +171 -0
  78. package/templates/m3e-app/src/modules/demo/router/routes/demo.routes.ts +22 -0
  79. package/templates/m3e-app/src/modules/demo/views/DemoView.vue +210 -0
  80. package/templates/m3e-app/src/modules/demo/views/OrderDetailView.vue +136 -0
  81. package/templates/m3e-app/src/modules/demo/views/OrderSearchView.vue +74 -0
  82. package/templates/m3e-app/src/modules/gallery/components/GalleryBlock.vue +16 -0
  83. package/templates/m3e-app/src/modules/gallery/components/GalleryCarouselTile.vue +71 -0
  84. package/templates/m3e-app/src/modules/gallery/components/GalleryCustomerForm.vue +76 -0
  85. package/templates/m3e-app/src/modules/gallery/components/GalleryProofOfDelivery.vue +69 -0
  86. package/templates/m3e-app/src/modules/gallery/components/agenda/AgendaDay.vue +106 -0
  87. package/templates/m3e-app/src/modules/gallery/components/agenda/AgendaMonth.vue +40 -0
  88. package/templates/m3e-app/src/modules/gallery/components/agenda/AgendaVisitList.vue +45 -0
  89. package/templates/m3e-app/src/modules/gallery/components/chat/ChatAttachSheet.vue +106 -0
  90. package/templates/m3e-app/src/modules/gallery/components/chat/ChatContactPicker.vue +85 -0
  91. package/templates/m3e-app/src/modules/gallery/components/chat/ChatInviteComposer.vue +131 -0
  92. package/templates/m3e-app/src/modules/gallery/components/chat/ChatPollComposer.vue +148 -0
  93. package/templates/m3e-app/src/modules/gallery/components/chat/ChatRecentPhotos.vue +62 -0
  94. package/templates/m3e-app/src/modules/gallery/components/inputs/InputsAccount.vue +79 -0
  95. package/templates/m3e-app/src/modules/gallery/components/inputs/InputsTags.vue +53 -0
  96. package/templates/m3e-app/src/modules/gallery/components/inputs/InputsVerification.vue +73 -0
  97. package/templates/m3e-app/src/modules/gallery/components/inputs/PasswordStrength.vue +37 -0
  98. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryButtons.vue +128 -0
  99. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryCarousels.vue +236 -0
  100. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryCharts.vue +84 -0
  101. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryFabs.vue +70 -0
  102. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryInputs.vue +219 -0
  103. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryNavigation.vue +141 -0
  104. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryOverlays.vue +368 -0
  105. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryPickers.vue +175 -0
  106. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryProgress.vue +118 -0
  107. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryScale.vue +103 -0
  108. package/templates/m3e-app/src/modules/gallery/components/sections/GallerySelection.vue +219 -0
  109. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryShapes.vue +48 -0
  110. package/templates/m3e-app/src/modules/gallery/components/sections/GallerySurfaces.vue +213 -0
  111. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryTables.vue +185 -0
  112. package/templates/m3e-app/src/modules/gallery/components/surfaces/SurfacesContainerTransform.vue +46 -0
  113. package/templates/m3e-app/src/modules/gallery/composables/chatTeam.ts +18 -0
  114. package/templates/m3e-app/src/modules/gallery/composables/composeKind.ts +2 -0
  115. package/templates/m3e-app/src/modules/gallery/composables/currentPosition.ts +35 -0
  116. package/templates/m3e-app/src/modules/gallery/composables/demoOrders.ts +29 -0
  117. package/templates/m3e-app/src/modules/gallery/composables/featuredStories.ts +73 -0
  118. package/templates/m3e-app/src/modules/gallery/composables/fieldFormats.ts +36 -0
  119. package/templates/m3e-app/src/modules/gallery/composables/openExternal.ts +11 -0
  120. package/templates/m3e-app/src/modules/gallery/composables/routePlan.ts +90 -0
  121. package/templates/m3e-app/src/modules/gallery/composables/useAgenda.ts +61 -0
  122. package/templates/m3e-app/src/modules/gallery/composables/useChatCustomers.ts +31 -0
  123. package/templates/m3e-app/src/modules/gallery/composables/useChatDemo.ts +299 -0
  124. package/templates/m3e-app/src/modules/gallery/composables/useGallerySections.ts +230 -0
  125. package/templates/m3e-app/src/modules/gallery/composables/useLocalAttachments.ts +71 -0
  126. package/templates/m3e-app/src/modules/gallery/composables/useLocationShare.ts +38 -0
  127. package/templates/m3e-app/src/modules/gallery/composables/usePhotoScenes.ts +82 -0
  128. package/templates/m3e-app/src/modules/gallery/composables/useTreeDemo.ts +67 -0
  129. package/templates/m3e-app/src/modules/gallery/composables/wilayas.ts +67 -0
  130. package/templates/m3e-app/src/modules/gallery/router/routes/gallery.routes.ts +52 -0
  131. package/templates/m3e-app/src/modules/gallery/views/GalleryAgendaView.vue +117 -0
  132. package/templates/m3e-app/src/modules/gallery/views/GalleryChatView.vue +292 -0
  133. package/templates/m3e-app/src/modules/gallery/views/GalleryContactsView.vue +77 -0
  134. package/templates/m3e-app/src/modules/gallery/views/GalleryFeaturedView.vue +60 -0
  135. package/templates/m3e-app/src/modules/gallery/views/GalleryLoginView.vue +135 -0
  136. package/templates/m3e-app/src/modules/gallery/views/GalleryOnboardingView.vue +116 -0
  137. package/templates/m3e-app/src/modules/gallery/views/GallerySectionView.vue +27 -0
  138. package/templates/m3e-app/src/modules/gallery/views/GalleryTabsView.vue +135 -0
  139. package/templates/m3e-app/src/modules/gallery/views/GalleryView.vue +50 -0
  140. package/templates/m3e-app/src/modules/home/components/HomeHero.vue +57 -0
  141. package/templates/m3e-app/src/modules/home/composables/useHomeFeatures.ts +128 -0
  142. package/templates/m3e-app/src/modules/home/composables/useOpenFeature.ts +22 -0
  143. package/templates/m3e-app/src/modules/home/router/routes/home.routes.ts +21 -0
  144. package/templates/m3e-app/src/modules/home/views/FeatureDetailView.vue +66 -0
  145. package/templates/m3e-app/src/modules/home/views/HomeView.vue +47 -0
  146. package/templates/m3e-app/src/modules/settings/components/RolePalette.vue +38 -0
  147. package/templates/m3e-app/src/modules/settings/components/SeedSwatches.vue +41 -0
  148. package/templates/m3e-app/src/modules/settings/components/SettingsChoice.vue +43 -0
  149. package/templates/m3e-app/src/modules/settings/components/StudioPreview.vue +52 -0
  150. package/templates/m3e-app/src/modules/settings/router/routes/settings.routes.ts +17 -0
  151. package/templates/m3e-app/src/modules/settings/types.ts +7 -0
  152. package/templates/m3e-app/src/modules/settings/views/ColorStudioView.vue +166 -0
  153. package/templates/m3e-app/src/modules/settings/views/SettingsView.vue +157 -0
  154. package/templates/m3e-app/src/plugins/bootstrapError.ts +62 -0
  155. package/templates/m3e-app/src/plugins/capacitor/index.ts +14 -0
  156. package/templates/m3e-app/src/plugins/capacitor/useAndroidBackButton.ts +36 -0
  157. package/templates/m3e-app/src/plugins/capacitor/useKeyboard.ts +48 -0
  158. package/templates/m3e-app/src/plugins/capacitor/useSplashScreen.ts +43 -0
  159. package/templates/m3e-app/src/plugins/capacitor/useStatusBar.ts +16 -0
  160. package/templates/m3e-app/src/plugins/framework7.plugin.ts +36 -0
  161. package/templates/m3e-app/src/plugins/i18n.plugin.ts +87 -0
  162. package/templates/m3e-app/src/plugins/m3e.plugin.ts +22 -0
  163. package/templates/m3e-app/src/plugins/seed.plugin.ts +7 -0
  164. package/templates/m3e-app/src/plugins/sqlite.plugin.ts +32 -0
  165. package/templates/m3e-app/src/router/global/global.routes.ts +12 -0
  166. package/templates/m3e-app/src/router/index.ts +20 -0
  167. package/templates/m3e-app/src/shared/components/error/404.vue +13 -0
  168. package/templates/m3e-app/src/shared/components/layout/EmptyState.vue +30 -0
  169. package/templates/m3e-app/src/shared/components/layout/SectionHeader.vue +10 -0
  170. package/templates/m3e-app/src/shared/components/page/AppPage.vue +104 -0
  171. package/templates/m3e-app/src/shared/composables/navigation/useActiveTab.ts +39 -0
  172. package/templates/m3e-app/src/shared/composables/navigation/useContainerTransform.ts +117 -0
  173. package/templates/m3e-app/src/shared/composables/navigation/useNavigationGuard.ts +48 -0
  174. package/templates/m3e-app/src/shared/composables/navigation/useNavigationVisibility.ts +64 -0
  175. package/templates/m3e-app/src/shared/composables/navigation/useViewRouter.ts +25 -0
  176. package/templates/m3e-app/src/shared/composables/navigation/useWindowClass.ts +22 -0
  177. package/templates/m3e-app/src/shared/composables/theme/useThemeSettings.ts +155 -0
  178. package/templates/m3e-app/src/shared/database/candidates/capacitorSqlite.ts +36 -0
  179. package/templates/m3e-app/src/shared/database/candidates/index.ts +4 -0
  180. package/templates/m3e-app/src/shared/database/candidates/opfsSahPool.ts +25 -0
  181. package/templates/m3e-app/src/shared/database/candidates/types.ts +56 -0
  182. package/templates/m3e-app/src/shared/database/candidates/waSqlite.ts +56 -0
  183. package/templates/m3e-app/src/shared/database/database.ts +219 -0
  184. package/templates/m3e-app/src/shared/database/index.ts +3 -0
  185. package/templates/m3e-app/src/shared/database/migrations.ts +223 -0
  186. package/templates/m3e-app/src/shared/database/opfs.worker.ts +5 -0
  187. package/templates/m3e-app/src/shared/database/queries.ts +26 -0
  188. package/templates/m3e-app/src/shared/database/schema.ts +153 -0
  189. package/templates/m3e-app/src/shared/database/storage.ts +64 -0
  190. package/templates/m3e-app/src/shared/database/wa.worker.ts +5 -0
  191. package/templates/m3e-app/src/shared/utils/lazyRoute.ts +41 -0
  192. package/templates/m3e-app/src/shared/utils/resolvers/resolvers.ts +42 -0
  193. package/templates/m3e-app/src/shared/utils/textDirection.ts +22 -0
  194. package/templates/m3e-app/src/shared/utils/theme/themeSettings.ts +59 -0
  195. package/templates/m3e-app/src/shared/utils/tone.ts +8 -0
  196. package/templates/m3e-app/tests/benchmark.suite.test.ts +104 -0
  197. package/templates/m3e-app/tests/containerTransform.test.ts +58 -0
  198. package/templates/m3e-app/tests/fieldFormats.test.ts +25 -0
  199. package/templates/m3e-app/tests/lazyRoute.test.ts +30 -0
  200. package/templates/m3e-app/tests/locales.test.ts +73 -0
  201. package/templates/m3e-app/tests/migrations.test.ts +74 -0
  202. package/templates/m3e-app/tests/navigationGuard.test.ts +82 -0
  203. package/templates/m3e-app/tests/openDatabase.test.ts +54 -0
  204. package/templates/m3e-app/tests/sales.repository.test.ts +329 -0
  205. package/templates/m3e-app/tests/storage.test.ts +117 -0
  206. package/templates/m3e-app/tests/textDirection.test.ts +11 -0
  207. package/templates/m3e-app/tooling/linkedPackages.ts +112 -0
  208. package/templates/m3e-app/tsconfig.json +21 -0
  209. package/templates/m3e-app/vite.config.ts +144 -0
@@ -0,0 +1,48 @@
1
+ import { f7 } from "framework7-vue";
2
+
3
+ interface GuardableRouter {
4
+ allowPageChange: boolean;
5
+ navigate: (...args: never[]) => unknown;
6
+ back: (...args: never[]) => unknown;
7
+ __navigationGuarded?: boolean;
8
+ }
9
+
10
+ /**
11
+ * Framework7 only enforces `allowPageChange` for synchronous routes. Routes here are async, and
12
+ * `asyncResolve` calls the internal `load()` with `ignorePageChange: true`, so a navigate issued
13
+ * while a transition is still animating runs a forward on top of an in-flight backward. That
14
+ * destroys the previous page element and leaves the router holding two copies of the same page;
15
+ * from there `loadBack` takes its same-url early return and back() can never move again, which
16
+ * reads to the user as a frozen screen.
17
+ *
18
+ * Dropping the losing call is a single boolean read - no queueing, no deferral - so the navigation
19
+ * that does win runs at exactly the same speed as before.
20
+ */
21
+ export function installNavigationGuard(router: GuardableRouter): void {
22
+ if (router.__navigationGuarded) return;
23
+ router.__navigationGuarded = true;
24
+
25
+ let depth = 0;
26
+
27
+ const guard = (method: (...args: never[]) => unknown) => {
28
+ return (...args: never[]) => {
29
+ if (depth === 0 && !router.allowPageChange) return router;
30
+ depth += 1;
31
+ try {
32
+ return method(...args);
33
+ } finally {
34
+ depth -= 1;
35
+ }
36
+ };
37
+ };
38
+
39
+ router.navigate = guard(router.navigate.bind(router));
40
+ router.back = guard(router.back.bind(router));
41
+ }
42
+
43
+ export function useNavigationGuard(): void {
44
+ const views = f7.views as unknown as Array<{ router?: GuardableRouter }>;
45
+ for (const view of views ?? []) {
46
+ if (view?.router) installNavigationGuard(view.router);
47
+ }
48
+ }
@@ -0,0 +1,124 @@
1
+ import { Capacitor } from "@capacitor/core";
2
+ import { Directory, Filesystem } from "@capacitor/filesystem";
3
+ import { Share } from "@capacitor/share";
4
+ import type { RecorderSink, SessionManifest } from "@cavulsqa/recorder";
5
+
6
+ const CAPTURE_DIR = "capu";
7
+ const BASE64_CHUNK = 0x8000;
8
+
9
+ export interface CaptureFile {
10
+ name: string;
11
+ size: number;
12
+ modifiedAt: number;
13
+ }
14
+
15
+ interface BrowserCapture {
16
+ name: string;
17
+ blob: Blob;
18
+ size: number;
19
+ savedAt: number;
20
+ }
21
+
22
+ const browserCaptures = new Map<string, BrowserCapture>();
23
+
24
+ function bytesToBase64(bytes: Uint8Array): string {
25
+ let binary = "";
26
+ for (let offset = 0; offset < bytes.length; offset += BASE64_CHUNK) {
27
+ const chunk = bytes.subarray(offset, offset + BASE64_CHUNK);
28
+ binary += String.fromCharCode(...chunk);
29
+ }
30
+ return btoa(binary);
31
+ }
32
+
33
+ function fileNameFor(manifest: SessionManifest): string {
34
+ return `${manifest.sessionId}-${String(Date.now())}.capu`;
35
+ }
36
+
37
+ function downloadBlob(name: string, blob: Blob): void {
38
+ const url = URL.createObjectURL(blob);
39
+ const link = document.createElement("a");
40
+ link.href = url;
41
+ link.download = name;
42
+ document.body.appendChild(link);
43
+ link.click();
44
+ link.remove();
45
+ URL.revokeObjectURL(url);
46
+ }
47
+
48
+ async function saveNative(archive: Uint8Array, manifest: SessionManifest): Promise<void> {
49
+ const path = `${CAPTURE_DIR}/${fileNameFor(manifest)}`;
50
+ await Filesystem.writeFile({
51
+ path,
52
+ data: bytesToBase64(archive),
53
+ directory: Directory.External,
54
+ recursive: true,
55
+ });
56
+ const { uri } = await Filesystem.getUri({ path, directory: Directory.External });
57
+ await Share.share({ url: uri, title: manifest.label });
58
+ }
59
+
60
+ function saveBrowser(archive: Uint8Array, manifest: SessionManifest): void {
61
+ const name = fileNameFor(manifest);
62
+ const blob = new Blob([archive as Uint8Array<ArrayBuffer>], { type: "application/zip" });
63
+ browserCaptures.set(name, { name, blob, size: blob.size, savedAt: Date.now() });
64
+ downloadBlob(name, blob);
65
+ }
66
+
67
+ /**
68
+ * On native, writes a finished `.capu` under `capu/<sessionId>-<ts>.capu` in the app's external
69
+ * storage and opens the Android share sheet. Off-device (`vp dev` in a browser) it triggers a blob
70
+ * download instead, since `Filesystem`/`Share` have nothing to write to there.
71
+ */
72
+ export function createCapacitorSink(): RecorderSink {
73
+ return {
74
+ async save(archive, manifest) {
75
+ if (Capacitor.isNativePlatform()) {
76
+ await saveNative(archive, manifest);
77
+ } else {
78
+ saveBrowser(archive, manifest);
79
+ }
80
+ },
81
+ };
82
+ }
83
+
84
+ /** Lists previously captured `.capu` files for the Settings diagnostics list, newest first. */
85
+ export async function listCaptures(): Promise<CaptureFile[]> {
86
+ if (Capacitor.isNativePlatform()) {
87
+ try {
88
+ const result = await Filesystem.readdir({
89
+ path: CAPTURE_DIR,
90
+ directory: Directory.External,
91
+ });
92
+ return result.files
93
+ .filter((file) => file.type === "file")
94
+ .map((file) => ({ name: file.name, size: file.size, modifiedAt: file.mtime }))
95
+ .sort((a, b) => b.modifiedAt - a.modifiedAt);
96
+ } catch {
97
+ return [];
98
+ }
99
+ }
100
+ return [...browserCaptures.values()]
101
+ .map((capture) => ({ name: capture.name, size: capture.size, modifiedAt: capture.savedAt }))
102
+ .sort((a, b) => b.modifiedAt - a.modifiedAt);
103
+ }
104
+
105
+ /** Re-opens the Android share sheet for a previously captured file; on browser, re-triggers the download. */
106
+ export async function shareCapture(name: string): Promise<void> {
107
+ if (Capacitor.isNativePlatform()) {
108
+ const path = `${CAPTURE_DIR}/${name}`;
109
+ const { uri } = await Filesystem.getUri({ path, directory: Directory.External });
110
+ await Share.share({ url: uri });
111
+ return;
112
+ }
113
+ const capture = browserCaptures.get(name);
114
+ if (capture) downloadBlob(name, capture.blob);
115
+ }
116
+
117
+ /** Permanently deletes a previously captured file. */
118
+ export async function deleteCapture(name: string): Promise<void> {
119
+ if (Capacitor.isNativePlatform()) {
120
+ await Filesystem.deleteFile({ path: `${CAPTURE_DIR}/${name}`, directory: Directory.External });
121
+ return;
122
+ }
123
+ browserCaptures.delete(name);
124
+ }
@@ -0,0 +1,25 @@
1
+ import type { Router } from "framework7/types";
2
+
3
+ type RouteComponentModule = { default: unknown };
4
+
5
+ /**
6
+ * Framework7 sets `allowPageChange = false` before calling a route's async hook and only restores
7
+ * it from `resolve` or `reject`. A hook that settles neither - a chunk that fails to download, or a
8
+ * module that throws while evaluating - leaves that view's router locked for the rest of the
9
+ * session, so every later navigation and back press is silently ignored. Routing the import
10
+ * through here guarantees the router is always released.
11
+ */
12
+ export function lazyRoute(
13
+ load: () => Promise<RouteComponentModule>,
14
+ ): Router.RouteParameters["async"] {
15
+ return function routeAsync({ resolve, reject }) {
16
+ load()
17
+ .then((module) => {
18
+ resolve({ component: module.default });
19
+ })
20
+ .catch((error: unknown) => {
21
+ console.error("[router] Unable to load route component", error);
22
+ reject();
23
+ });
24
+ };
25
+ }
@@ -0,0 +1,30 @@
1
+ import { expect, test, vi } from "vite-plus/test";
2
+ import { lazyRoute } from "../src/shared/utils/lazyRoute.js";
3
+
4
+ function callRoute(handler: ReturnType<typeof lazyRoute>) {
5
+ const resolve = vi.fn();
6
+ const reject = vi.fn();
7
+ (handler as (ctx: unknown) => void)({ resolve, reject });
8
+ return { resolve, reject };
9
+ }
10
+
11
+ test("resolves the route with the module's default export", async () => {
12
+ const component = { name: "HomeView" };
13
+ const { resolve, reject } = callRoute(lazyRoute(() => Promise.resolve({ default: component })));
14
+
15
+ await vi.waitFor(() => expect(resolve).toHaveBeenCalledOnce());
16
+ expect(resolve).toHaveBeenCalledWith({ component });
17
+ expect(reject).not.toHaveBeenCalled();
18
+ });
19
+
20
+ test("rejects the route when the chunk fails to load, releasing the router", async () => {
21
+ // Without this the router keeps allowPageChange === false forever and the view freezes.
22
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
23
+ const { resolve, reject } = callRoute(
24
+ lazyRoute(() => Promise.reject(new Error("chunk load failed"))),
25
+ );
26
+
27
+ await vi.waitFor(() => expect(reject).toHaveBeenCalledOnce());
28
+ expect(resolve).not.toHaveBeenCalled();
29
+ consoleError.mockRestore();
30
+ });
@@ -0,0 +1,82 @@
1
+ import { expect, test } from "vite-plus/test";
2
+ import { installNavigationGuard } from "../src/shared/composables/useNavigationGuard.js";
3
+
4
+ function makeRouter() {
5
+ const calls: string[] = [];
6
+ const router = {
7
+ allowPageChange: true,
8
+ navigate(url: string) {
9
+ calls.push(`navigate:${url}`);
10
+ },
11
+ back() {
12
+ calls.push("back");
13
+ },
14
+ };
15
+ return { router, calls };
16
+ }
17
+
18
+ test("drops a navigate issued while a transition is still in flight", () => {
19
+ const { router, calls } = makeRouter();
20
+ installNavigationGuard(router as never);
21
+
22
+ router.allowPageChange = false;
23
+ router.navigate("/home/feature/1/");
24
+
25
+ expect(calls).toEqual([]);
26
+ });
27
+
28
+ test("drops a back issued while a transition is still in flight", () => {
29
+ const { router, calls } = makeRouter();
30
+ installNavigationGuard(router as never);
31
+
32
+ router.allowPageChange = false;
33
+ router.back();
34
+
35
+ expect(calls).toEqual([]);
36
+ });
37
+
38
+ test("lets navigation through while the router is idle", () => {
39
+ const { router, calls } = makeRouter();
40
+ installNavigationGuard(router as never);
41
+
42
+ router.navigate("/home/feature/1/");
43
+ router.back();
44
+
45
+ expect(calls).toEqual(["navigate:/home/feature/1/", "back"]);
46
+ });
47
+
48
+ test("still allows Framework7's own recursive calls after it locks the router", () => {
49
+ // back() re-enters itself with { force: true }, and navigate() re-enters for named routes, both
50
+ // after allowPageChange is already false. Dropping those would break normal navigation.
51
+ const calls: string[] = [];
52
+ const router = {
53
+ allowPageChange: true,
54
+ navigate(url: string) {
55
+ calls.push(`navigate:${url}`);
56
+ router.allowPageChange = false;
57
+ if (url === "outer") router.navigate("inner");
58
+ },
59
+ back() {
60
+ calls.push("back");
61
+ router.allowPageChange = false;
62
+ router.navigate("from-back");
63
+ },
64
+ };
65
+ installNavigationGuard(router as never);
66
+
67
+ router.navigate("outer");
68
+ router.allowPageChange = true;
69
+ router.back();
70
+
71
+ expect(calls).toEqual(["navigate:outer", "navigate:inner", "back", "navigate:from-back"]);
72
+ });
73
+
74
+ test("installing twice does not wrap the router twice", () => {
75
+ const { router, calls } = makeRouter();
76
+ installNavigationGuard(router as never);
77
+ installNavigationGuard(router as never);
78
+
79
+ router.navigate("/demo/");
80
+
81
+ expect(calls).toEqual(["navigate:/demo/"]);
82
+ });
@@ -52,6 +52,7 @@ export default defineConfig({
52
52
  */
53
53
  AutoImport({
54
54
  include: [/\.[tj]sx?$/, /\.vue$/, /\.vue\?vue/],
55
+ exclude: [/[\\/]node_modules[\\/]/, /[\\/]\.git[\\/]/, /[\\/]dist[\\/]/],
55
56
  imports: ["vue", "vue-i18n", "@vueuse/core", getFramework7AutoImports()],
56
57
  dirs: [
57
58
  "src/shared/composables/**",
@@ -0,0 +1,68 @@
1
+ # Reading and writing data
2
+
3
+ Every read is a reactive query or a deliberate on-demand call. Every write goes through `rdb`. A
4
+ third shape — a hand-rolled cache, or a raw write — produces "stale screen, no error, no symptom",
5
+ which is the one failure an offline app cannot afford: the user has no network to blame.
6
+
7
+ ## Reads
8
+
9
+ `useReactiveQuery(fn, { tables, queryKey, debounce })` from `@/shared/database/queries`.
10
+
11
+ - **`tables` is the invalidation contract.** It must list exactly the tables `fn` reads — no more,
12
+ no less. Under-list and a write to the missing table leaves the screen stale with no error.
13
+ Over-list and an unrelated write re-runs an expensive query for nothing.
14
+
15
+ A join means every joined table. `loadOrderDetail` reads the order, its lines, the customer, the
16
+ product names and the customer's tags, so it lists all five. Count the tables in the SQL, not the
17
+ ones you were thinking about.
18
+
19
+ - **`queryKey` is a process-wide identity built from arguments, not a label.** It is an array, and
20
+ two mounted queries whose keys match await one request and share its result. Framework7 keeps
21
+ pages mounted, so two instances of one screen genuinely coexist — which is exactly why the key
22
+ has to carry what distinguishes them: `["demo:order", orderId]`, never `["demo:order"]`.
23
+
24
+ Put every value the query reads in the key. A ref belongs there directly — `["demo:search", term]`
25
+ — and the query re-runs through its own `debounce` when the ref moves, so a filtered screen never
26
+ calls `refetch()` by hand.
27
+
28
+ - **`debounce`** collapses a burst. A loop of twenty inserts should refetch once, not twenty times.
29
+
30
+ - Reads may take the database directly: `searchOrders(getDatabase().db, term)`. Repositories take
31
+ the database as a parameter rather than reaching for the singleton, which is what makes them
32
+ testable — `tests/sales.repository.test.ts` runs them against sql.js.
33
+
34
+ ## Writes
35
+
36
+ Always `rdb`, never `getDatabase().db`:
37
+
38
+ ```ts
39
+ await saveOrder(rdb, { customerId, reference, lines }); // announces sales_order, order_line
40
+ await saveOrder(getDatabase().db, …); // writes, and nothing notices
41
+ ```
42
+
43
+ `rdb` wraps Kysely so a mutation publishes the tables it touched on the change bus. A raw write
44
+ lands in SQLite and no query hears about it, so the screen keeps showing the old rows until
45
+ something unrelated triggers a refetch. There is no error and nothing to see in review.
46
+
47
+ Anything that must be all-or-nothing goes in one transaction. `saveOrder` writes the order and its
48
+ lines together because a half-written order is worse than no order.
49
+
50
+ ## Forbidden
51
+
52
+ A module-scope `Map` or `ref` holding fetched rows with neither a TTL nor a bus subscription. Once
53
+ populated it never refreshes for the session, and the next write is invisible until restart.
54
+ In-flight dedup (a `Map<key, Promise>` cleared in `finally`) is concurrency control, not a cache,
55
+ and is fine — `useReactiveQuery` already does it.
56
+
57
+ ## Seed and demo helpers
58
+
59
+ Anything a person can press twice must survive being pressed twice. `seedSampleData` inserts the
60
+ catalogue only where missing and upserts tags, because a plain insert threw
61
+ `UNIQUE constraint failed: tag.label` on the second press.
62
+
63
+ ## Proof obligations
64
+
65
+ - A new query: state which tables its SQL touches and that `tables` matches.
66
+ - A new write: state that it goes through `rdb`, and whether it needs a transaction.
67
+ - Either: a test in `tests/` exercising it against sql.js. Type-checking a query proves nothing
68
+ about what it returns.
@@ -0,0 +1,106 @@
1
+ # Schema and migrations
2
+
3
+ `src/shared/database/schema.ts` is what Kysely type-checks every query against.
4
+ `src/shared/database/migrations.ts` is what actually creates the tables. The two are edited
5
+ together, always: a field in one and not the other is a runtime error the compiler cannot see.
6
+
7
+ ## Migrations
8
+
9
+ - Keys are ordered lexically and recorded once applied, so they are **numbered and never renamed**.
10
+ Renaming one makes it run again on a database that already has it.
11
+ - Never edit a migration that has shipped. Add the next one.
12
+ - A synced table is created with `createTableWithDefaults` when it carries the sync contract, or a
13
+ plain `createTable` when it does not. This template has no server, so plain tables are the norm.
14
+ - Declare foreign keys, and index the columns screens filter and join by. Without them every
15
+ dashboard aggregate is a full scan, which you will not notice until the table is large and the
16
+ device is slow.
17
+ - SQLite ignores foreign keys unless asked; `PRAGMA foreign_keys = ON` runs at the end of the
18
+ migration. It is per-connection, so a cascade is not something to rely on — `deleteOrder` removes
19
+ the lines explicitly.
20
+
21
+ ## Types
22
+
23
+ - Money is **integer cents**, named `*_cents`. A float total is a rounding bug waiting for a
24
+ large-enough order.
25
+ - Timestamps are ISO strings via `nowISO()`.
26
+ - A price copied onto an order line is copied deliberately, so a later catalogue change does not
27
+ rewrite history.
28
+
29
+ ## Getting an inserted id
30
+
31
+ Use `insertId`, never `.returning(...)`:
32
+
33
+ ```ts
34
+ const inserted = await trx.insertInto("sales_order").values({ ... }).executeTakeFirstOrThrow();
35
+ const orderId = Number(inserted.insertId ?? 0);
36
+ if (!orderId) throw new Error("the order was written but the database reported no id for it");
37
+ ```
38
+
39
+ `insertId` is the portable answer. Every engine reports it — the worker engines from
40
+ `last_insert_rowid()`, the sql.js test dialect the same way — so a repository written against it
41
+ behaves identically in tests and on a device. `.returning(...)` does work on the worker engines, but
42
+ it is the one thing that differs between them: the Capacitor plugin runs a statement issued inside an
43
+ open transaction through `query()`, which executes it and silently drops its RETURNING rows, so
44
+ `.returning("id").executeTakeFirstOrThrow()` threw `no result` from an insert that had in fact
45
+ succeeded. `@cavulsqa/mobile-db` now throws a message that says so instead.
46
+
47
+ ## The web path is the device path
48
+
49
+ Both run the same engine: SQLite compiled to WebAssembly, in a worker, with the database file in
50
+ OPFS. There is no Capacitor SQLite plugin in this template and no sql.js outside the tests. So:
51
+
52
+ - **Browser data survives a reload.** OPFS is durable storage, not memory. Clear it from
53
+ Diagnostics, or through the browser's site-data controls.
54
+ - A bug reproduced in the browser is very likely the same bug as on the device, which was not true
55
+ when the two ran different engines.
56
+ - `localStorage["app.storage.force"]` pins the chain to one engine id, for comparing them.
57
+
58
+ What still does not transfer is **timing**. A phone's storage and CPU are nothing like a laptop's,
59
+ and the worker is serial either way, so a ratio measured in a browser says nothing about the device.
60
+ Run the Diagnostics benchmark on hardware.
61
+
62
+ ## Proof obligations
63
+
64
+ A schema change needs a test in `tests/` that runs the migration and the affected queries against
65
+ sql.js. `tests/sales.repository.test.ts` is the pattern: build a Kysely on `createSqlJsDialect()`,
66
+ migrate, then assert on real rows — including the arithmetic. A total that type-checks can still be
67
+ computed wrong.
68
+
69
+ ## Writing a lot of rows
70
+
71
+ Measured on a phone, at 100k rows, per row written:
72
+
73
+ | how | per row |
74
+ | ------------------------------------------------ | -------------- |
75
+ | one insert per statement, each its own commit | 8-13 ms |
76
+ | one insert per statement, inside one transaction | ~0.45 ms |
77
+ | multi-row insert, ~150 rows per statement | 0.066-0.115 ms |
78
+
79
+ Roughly a hundredfold between the worst and best way to write the same row. SQLite caps parameters
80
+ per statement, so 150 rows of five columns is about the practical ceiling for one insert - chunk by
81
+ parameter budget, not by a round number.
82
+
83
+ ## Why a big write freezes the screen, and what to do
84
+
85
+ The database runs in one worker, and that worker is serial. A read cannot overtake a write already
86
+ in flight; it waits for everything queued ahead of it. So the cost to the UI is not how fast the
87
+ write is, it is **how much work the write committed to before the read arrived**.
88
+
89
+ Time a screen's read waits when it lands during a 1000-row write:
90
+
91
+ | write strategy | read waits |
92
+ | ---------------------------------------------- | ------------- |
93
+ | 1000 single inserts in one transaction | ~350-690 ms |
94
+ | 150 rows per statement, one transaction | ~36-53 ms |
95
+ | ten transactions of 100, awaited one at a time | **~21-32 ms** |
96
+
97
+ A naive loop stalls the screen for most of a second. Chunked transactions bring it under the
98
+ threshold anyone notices, and the reason is mechanical: awaiting each chunk means only one chunk is
99
+ ever queued, so an arriving read waits for 100 rows instead of 1000.
100
+
101
+ **So: write in chunks of about a hundred rows, use multi-row inserts inside each chunk, and await
102
+ each chunk before starting the next.** Do not wrap a thousand rows in one transaction to be fast -
103
+ it is faster in total and far worse for anyone looking at the screen while it runs.
104
+
105
+ The Diagnostics benchmark measures all three strategies, so this is checkable on any device rather
106
+ than taken on faith.
@@ -0,0 +1,48 @@
1
+ # UI: Material 3 Expressive on a Framework7 engine
2
+
3
+ The full workflow is the `m3-expressive` skill. These are the guardrails it rests on, each one a way
4
+ the screen breaks silently.
5
+
6
+ ## Framework7 is the engine, not the look
7
+
8
+ Only five Framework7 components resolve: `F7App`, `F7Views`, `F7View`, `F7Page`, `F7PageContent`
9
+ (the allowlist in `src/shared/utils/resolvers/resolvers.ts`). Routing, per-tab history and page
10
+ events come from them; everything visible is an `M3*` component. A visual `f7-*` component brings
11
+ Framework7's styling and modal stack back and fights the M3 overlay stack for Android back.
12
+
13
+ `f7`, `f7ready` are auto-imported; `f7route` / `f7router` arrive as props of a route component:
14
+ `defineProps<{ f7route: Router.Route; f7router: Router.Router }>()`. Inside shared components, reach
15
+ the router with `useViewRouter(el)`.
16
+
17
+ ## CSS is layered
18
+
19
+ `assets/css/app.css` declares `@layer framework7, theme, base, components, utilities`. Framework7's
20
+ CSS is the lowest layer and the M3 components sit below Tailwind's utilities, so a utility on a
21
+ component always wins and `!important` is never needed. New global CSS goes in a layer; component
22
+ CSS goes in the component's `<style scoped>`.
23
+
24
+ Layers only settle conflicts: a Framework7 rule nothing else contradicts still applies. Its core
25
+ sizes every bare `button` to `width: 100%` - the M3 components undo it for their own buttons, so a
26
+ hand-made `<button>` must set its width or use an `M3*` button.
27
+
28
+ In `<style scoped>`, wrap the whole selector: `:global(.parent .child)`. Vue compiles
29
+ `:global(.parent) .child` to `.parent` alone, silently styling the wrong element.
30
+
31
+ ## Tokens only
32
+
33
+ Tailwind's default palette, radii, shadows and easings are cleared in
34
+ `assets/css/theme/tailwind.css` and refilled from `--md-sys-*`. A class like `bg-red-500` does not
35
+ exist. Colours: roles. Corners: the shape scale. Type: `type-*`. Hex values live in
36
+ `src/app/theme.config.ts` only.
37
+
38
+ ## Icons are SVG components, checked at build time
39
+
40
+ `<i-ms-<name>-rounded />` from Material Symbols (outlined: `-outline-rounded`, filled: `-rounded`).
41
+ `autoInstall` is off, so a wrong name fails the build instead of rendering nothing. In TS:
42
+ `import Icon from "~icons/material-symbols/<name>"` and `markRaw` it before putting it in reactive
43
+ state.
44
+
45
+ ## Proof obligations
46
+
47
+ `vp check`, `pnpm type-check` and `vp test` pass. Say what you saw run - light and dark, compact width,
48
+ reduced motion - and say plainly when you have not seen it on a device.
@@ -0,0 +1,43 @@
1
+ # Modules, domains and shared
2
+
3
+ ```
4
+ modules/<feature>/router/routes/<feature>.routes.ts default export, Router.RouteParameters[]
5
+ modules/<feature>/views/<Name>View.vue thin, presentational
6
+ modules/<feature>/components/*.vue props in, emits out
7
+ modules/<feature>/composables/use*.ts the feature's state and actions
8
+ domains/<domain>/<domain>.repository.ts SQL only
9
+ shared/… what two modules genuinely both need
10
+ ```
11
+
12
+ ## The seam
13
+
14
+ A **view** wires a composable to components. If it holds business logic, that logic belongs in the
15
+ composable; if it holds SQL, that belongs in a repository.
16
+
17
+ A **composable** owns state, queries and actions for one feature. It may import repositories and
18
+ the reactive query helpers. It returns refs and functions, never markup.
19
+
20
+ A **repository** is plain functions over Kysely. No `ref`, no lifecycle, no Framework7, no imports
21
+ from `modules/`. It takes the database as a parameter — that is what makes it testable, and reaching
22
+ for the singleton instead is what made the first version of this template untestable.
23
+
24
+ A **component** takes props and emits events. It does not query the database.
25
+
26
+ ## Direction of dependencies
27
+
28
+ `modules → domains → shared → packages`. Never backwards. A repository importing from a module, or
29
+ `shared` importing from `modules`, means something is in the wrong place.
30
+
31
+ ## Adding a feature
32
+
33
+ 1. `modules/<feature>/` with the four folders.
34
+ 2. Its routes in `router/routes/<feature>.routes.ts`, registered in `src/router/index.ts` before the
35
+ global catch-all.
36
+ 3. A tab in `src/app/tabs.ts` only if it is a top-level section. Otherwise it is a pushed page, and
37
+ pushed pages are an `AppPage` with `back`, which hides the navigation bar.
38
+ 4. Strings in **both** `locales/en.json` and `locales/fr.json`. A missing key renders the key.
39
+
40
+ ## shared/ is not a junk drawer
41
+
42
+ Something goes in `shared/` when two modules import it today. Not when one module might later. The
43
+ test is: can you name the second caller? If not, it lives in the module that uses it.
@@ -0,0 +1,60 @@
1
+ # Capacitor and native behaviour
2
+
3
+ Every native call is guarded with `Capacitor.isNativePlatform()` or
4
+ `Capacitor.getPlatform() === "web"`. `vp dev` in a browser must keep working — it is how the app is
5
+ inspected — so a handler that assumes a device breaks the fastest feedback loop you have.
6
+
7
+ Native wiring that needs the Framework7 instance runs inside `f7ready`, not at module load.
8
+ `src/plugins/capacitor/index.ts` is the single entry point.
9
+
10
+ ## What is already handled, and why it is not simple
11
+
12
+ - **Back button** (`useAndroidBackButton`) first asks the M3 overlay stack to close its topmost
13
+ overlay (sheet, dialog, menu, FAB menu) - a persistent one swallows back instead. Then the current
14
+ tab's history, then from another tab's root the start destination, then it minimises rather than
15
+ exits. Overlays register themselves through `useOverlay`; a hand-built modal is invisible to back.
16
+ - **Keyboard** (`useKeyboard`) scrolls the focused input into view on _every_ phase of the
17
+ transition, not once — the layout is still settling at `keyboardWillShow` and only
18
+ `keyboardDidShow` sees the final height. It also hides the navigation bar, which otherwise steals a
19
+ row from the field being typed into.
20
+ - **Status bar** overlays the web view (edge to edge); the top app bar pads itself with the inset,
21
+ and the icon colour follows the theme's dark mode.
22
+ - **Splash** stays up until `hideSplashScreen()`, called on a frame boundary so there is no flash of
23
+ an unpainted shell.
24
+
25
+ Do not simplify these into a single listener. Each branch is there because of a specific device
26
+ behaviour, and the comments say which.
27
+
28
+ ## The bootstrap must never fail silently
29
+
30
+ `main.ts` opens the database before mounting, inside a `try`, and renders the failure on the page if
31
+ it throws. An earlier version awaited it at module top level: a rejection produced an empty `#app`
32
+ and a completely silent console, which is the worst possible failure for whoever generates from this
33
+ template. The timeout exists so a hang cannot masquerade as a blank screen either.
34
+
35
+ Anything else added to the bootstrap follows the same shape: guarded, and loud when it fails.
36
+
37
+ ## Fixed elements and the shell
38
+
39
+ The compact navigation bar floats over the bottom of the views; the shell publishes its height as
40
+ `--app-nav-offset` and `--app-bottom-inset` (offset plus the gesture area). So:
41
+
42
+ - Anything floating above content offsets by `--app-bottom-inset` - `AppPage`'s `#fab` slot, the
43
+ snackbar host and the floating toolbar already do. Never measure the bar.
44
+ - Page content is already padded by the same variable; do not add your own bottom spacer for it.
45
+ - The FAB menu opens upward, away from the bar.
46
+
47
+ ## Permissions live in a generated folder
48
+
49
+ `android/` is generated by `cap add android` and gitignored, so a permission the app needs is not
50
+ carried by the template. Add it to `android/app/src/main/AndroidManifest.xml` after generating:
51
+
52
+ - **Location** (the chat's _Location_ attachment): `ACCESS_COARSE_LOCATION` and
53
+ `ACCESS_FINE_LOCATION`. The web view's own `navigator.geolocation` does the lookup; Capacitor
54
+ asks the person at runtime only for a permission the manifest declares, so without these the
55
+ lookup fails as `denied` and the chat offers the depot instead.
56
+
57
+ ## Proof obligations
58
+
59
+ Say which platform you tested on. "Type-checks" is not a claim about a device, and neither is a
60
+ browser. If you have not run it on Android, say so.