@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,104 @@
1
+ <template>
2
+ <F7Page
3
+ ref="page"
4
+ :name="props.name"
5
+ class="app-page"
6
+ @page:beforeout="leave"
7
+ :class="{ 'app-page--has-fab': $slots.fab }"
8
+ >
9
+ <M3TopAppBar
10
+ :title="props.title"
11
+ :subtitle="props.subtitle"
12
+ :variant="props.variant ?? (props.back ? 'small' : 'large')"
13
+ :scroll-behavior="props.scrollBehavior ?? SCROLL_BEHAVIOR.topAppBar"
14
+ >
15
+ <template v-if="props.back" #navigation>
16
+ <M3IconButton :label="t('shell.back')" @click="goBack">
17
+ <i-ms-arrow-back-rounded class="rtl:-scale-x-100" />
18
+ </M3IconButton>
19
+ </template>
20
+ <template v-if="$slots.actions" #actions><slot name="actions" /></template>
21
+ <template v-if="$slots.bottom" #bottom><slot name="bottom" /></template>
22
+ </M3TopAppBar>
23
+
24
+ <slot />
25
+
26
+ <template #fixed>
27
+ <div v-if="$slots.fab" class="app-page__fab"><slot name="fab" /></div>
28
+ <slot name="fixed" />
29
+ </template>
30
+ </F7Page>
31
+ </template>
32
+
33
+ <script setup lang="ts">
34
+ import type { ComponentPublicInstance } from "vue";
35
+ import { useHideOnScroll } from "@cavulsqa/m3e-vue";
36
+ import { SCROLL_BEHAVIOR, type ScrollBehaviorConfig } from "@/app/scroll.config";
37
+
38
+ /**
39
+ * Every routed screen is an `AppPage`: the Framework7 page (lifecycle, transitions, the scroller)
40
+ * with an M3 top app bar pinned in it and the right bottom padding for whatever floats above the
41
+ * content. A tab root gets the large flexible bar; a pushed page (`back`) gets the small bar with a
42
+ * back button and hides the navigation bar for as long as it is mounted.
43
+ *
44
+ * Slots: `actions` (trailing icon buttons in the bar), `bottom` (tabs or a filter pinned under the
45
+ * bar - Framework7's subnavbar), default (the content), `fab` (positioned
46
+ * above the navigation bar and the gesture area), `fixed` (anything else outside the scroller).
47
+ *
48
+ * The bars follow `SCROLL_BEHAVIOR`: a small bar slides away with the content (`scrollBehavior`
49
+ * overrides it per page), and a tab root's scrolling hides the navigation bar - only while that
50
+ * page is the one on screen, and never past the page leaving.
51
+ */
52
+ const props = defineProps<{
53
+ title: string;
54
+ subtitle?: string;
55
+ variant?: "small" | "medium" | "large";
56
+ back?: boolean;
57
+ name?: string;
58
+ scrollBehavior?: ScrollBehaviorConfig["topAppBar"];
59
+ }>();
60
+
61
+ const { t } = useI18n();
62
+ const page = useTemplateRef<ComponentPublicInstance>("page");
63
+ const pageElement = computed(() => (page.value?.$el as HTMLElement | undefined) ?? null);
64
+ const router = useViewRouter(pageElement);
65
+
66
+ if (props.back) useHiddenNavigation();
67
+
68
+ const content = shallowRef<HTMLElement | null>(null);
69
+ const { setScrollHidden } = useNavigationVisibility();
70
+ const onScreen = () =>
71
+ !!pageElement.value?.classList.contains("page-current") &&
72
+ !!pageElement.value.closest(".view.tab-active");
73
+ const navigationHide = useHideOnScroll(content, {
74
+ enabled: () => SCROLL_BEHAVIOR.navigationBar === "hideOnScroll" && !props.back && onScreen(),
75
+ });
76
+
77
+ watch(navigationHide.hidden, (hidden) => {
78
+ if (onScreen()) setScrollHidden(hidden);
79
+ });
80
+
81
+ onMounted(() => {
82
+ content.value = pageElement.value?.querySelector<HTMLElement>(":scope > .page-content") ?? null;
83
+ });
84
+
85
+ function leave() {
86
+ navigationHide.reset();
87
+ setScrollHidden(false);
88
+ }
89
+
90
+ function goBack() {
91
+ router.back();
92
+ }
93
+ </script>
94
+
95
+ <style scoped>
96
+ .app-page__fab {
97
+ position: absolute;
98
+ inset-inline-end: 16px;
99
+ bottom: calc(16px + var(--app-bottom-inset));
100
+ z-index: 10;
101
+ transition: bottom var(--md-sys-motion-spring-default-spatial-duration)
102
+ var(--md-sys-motion-spring-default-spatial);
103
+ }
104
+ </style>
@@ -0,0 +1,39 @@
1
+ import { shallowRef, type ShallowRef } from "vue";
2
+ import { START_TAB, tabs } from "@/app/tabs";
3
+
4
+ const active = shallowRef(START_TAB);
5
+
6
+ export interface ActiveTab {
7
+ active: Readonly<ShallowRef<string>>;
8
+ show: (id: string) => void;
9
+ /** Switches destination, then pushes `path` onto that destination's own history. */
10
+ open: (id: string, path: string) => void;
11
+ /** Back from the top of any other destination returns to the start one, as M3 navigation does. */
12
+ isStart: () => boolean;
13
+ }
14
+
15
+ /**
16
+ * The selected destination, shared by the shell's bar or rail and the Android back handler.
17
+ * Switching happens through Framework7's tab API so each view keeps its own history and the
18
+ * reactive queries see the `page:tabshow` / `page:tabhide` events they pause on.
19
+ */
20
+ export function useActiveTab(): ActiveTab {
21
+ const show = (id: string) => {
22
+ if (!tabs.some((tab) => tab.id === id)) return;
23
+ active.value = id;
24
+ f7ready((app) => {
25
+ app.tab.show(`#view-${id}`);
26
+ });
27
+ };
28
+ return {
29
+ active,
30
+ show,
31
+ open(id, path) {
32
+ show(id);
33
+ f7ready((app) => {
34
+ app.views.get(`#view-${id}`)?.router.navigate(path);
35
+ });
36
+ },
37
+ isStart: () => active.value === START_TAB,
38
+ };
39
+ }
@@ -0,0 +1,117 @@
1
+ import type { Router } from "framework7/types";
2
+
3
+ /** The Framework7 transition name; its CSS is `m3e-container` in `assets/css/layout/transitions.css`. */
4
+ export const CONTAINER_TRANSITION = "m3e-container";
5
+
6
+ const PENDING_FOR_MS = 1500;
7
+
8
+ /** Which element each detail page grew out of, so going back can shrink it into the same place. */
9
+ const origins = new WeakMap<HTMLElement, HTMLElement>();
10
+ let pending: { source: HTMLElement; at: number } | null = null;
11
+ let listening = false;
12
+
13
+ const CONTAINERS = ".m3-list-item, .m3-card";
14
+
15
+ function containerOf(event: Event): HTMLElement | null {
16
+ const target = (event.currentTarget ?? event.target) as Element | null;
17
+ return target instanceof Element ? target.closest<HTMLElement>(CONTAINERS) : null;
18
+ }
19
+
20
+ function viewOf(element: HTMLElement): HTMLElement | null {
21
+ return element.closest<HTMLElement>(".view");
22
+ }
23
+
24
+ /**
25
+ * Writes where the container starts (or ends) on the view: the source's box as insets from the
26
+ * view's edges, its corner radius and its colour. Physical insets, so right-to-left needs nothing.
27
+ */
28
+ const isClear = (color: string) => color === "transparent" || color === "rgba(0, 0, 0, 0)";
29
+
30
+ /**
31
+ * The element that draws the container: the source when it has a background, else the first
32
+ * element inside it of the same size that does - a list item paints its shape on an inner surface.
33
+ */
34
+ function surfaceOf(source: HTMLElement): HTMLElement {
35
+ if (!isClear(getComputedStyle(source).backgroundColor)) return source;
36
+ const outer = source.getBoundingClientRect();
37
+ for (const element of source.querySelectorAll<HTMLElement>("*")) {
38
+ const rect = element.getBoundingClientRect();
39
+ if (Math.abs(rect.width - outer.width) > 2 || Math.abs(rect.height - outer.height) > 2)
40
+ continue;
41
+ if (!isClear(getComputedStyle(element).backgroundColor)) return element;
42
+ }
43
+ return source;
44
+ }
45
+
46
+ function frame(view: HTMLElement, source: HTMLElement) {
47
+ const surface = surfaceOf(source);
48
+ const box = view.getBoundingClientRect();
49
+ const rect = surface.getBoundingClientRect();
50
+ const style = getComputedStyle(surface);
51
+ const color = style.backgroundColor;
52
+ const transparent = isClear(color);
53
+ const set = (name: string, value: string) => view.style.setProperty(`--m3e-ct-${name}`, value);
54
+ set("top", `${Math.max(0, rect.top - box.top)}px`);
55
+ set("left", `${Math.max(0, rect.left - box.left)}px`);
56
+ set("right", `${Math.max(0, box.right - rect.right)}px`);
57
+ set("bottom", `${Math.max(0, box.bottom - rect.bottom)}px`);
58
+ set("radius", style.borderTopLeftRadius || "0px");
59
+ set("color", transparent ? "var(--md-sys-color-surface-container-low)" : color);
60
+ }
61
+
62
+ function listen() {
63
+ if (listening || typeof document === "undefined") return;
64
+ listening = true;
65
+ document.addEventListener(
66
+ "page:mounted",
67
+ (event) => {
68
+ const page = event.target;
69
+ if (!pending || !(page instanceof HTMLElement)) return;
70
+ if (performance.now() - pending.at < PENDING_FOR_MS) origins.set(page, pending.source);
71
+ pending = null;
72
+ },
73
+ true,
74
+ );
75
+ document.addEventListener(
76
+ "page:beforeout",
77
+ (event) => {
78
+ const page = event.target;
79
+ if (!(page instanceof HTMLElement)) return;
80
+ const source = origins.get(page);
81
+ const view = viewOf(page);
82
+ if (source?.isConnected && view) frame(view, source);
83
+ },
84
+ true,
85
+ );
86
+ }
87
+
88
+ /**
89
+ * Material's container transform on Framework7's router: the tapped card grows into the page it
90
+ * opens - its box, corners and colour morph to the full screen while the page's content fades in -
91
+ * and going back, by the app bar, Android back or anything else that calls `router.back()`, shrinks
92
+ * the page into the card again, re-measured in case the list moved. Framework7 replays the
93
+ * transition a page was opened with, which is what makes every way back reverse it.
94
+ *
95
+ * @see https://m3.material.io/styles/motion/transitions/transition-patterns#b67cba74-6240-4663-a423-d537b6d21187
96
+ */
97
+ export function useContainerTransform() {
98
+ listen();
99
+
100
+ /**
101
+ * Opens `url` out of `from`: the element itself, or the event of a tap on it, whose closest list
102
+ * item or card becomes the container.
103
+ */
104
+ function open(router: Router.Router, from: Event | HTMLElement | null, url: string) {
105
+ const source = from instanceof Event ? containerOf(from) : from;
106
+ const view = source ? viewOf(source) : null;
107
+ if (!source || !view) {
108
+ router.navigate(url);
109
+ return;
110
+ }
111
+ frame(view, source);
112
+ pending = { source, at: performance.now() };
113
+ router.navigate(url, { transition: CONTAINER_TRANSITION });
114
+ }
115
+
116
+ return { open };
117
+ }
@@ -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,64 @@
1
+ import { computed, onMounted, onUnmounted, shallowRef, type ComputedRef } from "vue";
2
+
3
+ const keyboardOpen = shallowRef(false);
4
+ const scrollHidden = shallowRef(false);
5
+ const hiddenRequests = shallowRef(0);
6
+
7
+ export interface NavigationVisibility {
8
+ isVisible: ComputedRef<boolean>;
9
+ /**
10
+ * Whether the bar keeps its row in the layout. Hiding on scroll only slides it away: taking the
11
+ * row back would change the content's height under the finger, and near the end of a page that
12
+ * moves the scroll position, which shows the bar again, which moves it back.
13
+ */
14
+ isReserved: ComputedRef<boolean>;
15
+ setKeyboardOpen: (open: boolean) => void;
16
+ /** Hide-on-scroll: the page on screen is scrolling its content down. */
17
+ setScrollHidden: (hidden: boolean) => void;
18
+ /** Hides the bar until the returned function is called. Calls are counted, not toggled. */
19
+ hide: () => () => void;
20
+ }
21
+
22
+ /**
23
+ * Whether the compact navigation bar is on screen. The bar is the shell's, but what hides it - the
24
+ * keyboard, a pushed page - happens deep in the tree, so the state is shared rather than local.
25
+ */
26
+ export function useNavigationVisibility(): NavigationVisibility {
27
+ return {
28
+ isVisible: computed(
29
+ () => !keyboardOpen.value && !scrollHidden.value && hiddenRequests.value === 0,
30
+ ),
31
+ isReserved: computed(() => !keyboardOpen.value && hiddenRequests.value === 0),
32
+ setKeyboardOpen: (open) => {
33
+ keyboardOpen.value = open;
34
+ },
35
+ setScrollHidden: (hidden) => {
36
+ if (scrollHidden.value !== hidden) scrollHidden.value = hidden;
37
+ },
38
+ hide: () => {
39
+ hiddenRequests.value += 1;
40
+ let released = false;
41
+ return () => {
42
+ if (released) return;
43
+ released = true;
44
+ hiddenRequests.value -= 1;
45
+ };
46
+ },
47
+ };
48
+ }
49
+
50
+ /**
51
+ * For a pushed page: the bar leaves while it is mounted. The bar navigates between top-level
52
+ * destinations; on a detail screen it points somewhere you are not and steals a row from content.
53
+ */
54
+ export function useHiddenNavigation(): void {
55
+ const { hide } = useNavigationVisibility();
56
+ let release: (() => void) | null = null;
57
+ onMounted(() => {
58
+ release = hide();
59
+ });
60
+ onUnmounted(() => {
61
+ release?.();
62
+ release = null;
63
+ });
64
+ }
@@ -0,0 +1,25 @@
1
+ import type { Router } from "framework7/types";
2
+ import type { Ref } from "vue";
3
+
4
+ interface ViewElement extends HTMLElement {
5
+ f7View?: { router?: Router.Router };
6
+ }
7
+
8
+ /**
9
+ * The router of the view an element sits in, found through the DOM: framework7-vue hands a router
10
+ * to route components as a prop but provides nothing to their children, so a shared component
11
+ * would otherwise need it threaded through every screen.
12
+ */
13
+ export function useViewRouter(element: Ref<HTMLElement | null | undefined>) {
14
+ const router = (): Router.Router | undefined =>
15
+ (element.value?.closest(".view") as ViewElement | null)?.f7View?.router;
16
+
17
+ return {
18
+ router,
19
+ back: () => {
20
+ const current = router();
21
+ if (current && current.history.length > 1) current.back();
22
+ },
23
+ navigate: (url: string) => router()?.navigate(url),
24
+ };
25
+ }
@@ -0,0 +1,22 @@
1
+ import { useMediaQuery } from "@vueuse/core";
2
+ import { computed, type ComputedRef } from "vue";
3
+
4
+ export type WindowClass = "compact" | "medium" | "expanded" | "large";
5
+
6
+ /**
7
+ * M3's window size classes by width: compact under 600dp, medium to 839, expanded to 1199, large
8
+ * from 1200. Layout decisions key on these, never on device type - a phone in landscape is medium.
9
+ *
10
+ * @see https://m3.material.io/foundations/layout/applying-layout
11
+ */
12
+ export function useWindowClass(): ComputedRef<WindowClass> {
13
+ const medium = useMediaQuery("(min-width: 600px)");
14
+ const expanded = useMediaQuery("(min-width: 840px)");
15
+ const large = useMediaQuery("(min-width: 1200px)");
16
+ return computed(() => {
17
+ if (large.value) return "large";
18
+ if (expanded.value) return "expanded";
19
+ if (medium.value) return "medium";
20
+ return "compact";
21
+ });
22
+ }
@@ -0,0 +1,155 @@
1
+ import { applyColorScheme, applySystemTokens, setDarkMode } from "@cavulsqa/m3e-vue";
2
+ import { Capacitor } from "@capacitor/core";
3
+ import { StatusBar, Style } from "@capacitor/status-bar";
4
+ import { usePreferredDark, useStorage } from "@vueuse/core";
5
+ import {
6
+ computed,
7
+ effectScope,
8
+ readonly,
9
+ watch,
10
+ type ComputedRef,
11
+ type DeepReadonly,
12
+ type Ref,
13
+ } from "vue";
14
+ import { EXTRA_COLORS, TYPEFACE } from "@/app/theme.config";
15
+ import { m3e } from "@/plugins/m3e.plugin";
16
+ import {
17
+ CONTRAST_LEVELS,
18
+ DEFAULT_THEME,
19
+ parseThemeSettings,
20
+ reducedMotionOverride,
21
+ type ThemeSettings,
22
+ } from "@/shared/utils/theme/themeSettings";
23
+
24
+ const STORAGE_KEY = "app-theme";
25
+
26
+ interface ThemeState {
27
+ settings: Ref<ThemeSettings>;
28
+ isDark: ComputedRef<boolean>;
29
+ }
30
+
31
+ let state: ThemeState | null = null;
32
+
33
+ function readJson(raw: string): unknown {
34
+ try {
35
+ return JSON.parse(raw);
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ function paintColors(settings: ThemeSettings): void {
42
+ try {
43
+ applyColorScheme({
44
+ seed: settings.seed,
45
+ variant: settings.variant,
46
+ contrast: CONTRAST_LEVELS[settings.contrast],
47
+ extras: EXTRA_COLORS,
48
+ });
49
+ } catch (error) {
50
+ console.error("[theme] colour generation failed; falling back to the brand seed", error);
51
+ applyColorScheme({ seed: DEFAULT_THEME.seed, extras: EXTRA_COLORS });
52
+ }
53
+ }
54
+
55
+ async function paintStatusBar(isDark: boolean): Promise<void> {
56
+ if (!Capacitor.isNativePlatform()) return;
57
+ try {
58
+ await StatusBar.setStyle({ style: isDark ? Style.Dark : Style.Light });
59
+ } catch (error) {
60
+ console.warn("[theme] status bar style not applied", error);
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Starts the theme once, before the app mounts: tokens and both colour modes are in the document
66
+ * before the first paint, so there is no flash of an unthemed or wrong-mode screen. Colour changes
67
+ * from the studio coalesce to one regeneration per frame however fast a slider moves.
68
+ */
69
+ export function startTheme(): ThemeState {
70
+ if (state) return state;
71
+ const scope = effectScope(true);
72
+ state = scope.run(() => {
73
+ const settings = useStorage<ThemeSettings>(STORAGE_KEY, { ...DEFAULT_THEME }, undefined, {
74
+ serializer: {
75
+ read: (raw) => parseThemeSettings(readJson(raw)),
76
+ write: (value) => JSON.stringify(value),
77
+ },
78
+ writeDefaults: false,
79
+ mergeDefaults: false,
80
+ });
81
+ const prefersDark = usePreferredDark();
82
+ const isDark = computed(
83
+ () =>
84
+ settings.value.mode === "dark" || (settings.value.mode === "system" && prefersDark.value),
85
+ );
86
+
87
+ applySystemTokens({ brandTypeface: TYPEFACE, plainTypeface: TYPEFACE });
88
+ paintColors(settings.value);
89
+ setDarkMode(isDark.value);
90
+
91
+ let frame = 0;
92
+ watch(
93
+ () => [settings.value.seed, settings.value.variant, settings.value.contrast] as const,
94
+ () => {
95
+ if (frame) return;
96
+ frame = requestAnimationFrame(() => {
97
+ frame = 0;
98
+ paintColors(settings.value);
99
+ });
100
+ },
101
+ );
102
+
103
+ watch(
104
+ isDark,
105
+ (dark) => {
106
+ setDarkMode(dark);
107
+ void paintStatusBar(dark);
108
+ },
109
+ { immediate: true },
110
+ );
111
+
112
+ watch(
113
+ () => settings.value.motion,
114
+ (motion) => (m3e.config.reducedMotion.value = reducedMotionOverride(motion)),
115
+ { immediate: true },
116
+ );
117
+
118
+ return { settings, isDark };
119
+ })!;
120
+ return state;
121
+ }
122
+
123
+ export interface ThemeControls {
124
+ settings: DeepReadonly<Ref<ThemeSettings>>;
125
+ isDark: ComputedRef<boolean>;
126
+ update: (patch: Partial<ThemeSettings>) => void;
127
+ reset: () => void;
128
+ }
129
+
130
+ /** The persisted theme: read it anywhere, change it from the colour studio. */
131
+ export function useThemeSettings(): ThemeControls {
132
+ const { settings, isDark } = startTheme();
133
+ return {
134
+ settings: readonly(settings),
135
+ isDark,
136
+ update: (patch) => {
137
+ settings.value = parseThemeSettings({ ...settings.value, ...patch });
138
+ },
139
+ reset: () => {
140
+ settings.value = { ...DEFAULT_THEME };
141
+ },
142
+ };
143
+ }
144
+
145
+ /**
146
+ * Light status-bar icons while `active` holds - for a surface drawn on black, such as the photo
147
+ * browser - and the theme's own style back once it ends or the caller unmounts.
148
+ */
149
+ export function useDarkStatusBar(active: () => boolean): void {
150
+ const { isDark } = startTheme();
151
+ watch(active, (on) => void paintStatusBar(on || isDark.value));
152
+ onScopeDispose(() => {
153
+ if (active()) void paintStatusBar(isDark.value);
154
+ });
155
+ }
@@ -0,0 +1,36 @@
1
+ import { Capacitor } from "@capacitor/core";
2
+ import type { StorageCandidate, StorageProbe } from "./types";
3
+
4
+ /**
5
+ * `@capacitor-community/sqlite`: a real file behind a native bridge.
6
+ *
7
+ * Slower than the OPFS engine on every axis measured on a device - batched writes about 2.4x, the
8
+ * app's own screen queries about 1.3x - and it needs the plugin in the APK. It is here for the one
9
+ * thing the worker cannot do: the worker is serial, so a read waits behind an in-flight write,
10
+ * where this dialect keeps reads outside the write lock. Choose it for an app that writes
11
+ * continuously while the UI reads, or that needs SQLCipher or native access to the file.
12
+ */
13
+ function probeNativePlatform(): StorageProbe {
14
+ if (!Capacitor.isNativePlatform()) {
15
+ return {
16
+ supported: false,
17
+ reason: "the SQLite plugin is native-only; on web it needs jeep-sqlite and a separate store",
18
+ };
19
+ }
20
+ return { supported: true };
21
+ }
22
+
23
+ export const capacitorSqlite: StorageCandidate = {
24
+ id: "capacitor-sqlite",
25
+ label: "Capacitor SQLite · native plugin",
26
+ tradeoff:
27
+ "Needs the native plugin in the APK, and every statement crosses the bridge. Slower than the " +
28
+ "worker engines, but reads are not blocked by an in-flight write.",
29
+ durable: true,
30
+ evidence: "measured",
31
+ probe: probeNativePlatform,
32
+ createDialect: async () => {
33
+ const { createCapacitorDialect } = await import("@cavulsqa/mobile-db/capacitor");
34
+ return await createCapacitorDialect({ name: "app.sqlite3" });
35
+ },
36
+ };
@@ -0,0 +1,4 @@
1
+ export * from "./types";
2
+ export { opfsSahPool } from "./opfsSahPool";
3
+ export { waAccessHandlePool, waIdbBatchAtomic, waOriginPrivateFileSystem } from "./waSqlite";
4
+ export { capacitorSqlite } from "./capacitorSqlite";
@@ -0,0 +1,25 @@
1
+ import { createOpfsDialect } from "@cavulsqa/mobile-db/opfs";
2
+ import OpfsWorker from "../opfs.worker?worker";
3
+ import { probeOpfsCapable } from "../storage";
4
+ import type { StorageCandidate } from "./types";
5
+
6
+ /**
7
+ * Official `@sqlite.org/sqlite-wasm`, `opfs-sahpool` VFS, in a worker.
8
+ *
9
+ * The fastest thing measured on a real phone: reads 2.5-5.5x and batched writes ~9x against the
10
+ * Capacitor bridge, with much tighter worst cases. The SAH pool is chosen over the plain `opfs` VFS
11
+ * because it needs neither SharedArrayBuffer nor cross-origin isolation, and a Capacitor WebView
12
+ * cannot easily be given COOP/COEP headers.
13
+ */
14
+ export const opfsSahPool: StorageCandidate = {
15
+ id: "sqlite-wasm-opfs-sahpool",
16
+ label: "SQLite WASM · OPFS sync access handle pool",
17
+ tradeoff:
18
+ "One connection per directory, taken exclusively - a stale handle blocks the open. The worker " +
19
+ "is serial, so a read waits behind an in-flight write.",
20
+ durable: true,
21
+ evidence: "measured",
22
+ probe: probeOpfsCapable,
23
+ createDialect: () =>
24
+ Promise.resolve(createOpfsDialect({ worker: new OpfsWorker(), name: "app.sqlite3" })),
25
+ };