@cavulsqa/create 2.9.3 → 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 (199) 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/auto-imports.d.ts +9 -0
  6. package/templates/f7-app/package.json +6 -3
  7. package/templates/f7-app/src/env.d.ts +1 -0
  8. package/templates/f7-app/src/main.ts +3 -0
  9. package/templates/f7-app/src/plugins/bootstrapError.ts +2 -0
  10. package/templates/f7-app/src/plugins/recorder.plugin.ts +155 -0
  11. package/templates/f7-app/src/shared/recorder/capacitorSink.ts +124 -0
  12. package/templates/f7-app/vite.config.ts +1 -0
  13. package/templates/m3e-app/.claude/rules/data-fetching.md +68 -0
  14. package/templates/m3e-app/.claude/rules/database.md +106 -0
  15. package/templates/m3e-app/.claude/rules/m3e-ui.md +48 -0
  16. package/templates/m3e-app/.claude/rules/modules.md +43 -0
  17. package/templates/m3e-app/.claude/rules/native.md +60 -0
  18. package/templates/m3e-app/.claude/skills/m3-expressive/SKILL.md +68 -0
  19. package/templates/m3e-app/.claude/skills/m3-expressive/color.md +44 -0
  20. package/templates/m3e-app/.claude/skills/m3-expressive/components.md +325 -0
  21. package/templates/m3e-app/.claude/skills/m3-expressive/layout.md +42 -0
  22. package/templates/m3e-app/.claude/skills/m3-expressive/motion.md +51 -0
  23. package/templates/m3e-app/.claude/skills/m3-expressive/shapes-type.md +48 -0
  24. package/templates/m3e-app/.claude/skills/m3-expressive/sources.md +53 -0
  25. package/templates/m3e-app/.claude/skills/module-architecture/SKILL.md +65 -0
  26. package/templates/m3e-app/.claude/skills/module-architecture/file-templates.md +178 -0
  27. package/templates/m3e-app/.claude/skills/reactive-data/SKILL.md +90 -0
  28. package/templates/m3e-app/.claude/skills/reactive-data/testing.md +55 -0
  29. package/templates/m3e-app/.env.example +32 -0
  30. package/templates/m3e-app/CLAUDE.md +93 -0
  31. package/templates/m3e-app/auto-imports.d.ts +851 -0
  32. package/templates/m3e-app/capacitor.config.ts +44 -0
  33. package/templates/m3e-app/components.d.ts +252 -0
  34. package/templates/m3e-app/index.html +16 -0
  35. package/templates/m3e-app/package.json +107 -0
  36. package/templates/m3e-app/src/App.vue +129 -0
  37. package/templates/m3e-app/src/app/pragmas.config.ts +35 -0
  38. package/templates/m3e-app/src/app/scroll.config.ts +20 -0
  39. package/templates/m3e-app/src/app/storage.config.ts +65 -0
  40. package/templates/m3e-app/src/app/tabs.ts +33 -0
  41. package/templates/m3e-app/src/app/theme.config.ts +51 -0
  42. package/templates/m3e-app/src/assets/css/app.css +18 -0
  43. package/templates/m3e-app/src/assets/css/base.css +53 -0
  44. package/templates/m3e-app/src/assets/css/layout/container-transform.css +113 -0
  45. package/templates/m3e-app/src/assets/css/layout/shell.css +69 -0
  46. package/templates/m3e-app/src/assets/css/layout/transitions.css +121 -0
  47. package/templates/m3e-app/src/assets/css/m3e.css +6 -0
  48. package/templates/m3e-app/src/assets/css/theme/framework7.css +18 -0
  49. package/templates/m3e-app/src/assets/css/theme/tailwind.css +365 -0
  50. package/templates/m3e-app/src/domains/benchmark/benchmark.dataset.ts +209 -0
  51. package/templates/m3e-app/src/domains/benchmark/benchmark.suite.ts +662 -0
  52. package/templates/m3e-app/src/domains/sales/sales.repository.ts +458 -0
  53. package/templates/m3e-app/src/env.d.ts +40 -0
  54. package/templates/m3e-app/src/locales/ar.json +1260 -0
  55. package/templates/m3e-app/src/locales/en.json +1260 -0
  56. package/templates/m3e-app/src/locales/fr.json +1260 -0
  57. package/templates/m3e-app/src/main.ts +41 -0
  58. package/templates/m3e-app/src/modules/demo/components/DemoBenchmark.vue +119 -0
  59. package/templates/m3e-app/src/modules/demo/components/DemoBusLog.vue +63 -0
  60. package/templates/m3e-app/src/modules/demo/components/DemoCreateOrderSheet.vue +203 -0
  61. package/templates/m3e-app/src/modules/demo/components/DemoMetricsSheet.vue +75 -0
  62. package/templates/m3e-app/src/modules/demo/components/DemoOrderList.vue +75 -0
  63. package/templates/m3e-app/src/modules/demo/components/DemoPipelineBenchmark.vue +65 -0
  64. package/templates/m3e-app/src/modules/demo/components/DemoStatCards.vue +71 -0
  65. package/templates/m3e-app/src/modules/demo/composables/useBenchmark.ts +139 -0
  66. package/templates/m3e-app/src/modules/demo/composables/useOrderStatus.ts +64 -0
  67. package/templates/m3e-app/src/modules/demo/composables/useReactiveDemo.ts +171 -0
  68. package/templates/m3e-app/src/modules/demo/router/routes/demo.routes.ts +22 -0
  69. package/templates/m3e-app/src/modules/demo/views/DemoView.vue +210 -0
  70. package/templates/m3e-app/src/modules/demo/views/OrderDetailView.vue +136 -0
  71. package/templates/m3e-app/src/modules/demo/views/OrderSearchView.vue +74 -0
  72. package/templates/m3e-app/src/modules/gallery/components/GalleryBlock.vue +16 -0
  73. package/templates/m3e-app/src/modules/gallery/components/GalleryCarouselTile.vue +71 -0
  74. package/templates/m3e-app/src/modules/gallery/components/GalleryCustomerForm.vue +76 -0
  75. package/templates/m3e-app/src/modules/gallery/components/GalleryProofOfDelivery.vue +69 -0
  76. package/templates/m3e-app/src/modules/gallery/components/agenda/AgendaDay.vue +106 -0
  77. package/templates/m3e-app/src/modules/gallery/components/agenda/AgendaMonth.vue +40 -0
  78. package/templates/m3e-app/src/modules/gallery/components/agenda/AgendaVisitList.vue +45 -0
  79. package/templates/m3e-app/src/modules/gallery/components/chat/ChatAttachSheet.vue +106 -0
  80. package/templates/m3e-app/src/modules/gallery/components/chat/ChatContactPicker.vue +85 -0
  81. package/templates/m3e-app/src/modules/gallery/components/chat/ChatInviteComposer.vue +131 -0
  82. package/templates/m3e-app/src/modules/gallery/components/chat/ChatPollComposer.vue +148 -0
  83. package/templates/m3e-app/src/modules/gallery/components/chat/ChatRecentPhotos.vue +62 -0
  84. package/templates/m3e-app/src/modules/gallery/components/inputs/InputsAccount.vue +79 -0
  85. package/templates/m3e-app/src/modules/gallery/components/inputs/InputsTags.vue +53 -0
  86. package/templates/m3e-app/src/modules/gallery/components/inputs/InputsVerification.vue +73 -0
  87. package/templates/m3e-app/src/modules/gallery/components/inputs/PasswordStrength.vue +37 -0
  88. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryButtons.vue +128 -0
  89. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryCarousels.vue +236 -0
  90. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryCharts.vue +84 -0
  91. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryFabs.vue +70 -0
  92. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryInputs.vue +219 -0
  93. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryNavigation.vue +141 -0
  94. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryOverlays.vue +368 -0
  95. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryPickers.vue +175 -0
  96. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryProgress.vue +118 -0
  97. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryScale.vue +103 -0
  98. package/templates/m3e-app/src/modules/gallery/components/sections/GallerySelection.vue +219 -0
  99. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryShapes.vue +48 -0
  100. package/templates/m3e-app/src/modules/gallery/components/sections/GallerySurfaces.vue +213 -0
  101. package/templates/m3e-app/src/modules/gallery/components/sections/GalleryTables.vue +185 -0
  102. package/templates/m3e-app/src/modules/gallery/components/surfaces/SurfacesContainerTransform.vue +46 -0
  103. package/templates/m3e-app/src/modules/gallery/composables/chatTeam.ts +18 -0
  104. package/templates/m3e-app/src/modules/gallery/composables/composeKind.ts +2 -0
  105. package/templates/m3e-app/src/modules/gallery/composables/currentPosition.ts +35 -0
  106. package/templates/m3e-app/src/modules/gallery/composables/demoOrders.ts +29 -0
  107. package/templates/m3e-app/src/modules/gallery/composables/featuredStories.ts +73 -0
  108. package/templates/m3e-app/src/modules/gallery/composables/fieldFormats.ts +36 -0
  109. package/templates/m3e-app/src/modules/gallery/composables/openExternal.ts +11 -0
  110. package/templates/m3e-app/src/modules/gallery/composables/routePlan.ts +90 -0
  111. package/templates/m3e-app/src/modules/gallery/composables/useAgenda.ts +61 -0
  112. package/templates/m3e-app/src/modules/gallery/composables/useChatCustomers.ts +31 -0
  113. package/templates/m3e-app/src/modules/gallery/composables/useChatDemo.ts +299 -0
  114. package/templates/m3e-app/src/modules/gallery/composables/useGallerySections.ts +230 -0
  115. package/templates/m3e-app/src/modules/gallery/composables/useLocalAttachments.ts +71 -0
  116. package/templates/m3e-app/src/modules/gallery/composables/useLocationShare.ts +38 -0
  117. package/templates/m3e-app/src/modules/gallery/composables/usePhotoScenes.ts +82 -0
  118. package/templates/m3e-app/src/modules/gallery/composables/useTreeDemo.ts +67 -0
  119. package/templates/m3e-app/src/modules/gallery/composables/wilayas.ts +67 -0
  120. package/templates/m3e-app/src/modules/gallery/router/routes/gallery.routes.ts +52 -0
  121. package/templates/m3e-app/src/modules/gallery/views/GalleryAgendaView.vue +117 -0
  122. package/templates/m3e-app/src/modules/gallery/views/GalleryChatView.vue +292 -0
  123. package/templates/m3e-app/src/modules/gallery/views/GalleryContactsView.vue +77 -0
  124. package/templates/m3e-app/src/modules/gallery/views/GalleryFeaturedView.vue +60 -0
  125. package/templates/m3e-app/src/modules/gallery/views/GalleryLoginView.vue +135 -0
  126. package/templates/m3e-app/src/modules/gallery/views/GalleryOnboardingView.vue +116 -0
  127. package/templates/m3e-app/src/modules/gallery/views/GallerySectionView.vue +27 -0
  128. package/templates/m3e-app/src/modules/gallery/views/GalleryTabsView.vue +135 -0
  129. package/templates/m3e-app/src/modules/gallery/views/GalleryView.vue +50 -0
  130. package/templates/m3e-app/src/modules/home/components/HomeHero.vue +57 -0
  131. package/templates/m3e-app/src/modules/home/composables/useHomeFeatures.ts +128 -0
  132. package/templates/m3e-app/src/modules/home/composables/useOpenFeature.ts +22 -0
  133. package/templates/m3e-app/src/modules/home/router/routes/home.routes.ts +21 -0
  134. package/templates/m3e-app/src/modules/home/views/FeatureDetailView.vue +66 -0
  135. package/templates/m3e-app/src/modules/home/views/HomeView.vue +47 -0
  136. package/templates/m3e-app/src/modules/settings/components/RolePalette.vue +38 -0
  137. package/templates/m3e-app/src/modules/settings/components/SeedSwatches.vue +41 -0
  138. package/templates/m3e-app/src/modules/settings/components/SettingsChoice.vue +43 -0
  139. package/templates/m3e-app/src/modules/settings/components/StudioPreview.vue +52 -0
  140. package/templates/m3e-app/src/modules/settings/router/routes/settings.routes.ts +17 -0
  141. package/templates/m3e-app/src/modules/settings/types.ts +7 -0
  142. package/templates/m3e-app/src/modules/settings/views/ColorStudioView.vue +166 -0
  143. package/templates/m3e-app/src/modules/settings/views/SettingsView.vue +157 -0
  144. package/templates/m3e-app/src/plugins/bootstrapError.ts +62 -0
  145. package/templates/m3e-app/src/plugins/capacitor/index.ts +14 -0
  146. package/templates/m3e-app/src/plugins/capacitor/useAndroidBackButton.ts +36 -0
  147. package/templates/m3e-app/src/plugins/capacitor/useKeyboard.ts +48 -0
  148. package/templates/m3e-app/src/plugins/capacitor/useSplashScreen.ts +43 -0
  149. package/templates/m3e-app/src/plugins/capacitor/useStatusBar.ts +16 -0
  150. package/templates/m3e-app/src/plugins/framework7.plugin.ts +36 -0
  151. package/templates/m3e-app/src/plugins/i18n.plugin.ts +87 -0
  152. package/templates/m3e-app/src/plugins/m3e.plugin.ts +22 -0
  153. package/templates/m3e-app/src/plugins/seed.plugin.ts +7 -0
  154. package/templates/m3e-app/src/plugins/sqlite.plugin.ts +32 -0
  155. package/templates/m3e-app/src/router/global/global.routes.ts +12 -0
  156. package/templates/m3e-app/src/router/index.ts +20 -0
  157. package/templates/m3e-app/src/shared/components/error/404.vue +13 -0
  158. package/templates/m3e-app/src/shared/components/layout/EmptyState.vue +30 -0
  159. package/templates/m3e-app/src/shared/components/layout/SectionHeader.vue +10 -0
  160. package/templates/m3e-app/src/shared/components/page/AppPage.vue +104 -0
  161. package/templates/m3e-app/src/shared/composables/navigation/useActiveTab.ts +39 -0
  162. package/templates/m3e-app/src/shared/composables/navigation/useContainerTransform.ts +117 -0
  163. package/templates/m3e-app/src/shared/composables/navigation/useNavigationGuard.ts +48 -0
  164. package/templates/m3e-app/src/shared/composables/navigation/useNavigationVisibility.ts +64 -0
  165. package/templates/m3e-app/src/shared/composables/navigation/useViewRouter.ts +25 -0
  166. package/templates/m3e-app/src/shared/composables/navigation/useWindowClass.ts +22 -0
  167. package/templates/m3e-app/src/shared/composables/theme/useThemeSettings.ts +155 -0
  168. package/templates/m3e-app/src/shared/database/candidates/capacitorSqlite.ts +36 -0
  169. package/templates/m3e-app/src/shared/database/candidates/index.ts +4 -0
  170. package/templates/m3e-app/src/shared/database/candidates/opfsSahPool.ts +25 -0
  171. package/templates/m3e-app/src/shared/database/candidates/types.ts +56 -0
  172. package/templates/m3e-app/src/shared/database/candidates/waSqlite.ts +56 -0
  173. package/templates/m3e-app/src/shared/database/database.ts +219 -0
  174. package/templates/m3e-app/src/shared/database/index.ts +3 -0
  175. package/templates/m3e-app/src/shared/database/migrations.ts +223 -0
  176. package/templates/m3e-app/src/shared/database/opfs.worker.ts +5 -0
  177. package/templates/m3e-app/src/shared/database/queries.ts +26 -0
  178. package/templates/m3e-app/src/shared/database/schema.ts +153 -0
  179. package/templates/m3e-app/src/shared/database/storage.ts +64 -0
  180. package/templates/m3e-app/src/shared/database/wa.worker.ts +5 -0
  181. package/templates/m3e-app/src/shared/utils/lazyRoute.ts +41 -0
  182. package/templates/m3e-app/src/shared/utils/resolvers/resolvers.ts +42 -0
  183. package/templates/m3e-app/src/shared/utils/textDirection.ts +22 -0
  184. package/templates/m3e-app/src/shared/utils/theme/themeSettings.ts +59 -0
  185. package/templates/m3e-app/src/shared/utils/tone.ts +8 -0
  186. package/templates/m3e-app/tests/benchmark.suite.test.ts +104 -0
  187. package/templates/m3e-app/tests/containerTransform.test.ts +58 -0
  188. package/templates/m3e-app/tests/fieldFormats.test.ts +25 -0
  189. package/templates/m3e-app/tests/lazyRoute.test.ts +30 -0
  190. package/templates/m3e-app/tests/locales.test.ts +73 -0
  191. package/templates/m3e-app/tests/migrations.test.ts +74 -0
  192. package/templates/m3e-app/tests/navigationGuard.test.ts +82 -0
  193. package/templates/m3e-app/tests/openDatabase.test.ts +54 -0
  194. package/templates/m3e-app/tests/sales.repository.test.ts +329 -0
  195. package/templates/m3e-app/tests/storage.test.ts +117 -0
  196. package/templates/m3e-app/tests/textDirection.test.ts +11 -0
  197. package/templates/m3e-app/tooling/linkedPackages.ts +112 -0
  198. package/templates/m3e-app/tsconfig.json +21 -0
  199. package/templates/m3e-app/vite.config.ts +144 -0
@@ -0,0 +1,153 @@
1
+ import type { Generated } from "kysely";
2
+
3
+ /**
4
+ * A small relational schema rather than one flat table, so the demo can show what the reactive
5
+ * layer is actually for: a write to one table refreshing a screen that reads three others through
6
+ * joins and aggregates.
7
+ *
8
+ * customer 1-N sales_order 1-N order_line N-1 product, plus customer N-N tag through customer_tag.
9
+ */
10
+ export interface CustomerTable {
11
+ id: Generated<number>;
12
+ created_at: string;
13
+ name: string;
14
+ city: string;
15
+ }
16
+
17
+ export interface ProductTable {
18
+ id: Generated<number>;
19
+ created_at: string;
20
+ name: string;
21
+ /** Integer cents. Money in a float is a rounding bug waiting for a total. */
22
+ price_cents: number;
23
+ }
24
+
25
+ export interface SalesOrderTable {
26
+ id: Generated<number>;
27
+ created_at: string;
28
+ customer_id: number;
29
+ reference: string;
30
+ status: "draft" | "confirmed" | "delivered";
31
+ }
32
+
33
+ export interface OrderLineTable {
34
+ id: Generated<number>;
35
+ order_id: number;
36
+ product_id: number;
37
+ quantity: number;
38
+ /** Copied from the product at the time of sale, so later price changes do not rewrite history. */
39
+ unit_price_cents: number;
40
+ }
41
+
42
+ export interface TagTable {
43
+ id: Generated<number>;
44
+ label: string;
45
+ }
46
+
47
+ export interface CustomerTagTable {
48
+ customer_id: number;
49
+ tag_id: number;
50
+ }
51
+
52
+ export interface Database {
53
+ customer: CustomerTable;
54
+ product: ProductTable;
55
+ sales_order: SalesOrderTable;
56
+ order_line: OrderLineTable;
57
+ tag: TagTable;
58
+ customer_tag: CustomerTagTable;
59
+ bench_region: BenchRegionTable;
60
+ bench_city: BenchCityTable;
61
+ bench_customer: BenchCustomerTable;
62
+ bench_category: BenchCategoryTable;
63
+ bench_product: BenchProductTable;
64
+ bench_warehouse: BenchWarehouseTable;
65
+ bench_stock: BenchStockTable;
66
+ bench_order: BenchOrderTable;
67
+ bench_order_line: BenchOrderLineTable;
68
+ bench_payment: BenchPaymentTable;
69
+ }
70
+
71
+ /**
72
+ * The benchmark's own tables, deliberately separate from the app's.
73
+ *
74
+ * Ten tables at roughly 100k rows, shaped like a distribution domain - regions down to order lines,
75
+ * plus stock and payments - because the questions worth answering are about joins across a real
76
+ * cardinality spread, not about two tables with a foreign key. A five-level join here touches a
77
+ * 10-row table and a 40k-row table in the same query, which is where planners and indexes start to
78
+ * matter.
79
+ *
80
+ * Measuring against the app's own tables would either corrupt them or force a rollback, and a
81
+ * rolled-back transaction never pays the commit - most of what a write costs on a phone.
82
+ */
83
+ export interface BenchRegionTable {
84
+ id: Generated<number>;
85
+ name: string;
86
+ }
87
+
88
+ export interface BenchCityTable {
89
+ id: Generated<number>;
90
+ region_id: number;
91
+ name: string;
92
+ }
93
+
94
+ export interface BenchCustomerTable {
95
+ id: Generated<number>;
96
+ city_id: number;
97
+ code: string;
98
+ name: string;
99
+ /** Unindexed on purpose: the LIKE case needs a genuine full scan over 5k rows. */
100
+ notes: string;
101
+ credit_cents: number;
102
+ created_at: string;
103
+ }
104
+
105
+ export interface BenchCategoryTable {
106
+ id: Generated<number>;
107
+ name: string;
108
+ }
109
+
110
+ export interface BenchProductTable {
111
+ id: Generated<number>;
112
+ category_id: number;
113
+ sku: string;
114
+ name: string;
115
+ price_cents: number;
116
+ }
117
+
118
+ export interface BenchWarehouseTable {
119
+ id: Generated<number>;
120
+ name: string;
121
+ }
122
+
123
+ export interface BenchStockTable {
124
+ id: Generated<number>;
125
+ warehouse_id: number;
126
+ product_id: number;
127
+ quantity: number;
128
+ }
129
+
130
+ export interface BenchOrderTable {
131
+ id: Generated<number>;
132
+ customer_id: number;
133
+ reference: string;
134
+ status: string;
135
+ created_at: string;
136
+ total_cents: number;
137
+ }
138
+
139
+ export interface BenchOrderLineTable {
140
+ id: Generated<number>;
141
+ order_id: number;
142
+ product_id: number;
143
+ quantity: number;
144
+ unit_price_cents: number;
145
+ }
146
+
147
+ export interface BenchPaymentTable {
148
+ id: Generated<number>;
149
+ order_id: number;
150
+ amount_cents: number;
151
+ method: string;
152
+ created_at: string;
153
+ }
@@ -0,0 +1,64 @@
1
+ import type { StorageProbe } from "./candidates/types";
2
+
3
+ export type { StorageProbe };
4
+
5
+ /**
6
+ * `navigator.storage.getDirectory` is the whole of what a main-thread probe can see. Synchronous
7
+ * access handles are Worker-only in Chromium, so checking `createSyncAccessHandle` on
8
+ * `FileSystemFileHandle.prototype` is false on *every* device - it rejected a phone that had been
9
+ * running OPFS for hours. Anything finer belongs to the engine, via `describeOpenFailure`.
10
+ */
11
+ export function probeOpfsCapable(): StorageProbe {
12
+ if (typeof navigator === "undefined" || !navigator.storage?.getDirectory) {
13
+ return {
14
+ supported: false,
15
+ reason:
16
+ "This WebView has no Origin Private File System, so the database cannot be stored. Update " +
17
+ "Android System WebView from the Play Store - it updates separately from Android itself.",
18
+ };
19
+ }
20
+
21
+ return { supported: true };
22
+ }
23
+
24
+ export function probeIndexedDb(): StorageProbe {
25
+ if (typeof indexedDB === "undefined") {
26
+ return {
27
+ supported: false,
28
+ reason: "This WebView has no IndexedDB, which leaves nowhere to keep the database.",
29
+ };
30
+ }
31
+ return { supported: true };
32
+ }
33
+
34
+ export function describeOpenFailure(error: unknown): string {
35
+ const detail = error instanceof Error ? error.message : String(error);
36
+
37
+ if (/SyncAccessHandle|createSyncAccessHandle|sahpool|SAH/i.test(detail)) {
38
+ return (
39
+ "SQLite could not take a synchronous file handle. Either this WebView is too old - update " +
40
+ "Android System WebView from the Play Store, it updates separately from Android - or another " +
41
+ `copy of the app still holds the database open. (${detail})`
42
+ );
43
+ }
44
+
45
+ return `The database could not be opened: ${detail}`;
46
+ }
47
+
48
+ /**
49
+ * Android System WebView updates from the Play Store independently of Android itself, so the OS
50
+ * version says nothing useful here - a phone on Android 7 can be current and one on Android 14 old.
51
+ */
52
+ export function webviewVersion(): number | null {
53
+ if (typeof navigator === "undefined") return null;
54
+ const match = /Chrome\/(\d+)/.exec(navigator.userAgent);
55
+ return match ? Number(match[1]) : null;
56
+ }
57
+
58
+ /** Advice, never a gate: a vendor WebView build can differ either way, so the engine decides. */
59
+ export const MINIMUM_CHROMIUM_FOR_OPFS = 109;
60
+
61
+ export function webviewLikelyTooOld(): boolean {
62
+ const version = webviewVersion();
63
+ return version !== null && version < MINIMUM_CHROMIUM_FOR_OPFS;
64
+ }
@@ -0,0 +1,5 @@
1
+ import { runWaWorker } from "@cavulsqa/mobile-db/wa";
2
+
3
+ // The whole worker. It lives in the app because only the app's bundler can emit a worker and the
4
+ // wa-sqlite wasm it loads; the logic is all in @cavulsqa/mobile-db.
5
+ runWaWorker();
@@ -0,0 +1,41 @@
1
+ import type { Router } from "framework7/types";
2
+ import { i18n } from "@/plugins/i18n.plugin";
3
+ import { m3e } from "@/plugins/m3e.plugin";
4
+
5
+ type RouteComponentModule = { default: unknown };
6
+
7
+ /**
8
+ * Framework7 sets `allowPageChange = false` before calling a route's async hook and only restores
9
+ * it from `resolve` or `reject`. A hook that settles neither - a chunk that fails to download, or a
10
+ * module that throws while evaluating - leaves that view's router locked for the rest of the
11
+ * session, so every later navigation and back press is silently ignored. Routing the import
12
+ * through here guarantees the router is always released.
13
+ *
14
+ * A failed tap must not look like a dead button, so the failure also says so, with a reload: the
15
+ * browser can keep a failed module import for the life of the document, which makes a plain retry
16
+ * fail the same way.
17
+ */
18
+ export function lazyRoute(
19
+ load: () => Promise<RouteComponentModule>,
20
+ ): Router.RouteParameters["async"] {
21
+ return function routeAsync({ resolve, reject }) {
22
+ load()
23
+ .then((module) => {
24
+ resolve({ component: module.default });
25
+ })
26
+ .catch((error: unknown) => {
27
+ console.error("[router] Unable to load route component", error);
28
+ reject();
29
+ void reportFailure();
30
+ });
31
+ };
32
+ }
33
+
34
+ async function reportFailure(): Promise<void> {
35
+ const { t } = i18n.global;
36
+ const result = await m3e.config.snackbar.show({
37
+ message: t("shell.routeFailed"),
38
+ action: t("shell.reload"),
39
+ });
40
+ if (result === "action") window.location.reload();
41
+ }
@@ -0,0 +1,42 @@
1
+ import type { ComponentResolver } from "unplugin-vue-components/types";
2
+
3
+ /**
4
+ * Framework7 is this template's navigation engine and nothing else: the app root, the tab views,
5
+ * each view's router and the page lifecycle the reactive queries pause on. Everything you see is a
6
+ * Material 3 Expressive component from @cavulsqa/m3e-vue. The allowlist is the boundary - adding a
7
+ * visual `f7-*` component here brings Framework7's styling back, which is what this flavour exists
8
+ * to avoid.
9
+ */
10
+ const ENGINE_COMPONENTS = new Set(["f7-app", "f7-views", "f7-view", "f7-page", "f7-page-content"]);
11
+
12
+ const toCamelCase = (value: string) =>
13
+ value.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase());
14
+
15
+ function kebabOf(name: string): string | null {
16
+ if (/^F7[A-Z]/.test(name))
17
+ return name
18
+ .replace(/^F7/, "f7-")
19
+ .replace(/([a-z])([A-Z])/g, "$1-$2")
20
+ .toLowerCase();
21
+ if (name.startsWith("f7-")) return name;
22
+ return null;
23
+ }
24
+
25
+ export function Framework7VueResolver(): ComponentResolver {
26
+ return {
27
+ type: "component",
28
+ resolve: (name: string) => {
29
+ const kebab = kebabOf(name);
30
+ if (kebab && ENGINE_COMPONENTS.has(kebab))
31
+ return { name: toCamelCase(kebab), from: "framework7-vue" };
32
+ return undefined;
33
+ },
34
+ };
35
+ }
36
+
37
+ export function getFramework7AutoImports() {
38
+ return {
39
+ "framework7/lite": ["utils", "getDevice", "Dom7"],
40
+ "framework7-vue": ["f7ready", "f7"],
41
+ };
42
+ }
@@ -0,0 +1,22 @@
1
+ const RIGHT_TO_LEFT = new Set(["ar", "arc", "ckb", "dv", "fa", "he", "ps", "sd", "ug", "ur", "yi"]);
2
+
3
+ interface TextInfoLocale {
4
+ getTextInfo?: () => { direction?: string };
5
+ textInfo?: { direction?: string };
6
+ }
7
+
8
+ /**
9
+ * Which way a language is written. Asks the engine first (`Intl.Locale` text info), then a list of
10
+ * the right-to-left scripts' languages, so Arabic lays out mirrored even on a WebView that predates
11
+ * the API.
12
+ */
13
+ export function textDirection(locale: string): "ltr" | "rtl" {
14
+ try {
15
+ const info = new Intl.Locale(locale) as Intl.Locale & TextInfoLocale;
16
+ const direction = (info.getTextInfo?.() ?? info.textInfo)?.direction;
17
+ if (direction === "rtl" || direction === "ltr") return direction;
18
+ } catch {
19
+ return "ltr";
20
+ }
21
+ return RIGHT_TO_LEFT.has(locale.split(/[-_]/)[0]!.toLowerCase()) ? "rtl" : "ltr";
22
+ }
@@ -0,0 +1,59 @@
1
+ import { CONTRAST, SCHEME_VARIANTS, isHexColor, type SchemeVariant } from "@cavulsqa/m3e";
2
+ import { BRAND_SEED, DEFAULT_VARIANT } from "@/app/theme.config";
3
+
4
+ export type ContrastChoice = "standard" | "medium" | "high";
5
+ export type ModeChoice = "system" | "light" | "dark";
6
+ export type MotionChoice = "system" | "reduced" | "full";
7
+
8
+ export interface ThemeSettings {
9
+ seed: string;
10
+ variant: SchemeVariant;
11
+ contrast: ContrastChoice;
12
+ mode: ModeChoice;
13
+ motion: MotionChoice;
14
+ }
15
+
16
+ export const DEFAULT_THEME: Readonly<ThemeSettings> = {
17
+ seed: BRAND_SEED,
18
+ variant: DEFAULT_VARIANT,
19
+ contrast: "standard",
20
+ mode: "system",
21
+ motion: "system",
22
+ };
23
+
24
+ export const CONTRAST_LEVELS: Readonly<Record<ContrastChoice, number>> = {
25
+ standard: CONTRAST.standard,
26
+ medium: CONTRAST.medium,
27
+ high: CONTRAST.high,
28
+ };
29
+
30
+ const oneOf = <T extends string>(value: unknown, options: readonly T[], fallback: T): T =>
31
+ typeof value === "string" && (options as readonly string[]).includes(value)
32
+ ? (value as T)
33
+ : fallback;
34
+
35
+ /**
36
+ * Settings read back from storage are untrusted text: an older build, a hand-edited value, a
37
+ * variant this build no longer offers. Every field is checked and falls back on its own, so one
38
+ * bad value never costs the user the rest of their choices.
39
+ */
40
+ export function parseThemeSettings(raw: unknown): ThemeSettings {
41
+ const value = (typeof raw === "object" && raw !== null ? raw : {}) as Record<string, unknown>;
42
+ return {
43
+ seed:
44
+ typeof value.seed === "string" && isHexColor(value.seed) ? value.seed : DEFAULT_THEME.seed,
45
+ variant: oneOf(value.variant, SCHEME_VARIANTS, DEFAULT_THEME.variant),
46
+ contrast: oneOf(
47
+ value.contrast,
48
+ ["standard", "medium", "high"] as const,
49
+ DEFAULT_THEME.contrast,
50
+ ),
51
+ mode: oneOf(value.mode, ["system", "light", "dark"] as const, DEFAULT_THEME.mode),
52
+ motion: oneOf(value.motion, ["system", "reduced", "full"] as const, DEFAULT_THEME.motion),
53
+ };
54
+ }
55
+
56
+ export function reducedMotionOverride(motion: MotionChoice): boolean | null {
57
+ if (motion === "system") return null;
58
+ return motion === "reduced";
59
+ }
@@ -0,0 +1,8 @@
1
+ export type Tone = "primary" | "secondary" | "tertiary";
2
+
3
+ /** A container and its content colour, for decorative shapes that need a hue but carry no state. */
4
+ export const TONE_CLASSES: Readonly<Record<Tone, string>> = {
5
+ primary: "bg-primary-container text-on-primary-container",
6
+ secondary: "bg-secondary-container text-on-secondary-container",
7
+ tertiary: "bg-tertiary-container text-on-tertiary-container",
8
+ };
@@ -0,0 +1,104 @@
1
+ import { expect, test } from "vite-plus/test";
2
+ import { Kysely } from "kysely";
3
+ import { Migrator } from "kysely/migration";
4
+ import { createSqlJsDialect } from "@cavulsqa/mobile-db/testing";
5
+ import { runBenchmark } from "../src/domains/benchmark/benchmark.suite.js";
6
+ import { datasetRowCount, SCALE, TOTAL_ROWS } from "../src/domains/benchmark/benchmark.dataset.js";
7
+ import { migrations } from "../src/shared/database/migrations.js";
8
+ import type { Database } from "../src/shared/database/schema.js";
9
+
10
+ async function open(): Promise<Kysely<Database>> {
11
+ const db = new Kysely<Database>({ dialect: await createSqlJsDialect() });
12
+ await new Migrator({
13
+ db,
14
+ provider: { getMigrations: () => Promise.resolve(migrations) },
15
+ }).migrateToLatest();
16
+ return db;
17
+ }
18
+
19
+ /**
20
+ * The suite seeds roughly 100k rows across ten tables and runs 24 cases, several of them six-table
21
+ * joins. A broken case would otherwise surface halfway through a run on a phone, as a number nobody
22
+ * could interpret - so it executes here against real SQLite first.
23
+ *
24
+ * Slow by design: this is the only place the whole thing runs without a device.
25
+ */
26
+ test("the whole suite runs at scale and every case reports a timing", async () => {
27
+ const db = await open();
28
+
29
+ const seen: string[] = [];
30
+ const result = await runBenchmark(db, (name) => seen.push(name));
31
+
32
+ expect(result.cases.length).toBeGreaterThanOrEqual(27);
33
+ expect(result.seedMs, "the first run builds the dataset").toBeGreaterThan(0);
34
+ expect(result.rowsSeeded).toBeGreaterThanOrEqual(SCALE.orderLines + SCALE.stock);
35
+
36
+ for (const entry of result.cases) {
37
+ expect(entry.operations, entry.name).toBeGreaterThan(0);
38
+ expect(entry.msPerOperation, entry.name).toBeGreaterThanOrEqual(0);
39
+ expect(entry.worstMs, entry.name).toBeGreaterThanOrEqual(entry.medianMs);
40
+ }
41
+
42
+ // All six groups represented, or the suite is not covering what it claims to.
43
+ expect([...new Set(result.cases.map((entry) => entry.group))].sort()).toEqual([
44
+ "concurrency",
45
+ "join",
46
+ "read",
47
+ "schema",
48
+ "sync",
49
+ "transaction",
50
+ "write",
51
+ ]);
52
+
53
+ /**
54
+ * The sync cases measure how long a read waits, so a zero would mean the read never queued behind
55
+ * the write and the case proved nothing.
56
+ */
57
+ for (const entry of result.cases.filter((c) => c.group === "sync")) {
58
+ expect(entry.medianMs, entry.name).toBeGreaterThan(0);
59
+ }
60
+
61
+ // Read and join cases must actually return rows; a fast case that returns none proves nothing.
62
+ for (const entry of result.cases.filter((c) => c.group === "join")) {
63
+ expect(entry.rows, `${entry.name} returned no rows`).toBeGreaterThan(0);
64
+ }
65
+ }, 600_000);
66
+
67
+ /**
68
+ * The dataset has to survive a run untouched, or every subsequent run measures a different database
69
+ * and two runs cannot be compared.
70
+ */
71
+ test("a run leaves the dataset exactly as it found it", async () => {
72
+ const db = await open();
73
+ await runBenchmark(db);
74
+ const after = await datasetRowCount(db);
75
+
76
+ await runBenchmark(db);
77
+ expect(await datasetRowCount(db)).toBe(after);
78
+
79
+ // And nothing the write cases created is left behind.
80
+ const strays = await db
81
+ .selectFrom("bench_payment")
82
+ .select(({ fn }) => fn.countAll<number>().as("n"))
83
+ .where("method", "like", "bench-%")
84
+ .executeTakeFirstOrThrow();
85
+ expect(Number(strays.n)).toBe(0);
86
+ }, 600_000);
87
+
88
+ test("the scale constants add up to about 100k rows", () => {
89
+ expect(TOTAL_ROWS).toBeGreaterThan(100_000);
90
+ expect(SCALE.orderLines).toBe(40_000);
91
+ });
92
+
93
+ test("the benchmark never touches the app's tables", async () => {
94
+ const db = await open();
95
+ await db
96
+ .insertInto("customer")
97
+ .values({ created_at: "2026-01-01T00:00:00.000Z", name: "Untouched", city: "Algiers" })
98
+ .execute();
99
+
100
+ await runBenchmark(db);
101
+
102
+ const customers = await db.selectFrom("customer").select("name").execute();
103
+ expect(customers.map((row) => row.name)).toEqual(["Untouched"]);
104
+ }, 600_000);
@@ -0,0 +1,58 @@
1
+ // @vitest-environment happy-dom
2
+ import { expect, test, vi } from "vite-plus/test";
3
+ import {
4
+ CONTAINER_TRANSITION,
5
+ useContainerTransform,
6
+ } from "../src/shared/composables/navigation/useContainerTransform.js";
7
+
8
+ function rect(left: number, top: number, width: number, height: number) {
9
+ return {
10
+ left,
11
+ top,
12
+ width,
13
+ height,
14
+ right: left + width,
15
+ bottom: top + height,
16
+ x: left,
17
+ y: top,
18
+ } as DOMRect;
19
+ }
20
+
21
+ test("a card inside a view opens its page with the container transform, from its own box", () => {
22
+ const view = document.createElement("div");
23
+ view.className = "view";
24
+ const card = document.createElement("div");
25
+ card.className = "m3-card";
26
+ card.style.backgroundColor = "rgb(1, 2, 3)";
27
+ card.style.borderTopLeftRadius = "12px";
28
+ const label = document.createElement("span");
29
+ card.append(label);
30
+ view.append(card);
31
+ document.body.append(view);
32
+ vi.spyOn(view, "getBoundingClientRect").mockReturnValue(rect(0, 0, 360, 800));
33
+ vi.spyOn(card, "getBoundingClientRect").mockReturnValue(rect(16, 200, 328, 80));
34
+
35
+ const navigate = vi.fn();
36
+ const { open } = useContainerTransform();
37
+ label.addEventListener("click", (event) => open({ navigate } as never, event, "/x/"));
38
+ label.dispatchEvent(new MouseEvent("click", { bubbles: true }));
39
+
40
+ expect(navigate).toHaveBeenCalledWith("/x/", { transition: CONTAINER_TRANSITION });
41
+ const value = (name: string) => view.style.getPropertyValue(`--m3e-ct-${name}`);
42
+ expect([value("top"), value("left"), value("right"), value("bottom")]).toEqual([
43
+ "200px",
44
+ "16px",
45
+ "16px",
46
+ "520px",
47
+ ]);
48
+ expect(value("radius")).toBe("12px");
49
+ expect(value("color")).toBe("rgb(1, 2, 3)");
50
+ view.remove();
51
+ });
52
+
53
+ test("without a view to grow in, it navigates the ordinary way", () => {
54
+ const navigate = vi.fn();
55
+ const { open } = useContainerTransform();
56
+ open({ navigate } as never, document.createElement("div"), "/y/");
57
+ expect(navigate).toHaveBeenCalledWith("/y/");
58
+ });
@@ -0,0 +1,25 @@
1
+ import { expect, test } from "vite-plus/test";
2
+ import {
3
+ formatMobile,
4
+ isMobile,
5
+ mobileDigits,
6
+ passwordScore,
7
+ } from "../src/modules/gallery/composables/fieldFormats.js";
8
+
9
+ test("a password scores by length and by the kinds of character in it", () => {
10
+ expect(passwordScore("abc")).toBe(0);
11
+ expect(passwordScore("abcdefgh")).toBe(1);
12
+ expect(passwordScore("abcdefghijklmn")).toBe(1);
13
+ expect(passwordScore("Abcdefg1")).toBe(3);
14
+ expect(passwordScore("Abcdefgh1!xyz")).toBe(4);
15
+ });
16
+
17
+ test("a mobile number reads the same however its prefix was typed", () => {
18
+ expect(mobileDigits("0555 12 34 56")).toBe("555123456");
19
+ expect(mobileDigits("+213 555-123-456")).toBe("555123456");
20
+ expect(mobileDigits("5551234567")).toBe("555123456");
21
+ expect(formatMobile("555123456")).toBe("5 55 12 34 56");
22
+ expect(formatMobile("5551")).toBe("5 55 1");
23
+ expect(isMobile("555123456")).toBe(true);
24
+ expect(isMobile("212345678")).toBe(false);
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
+ });