@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.
- package/README.md +12 -1
- package/bin/create.mjs +18 -25
- package/lib/templates.mjs +56 -0
- package/package.json +13 -5
- package/templates/f7-app/CLAUDE.md +3 -0
- package/templates/f7-app/auto-imports.d.ts +15 -0
- package/templates/f7-app/package.json +6 -3
- package/templates/f7-app/src/App.vue +1 -0
- package/templates/f7-app/src/env.d.ts +1 -0
- package/templates/f7-app/src/main.ts +3 -0
- package/templates/f7-app/src/modules/demo/router/routes/demo.routes.ts +4 -16
- package/templates/f7-app/src/modules/home/router/routes/home.routes.ts +3 -11
- package/templates/f7-app/src/modules/settings/router/routes/settings.routes.ts +2 -6
- package/templates/f7-app/src/plugins/bootstrapError.ts +2 -0
- package/templates/f7-app/src/plugins/recorder.plugin.ts +155 -0
- package/templates/f7-app/src/router/global/global.routes.ts +2 -6
- package/templates/f7-app/src/shared/composables/useNavigationGuard.ts +48 -0
- package/templates/f7-app/src/shared/recorder/capacitorSink.ts +124 -0
- package/templates/f7-app/src/shared/utils/lazyRoute.ts +25 -0
- package/templates/f7-app/tests/lazyRoute.test.ts +30 -0
- package/templates/f7-app/tests/navigationGuard.test.ts +82 -0
- package/templates/f7-app/vite.config.ts +1 -0
- package/templates/m3e-app/.claude/rules/data-fetching.md +68 -0
- package/templates/m3e-app/.claude/rules/database.md +106 -0
- package/templates/m3e-app/.claude/rules/m3e-ui.md +48 -0
- package/templates/m3e-app/.claude/rules/modules.md +43 -0
- package/templates/m3e-app/.claude/rules/native.md +60 -0
- package/templates/m3e-app/.claude/skills/m3-expressive/SKILL.md +68 -0
- package/templates/m3e-app/.claude/skills/m3-expressive/color.md +44 -0
- package/templates/m3e-app/.claude/skills/m3-expressive/components.md +325 -0
- package/templates/m3e-app/.claude/skills/m3-expressive/layout.md +42 -0
- package/templates/m3e-app/.claude/skills/m3-expressive/motion.md +51 -0
- package/templates/m3e-app/.claude/skills/m3-expressive/shapes-type.md +48 -0
- package/templates/m3e-app/.claude/skills/m3-expressive/sources.md +53 -0
- package/templates/m3e-app/.claude/skills/module-architecture/SKILL.md +65 -0
- package/templates/m3e-app/.claude/skills/module-architecture/file-templates.md +178 -0
- package/templates/m3e-app/.claude/skills/reactive-data/SKILL.md +90 -0
- package/templates/m3e-app/.claude/skills/reactive-data/testing.md +55 -0
- package/templates/m3e-app/.env.example +32 -0
- package/templates/m3e-app/CLAUDE.md +93 -0
- package/templates/m3e-app/auto-imports.d.ts +851 -0
- package/templates/m3e-app/capacitor.config.ts +44 -0
- package/templates/m3e-app/components.d.ts +252 -0
- package/templates/m3e-app/index.html +16 -0
- package/templates/m3e-app/package.json +107 -0
- package/templates/m3e-app/src/App.vue +129 -0
- package/templates/m3e-app/src/app/pragmas.config.ts +35 -0
- package/templates/m3e-app/src/app/scroll.config.ts +20 -0
- package/templates/m3e-app/src/app/storage.config.ts +65 -0
- package/templates/m3e-app/src/app/tabs.ts +33 -0
- package/templates/m3e-app/src/app/theme.config.ts +51 -0
- package/templates/m3e-app/src/assets/css/app.css +18 -0
- package/templates/m3e-app/src/assets/css/base.css +53 -0
- package/templates/m3e-app/src/assets/css/layout/container-transform.css +113 -0
- package/templates/m3e-app/src/assets/css/layout/shell.css +69 -0
- package/templates/m3e-app/src/assets/css/layout/transitions.css +121 -0
- package/templates/m3e-app/src/assets/css/m3e.css +6 -0
- package/templates/m3e-app/src/assets/css/theme/framework7.css +18 -0
- package/templates/m3e-app/src/assets/css/theme/tailwind.css +365 -0
- package/templates/m3e-app/src/domains/benchmark/benchmark.dataset.ts +209 -0
- package/templates/m3e-app/src/domains/benchmark/benchmark.suite.ts +662 -0
- package/templates/m3e-app/src/domains/sales/sales.repository.ts +458 -0
- package/templates/m3e-app/src/env.d.ts +40 -0
- package/templates/m3e-app/src/locales/ar.json +1260 -0
- package/templates/m3e-app/src/locales/en.json +1260 -0
- package/templates/m3e-app/src/locales/fr.json +1260 -0
- package/templates/m3e-app/src/main.ts +41 -0
- package/templates/m3e-app/src/modules/demo/components/DemoBenchmark.vue +119 -0
- package/templates/m3e-app/src/modules/demo/components/DemoBusLog.vue +63 -0
- package/templates/m3e-app/src/modules/demo/components/DemoCreateOrderSheet.vue +203 -0
- package/templates/m3e-app/src/modules/demo/components/DemoMetricsSheet.vue +75 -0
- package/templates/m3e-app/src/modules/demo/components/DemoOrderList.vue +75 -0
- package/templates/m3e-app/src/modules/demo/components/DemoPipelineBenchmark.vue +65 -0
- package/templates/m3e-app/src/modules/demo/components/DemoStatCards.vue +71 -0
- package/templates/m3e-app/src/modules/demo/composables/useBenchmark.ts +139 -0
- package/templates/m3e-app/src/modules/demo/composables/useOrderStatus.ts +64 -0
- package/templates/m3e-app/src/modules/demo/composables/useReactiveDemo.ts +171 -0
- package/templates/m3e-app/src/modules/demo/router/routes/demo.routes.ts +22 -0
- package/templates/m3e-app/src/modules/demo/views/DemoView.vue +210 -0
- package/templates/m3e-app/src/modules/demo/views/OrderDetailView.vue +136 -0
- package/templates/m3e-app/src/modules/demo/views/OrderSearchView.vue +74 -0
- package/templates/m3e-app/src/modules/gallery/components/GalleryBlock.vue +16 -0
- package/templates/m3e-app/src/modules/gallery/components/GalleryCarouselTile.vue +71 -0
- package/templates/m3e-app/src/modules/gallery/components/GalleryCustomerForm.vue +76 -0
- package/templates/m3e-app/src/modules/gallery/components/GalleryProofOfDelivery.vue +69 -0
- package/templates/m3e-app/src/modules/gallery/components/agenda/AgendaDay.vue +106 -0
- package/templates/m3e-app/src/modules/gallery/components/agenda/AgendaMonth.vue +40 -0
- package/templates/m3e-app/src/modules/gallery/components/agenda/AgendaVisitList.vue +45 -0
- package/templates/m3e-app/src/modules/gallery/components/chat/ChatAttachSheet.vue +106 -0
- package/templates/m3e-app/src/modules/gallery/components/chat/ChatContactPicker.vue +85 -0
- package/templates/m3e-app/src/modules/gallery/components/chat/ChatInviteComposer.vue +131 -0
- package/templates/m3e-app/src/modules/gallery/components/chat/ChatPollComposer.vue +148 -0
- package/templates/m3e-app/src/modules/gallery/components/chat/ChatRecentPhotos.vue +62 -0
- package/templates/m3e-app/src/modules/gallery/components/inputs/InputsAccount.vue +79 -0
- package/templates/m3e-app/src/modules/gallery/components/inputs/InputsTags.vue +53 -0
- package/templates/m3e-app/src/modules/gallery/components/inputs/InputsVerification.vue +73 -0
- package/templates/m3e-app/src/modules/gallery/components/inputs/PasswordStrength.vue +37 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryButtons.vue +128 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryCarousels.vue +236 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryCharts.vue +84 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryFabs.vue +70 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryInputs.vue +219 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryNavigation.vue +141 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryOverlays.vue +368 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryPickers.vue +175 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryProgress.vue +118 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryScale.vue +103 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GallerySelection.vue +219 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryShapes.vue +48 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GallerySurfaces.vue +213 -0
- package/templates/m3e-app/src/modules/gallery/components/sections/GalleryTables.vue +185 -0
- package/templates/m3e-app/src/modules/gallery/components/surfaces/SurfacesContainerTransform.vue +46 -0
- package/templates/m3e-app/src/modules/gallery/composables/chatTeam.ts +18 -0
- package/templates/m3e-app/src/modules/gallery/composables/composeKind.ts +2 -0
- package/templates/m3e-app/src/modules/gallery/composables/currentPosition.ts +35 -0
- package/templates/m3e-app/src/modules/gallery/composables/demoOrders.ts +29 -0
- package/templates/m3e-app/src/modules/gallery/composables/featuredStories.ts +73 -0
- package/templates/m3e-app/src/modules/gallery/composables/fieldFormats.ts +36 -0
- package/templates/m3e-app/src/modules/gallery/composables/openExternal.ts +11 -0
- package/templates/m3e-app/src/modules/gallery/composables/routePlan.ts +90 -0
- package/templates/m3e-app/src/modules/gallery/composables/useAgenda.ts +61 -0
- package/templates/m3e-app/src/modules/gallery/composables/useChatCustomers.ts +31 -0
- package/templates/m3e-app/src/modules/gallery/composables/useChatDemo.ts +299 -0
- package/templates/m3e-app/src/modules/gallery/composables/useGallerySections.ts +230 -0
- package/templates/m3e-app/src/modules/gallery/composables/useLocalAttachments.ts +71 -0
- package/templates/m3e-app/src/modules/gallery/composables/useLocationShare.ts +38 -0
- package/templates/m3e-app/src/modules/gallery/composables/usePhotoScenes.ts +82 -0
- package/templates/m3e-app/src/modules/gallery/composables/useTreeDemo.ts +67 -0
- package/templates/m3e-app/src/modules/gallery/composables/wilayas.ts +67 -0
- package/templates/m3e-app/src/modules/gallery/router/routes/gallery.routes.ts +52 -0
- package/templates/m3e-app/src/modules/gallery/views/GalleryAgendaView.vue +117 -0
- package/templates/m3e-app/src/modules/gallery/views/GalleryChatView.vue +292 -0
- package/templates/m3e-app/src/modules/gallery/views/GalleryContactsView.vue +77 -0
- package/templates/m3e-app/src/modules/gallery/views/GalleryFeaturedView.vue +60 -0
- package/templates/m3e-app/src/modules/gallery/views/GalleryLoginView.vue +135 -0
- package/templates/m3e-app/src/modules/gallery/views/GalleryOnboardingView.vue +116 -0
- package/templates/m3e-app/src/modules/gallery/views/GallerySectionView.vue +27 -0
- package/templates/m3e-app/src/modules/gallery/views/GalleryTabsView.vue +135 -0
- package/templates/m3e-app/src/modules/gallery/views/GalleryView.vue +50 -0
- package/templates/m3e-app/src/modules/home/components/HomeHero.vue +57 -0
- package/templates/m3e-app/src/modules/home/composables/useHomeFeatures.ts +128 -0
- package/templates/m3e-app/src/modules/home/composables/useOpenFeature.ts +22 -0
- package/templates/m3e-app/src/modules/home/router/routes/home.routes.ts +21 -0
- package/templates/m3e-app/src/modules/home/views/FeatureDetailView.vue +66 -0
- package/templates/m3e-app/src/modules/home/views/HomeView.vue +47 -0
- package/templates/m3e-app/src/modules/settings/components/RolePalette.vue +38 -0
- package/templates/m3e-app/src/modules/settings/components/SeedSwatches.vue +41 -0
- package/templates/m3e-app/src/modules/settings/components/SettingsChoice.vue +43 -0
- package/templates/m3e-app/src/modules/settings/components/StudioPreview.vue +52 -0
- package/templates/m3e-app/src/modules/settings/router/routes/settings.routes.ts +17 -0
- package/templates/m3e-app/src/modules/settings/types.ts +7 -0
- package/templates/m3e-app/src/modules/settings/views/ColorStudioView.vue +166 -0
- package/templates/m3e-app/src/modules/settings/views/SettingsView.vue +157 -0
- package/templates/m3e-app/src/plugins/bootstrapError.ts +62 -0
- package/templates/m3e-app/src/plugins/capacitor/index.ts +14 -0
- package/templates/m3e-app/src/plugins/capacitor/useAndroidBackButton.ts +36 -0
- package/templates/m3e-app/src/plugins/capacitor/useKeyboard.ts +48 -0
- package/templates/m3e-app/src/plugins/capacitor/useSplashScreen.ts +43 -0
- package/templates/m3e-app/src/plugins/capacitor/useStatusBar.ts +16 -0
- package/templates/m3e-app/src/plugins/framework7.plugin.ts +36 -0
- package/templates/m3e-app/src/plugins/i18n.plugin.ts +87 -0
- package/templates/m3e-app/src/plugins/m3e.plugin.ts +22 -0
- package/templates/m3e-app/src/plugins/seed.plugin.ts +7 -0
- package/templates/m3e-app/src/plugins/sqlite.plugin.ts +32 -0
- package/templates/m3e-app/src/router/global/global.routes.ts +12 -0
- package/templates/m3e-app/src/router/index.ts +20 -0
- package/templates/m3e-app/src/shared/components/error/404.vue +13 -0
- package/templates/m3e-app/src/shared/components/layout/EmptyState.vue +30 -0
- package/templates/m3e-app/src/shared/components/layout/SectionHeader.vue +10 -0
- package/templates/m3e-app/src/shared/components/page/AppPage.vue +104 -0
- package/templates/m3e-app/src/shared/composables/navigation/useActiveTab.ts +39 -0
- package/templates/m3e-app/src/shared/composables/navigation/useContainerTransform.ts +117 -0
- package/templates/m3e-app/src/shared/composables/navigation/useNavigationGuard.ts +48 -0
- package/templates/m3e-app/src/shared/composables/navigation/useNavigationVisibility.ts +64 -0
- package/templates/m3e-app/src/shared/composables/navigation/useViewRouter.ts +25 -0
- package/templates/m3e-app/src/shared/composables/navigation/useWindowClass.ts +22 -0
- package/templates/m3e-app/src/shared/composables/theme/useThemeSettings.ts +155 -0
- package/templates/m3e-app/src/shared/database/candidates/capacitorSqlite.ts +36 -0
- package/templates/m3e-app/src/shared/database/candidates/index.ts +4 -0
- package/templates/m3e-app/src/shared/database/candidates/opfsSahPool.ts +25 -0
- package/templates/m3e-app/src/shared/database/candidates/types.ts +56 -0
- package/templates/m3e-app/src/shared/database/candidates/waSqlite.ts +56 -0
- package/templates/m3e-app/src/shared/database/database.ts +219 -0
- package/templates/m3e-app/src/shared/database/index.ts +3 -0
- package/templates/m3e-app/src/shared/database/migrations.ts +223 -0
- package/templates/m3e-app/src/shared/database/opfs.worker.ts +5 -0
- package/templates/m3e-app/src/shared/database/queries.ts +26 -0
- package/templates/m3e-app/src/shared/database/schema.ts +153 -0
- package/templates/m3e-app/src/shared/database/storage.ts +64 -0
- package/templates/m3e-app/src/shared/database/wa.worker.ts +5 -0
- package/templates/m3e-app/src/shared/utils/lazyRoute.ts +41 -0
- package/templates/m3e-app/src/shared/utils/resolvers/resolvers.ts +42 -0
- package/templates/m3e-app/src/shared/utils/textDirection.ts +22 -0
- package/templates/m3e-app/src/shared/utils/theme/themeSettings.ts +59 -0
- package/templates/m3e-app/src/shared/utils/tone.ts +8 -0
- package/templates/m3e-app/tests/benchmark.suite.test.ts +104 -0
- package/templates/m3e-app/tests/containerTransform.test.ts +58 -0
- package/templates/m3e-app/tests/fieldFormats.test.ts +25 -0
- package/templates/m3e-app/tests/lazyRoute.test.ts +30 -0
- package/templates/m3e-app/tests/locales.test.ts +73 -0
- package/templates/m3e-app/tests/migrations.test.ts +74 -0
- package/templates/m3e-app/tests/navigationGuard.test.ts +82 -0
- package/templates/m3e-app/tests/openDatabase.test.ts +54 -0
- package/templates/m3e-app/tests/sales.repository.test.ts +329 -0
- package/templates/m3e-app/tests/storage.test.ts +117 -0
- package/templates/m3e-app/tests/textDirection.test.ts +11 -0
- package/templates/m3e-app/tooling/linkedPackages.ts +112 -0
- package/templates/m3e-app/tsconfig.json +21 -0
- 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.
|